diff --git a/cmd/serve.go b/cmd/serve.go index f578a0cb5..731a2e735 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -29,6 +29,7 @@ func (c *serveCmd) Command() *cobra.Command { utils.StellarEnvironmentOption(&stellarEnvironment), utils.ServerBaseURLOption(&cfg.ServerBaseURL), utils.GraphQLComplexityLimitOption(&cfg.GraphQLComplexityLimit), + utils.GraphQLIntrospectionEnabledOption(&cfg.GraphQLIntrospectionEnabled), utils.AdminPortOption(&cfg.AdminPort), { Name: "port", diff --git a/cmd/utils/global_options.go b/cmd/utils/global_options.go index 2e34be257..07d7d6ec6 100644 --- a/cmd/utils/global_options.go +++ b/cmd/utils/global_options.go @@ -164,6 +164,20 @@ func GraphQLComplexityLimitOption(configKey *int) *config.ConfigOption { } } +// GraphQLIntrospectionEnabledOption controls whether GraphQL schema introspection (__schema, +// __type) is served. Defaults to disabled: introspection makes the full schema (including any +// unreleased or internal-only fields) discoverable to anyone who can reach the endpoint. +func GraphQLIntrospectionEnabledOption(configKey *bool) *config.ConfigOption { + return &config.ConfigOption{ + Name: "graphql-introspection-enabled", + Usage: "Whether to enable GraphQL schema introspection (__schema, __type). Disabled by default in production.", + OptType: types.Bool, + ConfigKey: configKey, + FlagDefault: false, + Required: false, + } +} + // DBPoolOptions returns config options for tuning the pgxpool connection pool. func DBPoolOptions(maxConns *int, minConns *int, maxConnLifetime *time.Duration, maxConnIdleTime *time.Duration) config.ConfigOptions { return config.ConfigOptions{ diff --git a/docs/operations.md b/docs/operations.md index bd65403cb..fe10902c9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,4 +1,48 @@ -# Operations +# Production Operations + +Reference for operating the wallet-backend's TimescaleDB-backed ingestion and API in production. + +## Production Database Checklist + +**CNPG `postgresql.parameters`:** + +| Parameter | Value | Why | +|---|---|---| +| `timescaledb.enable_chunk_skipping` | `on` | Off by default. Min/max ranges used for chunk skipping are recorded at compression time, so this must be on **before** the first ingest — enabling it later requires recompressing all existing history to backfill the ranges. | +| `timescaledb.enable_sparse_index_bloom` | `on` | Enables bloom-filter sparse indexes on compressed chunks. | +| `timescaledb.stats_max_chunks` | `2048` (2.28+ only) | Steady-state compressed chunk count at 1-year retention is ~1,820, which exceeds the 1,024 default stats cache. | + +**Instance sizing:** + +| Resource | Target | Why | +|---|---|---| +| Memory | >= 64 GB, `shared_buffers` 16 GB, `effective_cache_size` ~48 GB | The actively-ingested day's chunk indexes total ~14 GB, fitting the shared_buffers 25%-of-RAM guidance. | +| Volume | >= 1.5 TB | Steady state at 1-year retention is ~1 TB (roughly 2.3 GB/day compressed across hypertables, plus balance tables), with the remainder as WAL/headroom. | + +**Runtime flags** (`ingest` command): + +| Flag / env var | Value | Why | +|---|---|---| +| `RETENTION_PERIOD` | `1 year` | Chunks older than this are dropped automatically. | +| `COMPRESSION_COMPRESS_AFTER` | `1 day` | Leaves one uncompressed day of headroom so gap-backfill writes land in the rowstore instead of a compressed chunk. | +| `COMPRESSION_SCHEDULE_INTERVAL` | `1 day` | How often the compression job checks for eligible chunks. | +| `COMPRESSION_MAX_CHUNKS` | `10` | Caps chunks compressed per job run to prevent overlapping runs. | +| `CHUNK_INTERVAL` | `1 day` | Hypertable chunk time interval; only affects newly created chunks. | + +**GraphQL:** `GRAPHQL_INTROSPECTION_ENABLED` should be unset (or `false`) in production. Introspection makes the full schema, including any unreleased or internal-only fields, discoverable to anyone who can reach the endpoint. Leave it `true` in dev environments. + +## Alerting & Failure Modes + +**Alert on:** +- `wallet_ingestion_lag_ledgers` growth — ingestion falling behind the backend tip. +- Container restart counts — the ingest process exits immediately after a permanent failure. +- `wallet_db_query_errors_total{error_type="cursor_missing"}` — fires only when a protocol cursor that previously existed disappears, which is always a genuine incident (not a normal not-yet-initialized state). + +**Do not alert on** `wallet_ingestion_retry_exhaustions_total` directly — the process exits right after incrementing it, so by the time an alert fires the process has already restarted. Treat it as a post-mortem breadcrumb, not a live signal. + +**Poisoned ledgers:** a ledger that deterministically fails ingestion cannot self-heal by retrying — the ingest loop fails fast on permanent SQLSTATE classes, and recovery requires either a code fix or a manually-guarded cursor bump past the ledger. + +**TimescaleDB job health** has no application-level metric. Monitor `timescaledb_information.job_stats` and `timescaledb_information.job_errors` directly via SQL (through the CNPG metrics exporter or a scheduled check) to catch stalled or failing compression/retention jobs. **Hypertable index usage:** `pg_stat_user_indexes` on the `public` schema always reports `idx_scan = 0` for hypertable indexes — scans accrue on the per-chunk indexes in `_timescaledb_internal`, and the parent's index entry is only a template. To measure real usage (e.g. for an unused-index audit), aggregate chunk-index stats: @@ -12,3 +56,7 @@ GROUP BY 1, 2 ORDER BY 1, 3 DESC; ``` Every index in the schema must have a nameable consumer query; secondary indexes duplicating a primary key's column set are not kept (the PK's column order is chosen to serve its consumers directly — B-tree scans run forward or backward, so ASC/DESC variants of the same key are one index, not two). + +## Connection Topology Constraint + +Ingestion connects directly to the CNPG `rw` service and relies on pgx's server-side prepared-statement caching for performance. If a transaction-pooling PgBouncer/Pooler is ever placed in front of the ingest path, its pool mode must be switched to `QueryExecModeExec` — transaction pooling is incompatible with server-side prepared statements. The API server already runs in `Exec` mode. diff --git a/internal/data/ingest_store.go b/internal/data/ingest_store.go index 0c62ad13f..bca2a20ef 100644 --- a/internal/data/ingest_store.go +++ b/internal/data/ingest_store.go @@ -198,7 +198,7 @@ func (m *IngestStoreModel) CompareAndSwap(ctx context.Context, dbTx pgx.Tx, curs func (m *IngestStoreModel) UpdateMin(ctx context.Context, dbTx pgx.Tx, cursorName string, ledger uint32) error { const query = ` UPDATE ingest_store - SET value = LEAST(value::integer, $2)::text + SET value = LEAST(value::bigint, $2)::text WHERE key = $1 ` start := time.Now() diff --git a/internal/data/operations.go b/internal/data/operations.go index 140d17d5a..e5d347cf8 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -37,56 +37,6 @@ func (m *OperationModel) GetByID(ctx context.Context, id int64, columns string) return &operation, nil } -func (m *OperationModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *types.CompositeCursor, sortOrder SortOrder) ([]*types.OperationWithCursor, error) { - if err := validatePositiveLimit(limit); err != nil { - return nil, err - } - columns = prepareColumnsWithID(columns, types.Operation{}, "", "id", "ledger_created_at") - queryBuilder := strings.Builder{} - var args []interface{} - argIndex := 1 - - fmt.Fprintf(&queryBuilder, `SELECT %s, ledger_created_at as cursor_ledger_created_at, id as cursor_id FROM operations`, columns) - - // Decomposed cursor pagination: expands ROW() tuple comparison into OR clauses so - // TimescaleDB ColumnarScan can push filters into vectorized batch processing. - if cursor != nil { - clause, cursorArgs, nextIdx := buildDecomposedCursorCondition([]CursorColumn{ - {Name: "ledger_created_at", Value: cursor.LedgerCreatedAt}, - {Name: "id", Value: cursor.ID}, - }, sortOrder, argIndex) - queryBuilder.WriteString(" WHERE " + clause) - args = append(args, cursorArgs...) - argIndex = nextIdx - } - - if sortOrder == DESC { - queryBuilder.WriteString(" ORDER BY ledger_created_at DESC, id DESC") - } else { - queryBuilder.WriteString(" ORDER BY ledger_created_at ASC, id ASC") - } - - if limit != nil { - fmt.Fprintf(&queryBuilder, " LIMIT $%d", argIndex) - args = append(args, *limit) - } - query := queryBuilder.String() - if sortOrder == DESC { - query = fmt.Sprintf(`SELECT * FROM (%s) AS operations ORDER BY operations.cursor_ledger_created_at ASC, operations.cursor_id ASC`, query) - } - - start := time.Now() - operations, err := db.QueryManyPtrs[types.OperationWithCursor](ctx, m.DB, query, args...) - duration := time.Since(start).Seconds() - m.Metrics.QueryDuration.WithLabelValues("GetAll", "operations").Observe(duration) - m.Metrics.QueriesTotal.WithLabelValues("GetAll", "operations").Inc() - if err != nil { - m.Metrics.QueryErrors.WithLabelValues("GetAll", "operations", utils.GetDBErrorType(err)).Inc() - return nil, fmt.Errorf("getting operations: %w", err) - } - return operations, nil -} - // BatchGetByToIDs gets the operations that are associated with the given transaction ToIDs. // // # TOID (Total Order ID) Encoding - SEP-35 diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index 91bd653a4..2ad8f85d7 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -191,67 +191,6 @@ func Test_OperationModel_BatchCopy(t *testing.T) { } } -func TestOperationModel_GetAll(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - ctx := context.Background() - dbConnectionPool, err := db.OpenDBConnectionPool(ctx, dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - reg := prometheus.NewRegistry() - dbMetrics := metrics.NewMetrics(reg).DB - - m := &OperationModel{ - DB: dbConnectionPool, - Metrics: dbMetrics, - } - - now := time.Now() - - // Create test transactions first (hash is BYTEA, using valid 64-char hex strings) - testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") - testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") - testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") - _, err = dbConnectionPool.Exec(ctx, ` - INSERT INTO transactions (hash, to_id, fee_charged, result_code, ledger_number, ledger_created_at, is_fee_bump) - VALUES - ($2, 1, 100, 'TransactionResultCodeTxSuccess', 1, $1, false), - ($3, 2, 200, 'TransactionResultCodeTxSuccess', 2, $1, true), - ($4, 3, 300, 'TransactionResultCodeTxSuccess', 3, $1, false) - `, now, testHash1, testHash2, testHash3) - require.NoError(t, err) - - // Create test operations (IDs must be in TOID range for each transaction: (to_id, to_id + 4096)) - xdr1 := types.XDRBytea([]byte("xdr1")) - xdr2 := types.XDRBytea([]byte("xdr2")) - xdr3 := types.XDRBytea([]byte("xdr3")) - _, err = dbConnectionPool.Exec(ctx, ` - INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) - VALUES - (2, 'PAYMENT', $2, 'op_success', true, 1, $1), - (4098, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), - (8194, 'PAYMENT', $4, 'op_success', true, 3, $1) - `, now, xdr1, xdr2, xdr3) - require.NoError(t, err) - - // Test GetAll without limit (gets all operations) - operations, err := m.GetAll(ctx, "", nil, nil, ASC) - require.NoError(t, err) - assert.Len(t, operations, 3) - assert.Equal(t, int64(2), operations[0].CompositeCursor.ID) - assert.Equal(t, int64(4098), operations[1].CompositeCursor.ID) - assert.Equal(t, int64(8194), operations[2].CompositeCursor.ID) - - // Test GetAll with smaller limit - limit := int32(2) - operations, err = m.GetAll(ctx, "", &limit, nil, ASC) - require.NoError(t, err) - assert.Len(t, operations, 2) - assert.Equal(t, int64(2), operations[0].CompositeCursor.ID) - assert.Equal(t, int64(4098), operations[1].CompositeCursor.ID) -} - func TestOperationModel_BatchGetByToIDs(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -930,10 +869,6 @@ func TestOperationModel_MinimalProjectionHydratesLedgerCreatedAt(t *testing.T) { assert.True(t, now.Equal(op.LedgerCreatedAt), "GetByID with minimal projection must hydrate ledger_created_at") limit := int32(10) - ops, err := m.GetAll(ctx, "id", &limit, nil, DESC) - require.NoError(t, err) - require.Len(t, ops, 1) - assert.True(t, now.Equal(ops[0].Operation.LedgerCreatedAt), "GetAll with minimal projection must hydrate ledger_created_at") batched, err := m.BatchGetByToID(ctx, 4096, now, "id", &limit, nil, ASC) require.NoError(t, err) diff --git a/internal/data/query_utils.go b/internal/data/query_utils.go index 7d5ba698c..ce6d4e1b7 100644 --- a/internal/data/query_utils.go +++ b/internal/data/query_utils.go @@ -49,17 +49,6 @@ const ( DESC SortOrder = "DESC" ) -// validatePositiveLimit rejects a non-nil limit that is zero or negative. A caller-supplied -// page size can go negative (e.g. an int32-overflowed `first` upstream), and silently dropping -// the LIMIT clause in that case turns a bounded page read into an unbounded full-table read. -// limit == nil still means "no limit" (used internally) and is left untouched. -func validatePositiveLimit(limit *int32) error { - if limit != nil && *limit <= 0 { - return fmt.Errorf("limit must be positive, got %d", *limit) - } - return nil -} - // pgtypeTextFromNullString converts sql.NullString to pgtype.Text for efficient binary COPY. func pgtypeTextFromNullString(ns sql.NullString) pgtype.Text { return pgtype.Text{String: ns.String, Valid: ns.Valid} diff --git a/internal/data/sep41/allowances.go b/internal/data/sep41/allowances.go index d4c55ac98..c78c641d7 100644 --- a/internal/data/sep41/allowances.go +++ b/internal/data/sep41/allowances.go @@ -88,7 +88,7 @@ func (m *AllowanceModel) GetByOwner(ctx context.Context, ownerAddress string, li INNER JOIN contract_tokens ct ON ct.id = a.contract_id WHERE a.owner_id = $1 AND a.expiration_ledger >= COALESCE( - (SELECT value::integer FROM ingest_store WHERE key = $2), + (SELECT value::bigint FROM ingest_store WHERE key = $2), 0 )` args := []interface{}{types.AddressBytea(ownerAddress), latestIngestLedgerCursor} diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 2f55a2e18..67f431780 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -124,67 +124,6 @@ func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, account return stateChanges, nil } -func (m *StateChangeModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { - if err := validatePositiveLimit(limit); err != nil { - return nil, err - } - columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at") - var queryBuilder strings.Builder - var args []interface{} - argIndex := 1 - - fmt.Fprintf(&queryBuilder, ` - SELECT %s, ledger_created_at as cursor_ledger_created_at, to_id as cursor_to_id, operation_id as cursor_operation_id, state_change_id as cursor_state_change_id - FROM state_changes - `, columns) - - // Decomposed cursor pagination: expands ROW() tuple comparison into OR clauses so - // TimescaleDB ColumnarScan can push filters into vectorized batch processing. - if cursor != nil { - clause, cursorArgs, nextIdx := buildDecomposedCursorCondition([]CursorColumn{ - {Name: "ledger_created_at", Value: cursor.LedgerCreatedAt}, - {Name: "to_id", Value: cursor.ToID}, - {Name: "operation_id", Value: cursor.OperationID}, - {Name: "state_change_id", Value: cursor.StateChangeID}, - }, sortOrder, argIndex) - queryBuilder.WriteString(" WHERE " + clause) - args = append(args, cursorArgs...) - argIndex = nextIdx - } - - // Order with ledger_created_at as leading column for TimescaleDB ChunkAppend - if sortOrder == DESC { - queryBuilder.WriteString(" ORDER BY ledger_created_at DESC, to_id DESC, operation_id DESC, state_change_id DESC") - } else { - queryBuilder.WriteString(" ORDER BY ledger_created_at ASC, to_id ASC, operation_id ASC, state_change_id ASC") - } - - if limit != nil { - fmt.Fprintf(&queryBuilder, " LIMIT $%d", argIndex) - args = append(args, *limit) - } - - query := queryBuilder.String() - - // For backward pagination, wrap query to reverse the final order. - // We use cursor alias columns (e.g., "cursor_ledger_created_at") in ORDER BY to avoid - // ambiguity since the inner SELECT includes both original columns and cursor aliases. - if sortOrder == DESC { - query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY statechanges.cursor_ledger_created_at ASC, statechanges.cursor_to_id ASC, statechanges.cursor_operation_id ASC, statechanges.cursor_state_change_id ASC`, query) - } - - start := time.Now() - stateChanges, err := db.QueryManyPtrs[types.StateChangeWithCursor](ctx, m.DB, query, args...) - duration := time.Since(start).Seconds() - m.Metrics.QueryDuration.WithLabelValues("GetAll", "state_changes").Observe(duration) - m.Metrics.QueriesTotal.WithLabelValues("GetAll", "state_changes").Inc() - if err != nil { - m.Metrics.QueryErrors.WithLabelValues("GetAll", "state_changes", utils.GetDBErrorType(err)).Inc() - return nil, fmt.Errorf("getting all state changes: %w", err) - } - return stateChanges, nil -} - // BatchCopy inserts state changes using pgx's binary COPY protocol. // Uses native pgtype types for optimal performance (see https://github.com/jackc/pgx/issues/763). // diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 618e19f01..8660e4e8a 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -610,61 +610,6 @@ func TestStateChangeModel_BatchGetByAccountAddress_DuplicateHashTolerated(t *tes assert.True(t, toIDsFound[2]) } -func TestStateChangeModel_GetAll(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - ctx := context.Background() - dbConnectionPool, err := db.OpenDBConnectionPool(ctx, dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - reg := prometheus.NewRegistry() - dbMetrics := metrics.NewMetrics(reg).DB - - m := &StateChangeModel{ - DB: dbConnectionPool, - Metrics: dbMetrics, - } - - now := time.Now() - - address := keypair.MustRandom().Address() - - // Create test transactions first (hash is BYTEA) - testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") - testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") - testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") - _, err = dbConnectionPool.Exec(ctx, ` - INSERT INTO transactions (hash, to_id, fee_charged, result_code, ledger_number, ledger_created_at, is_fee_bump) - VALUES - ($2, 1, 100, 'TransactionResultCodeTxSuccess', 1, $1, false), - ($3, 2, 200, 'TransactionResultCodeTxSuccess', 2, $1, true), - ($4, 3, 300, 'TransactionResultCodeTxSuccess', 3, $1, false) - `, now, testHash1, testHash2, testHash3) - require.NoError(t, err) - - // Create test state changes - _, err = dbConnectionPool.Exec(ctx, ` - INSERT INTO state_changes (to_id, state_change_id, state_change_category, state_change_reason, ledger_created_at, ledger_number, account_id, operation_id) - VALUES - (1, 1, 'BALANCE', 'CREDIT', $1, 1, $2, 123), - (2, 1, 'BALANCE', 'CREDIT', $1, 2, $2, 456), - (3, 1, 'BALANCE', 'CREDIT', $1, 3, $2, 789) - `, now, types.AddressBytea(address)) - require.NoError(t, err) - - // Test GetAll without limit - stateChanges, err := m.GetAll(ctx, "", nil, nil, DESC) - require.NoError(t, err) - assert.Len(t, stateChanges, 3) - - // Test GetAll with limit - limit := int32(2) - stateChanges, err = m.GetAll(ctx, "", &limit, nil, DESC) - require.NoError(t, err) - assert.Len(t, stateChanges, 2) -} - func TestStateChangeModel_BatchGetByToIDs(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() diff --git a/internal/data/transactions.go b/internal/data/transactions.go index c547f3191..65ad71b28 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -38,57 +38,6 @@ func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns s return &transaction, nil } -func (m *TransactionModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *types.CompositeCursor, sortOrder SortOrder) ([]*types.TransactionWithCursor, error) { - if err := validatePositiveLimit(limit); err != nil { - return nil, err - } - columns = prepareColumnsWithID(columns, types.Transaction{}, "", "to_id", "ledger_created_at") - queryBuilder := strings.Builder{} - var args []interface{} - argIndex := 1 - - fmt.Fprintf(&queryBuilder, `SELECT %s, ledger_created_at as cursor_ledger_created_at, to_id as cursor_id FROM transactions`, columns) - - // Decomposed cursor pagination: expands ROW() tuple comparison into OR clauses so - // TimescaleDB ColumnarScan can push filters into vectorized batch processing. - if cursor != nil { - clause, cursorArgs, nextIdx := buildDecomposedCursorCondition([]CursorColumn{ - {Name: "ledger_created_at", Value: cursor.LedgerCreatedAt}, - {Name: "to_id", Value: cursor.ID}, - }, sortOrder, argIndex) - queryBuilder.WriteString(" WHERE " + clause) - args = append(args, cursorArgs...) - argIndex = nextIdx - } - - if sortOrder == DESC { - queryBuilder.WriteString(" ORDER BY ledger_created_at DESC, to_id DESC") - } else { - queryBuilder.WriteString(" ORDER BY ledger_created_at ASC, to_id ASC") - } - - if limit != nil { - fmt.Fprintf(&queryBuilder, " LIMIT $%d", argIndex) - args = append(args, *limit) - } - - query := queryBuilder.String() - if sortOrder == DESC { - query = fmt.Sprintf(`SELECT * FROM (%s) AS transactions ORDER BY transactions.cursor_ledger_created_at ASC, transactions.cursor_id ASC`, query) - } - - start := time.Now() - transactions, err := db.QueryManyPtrs[types.TransactionWithCursor](ctx, m.DB, query, args...) - duration := time.Since(start).Seconds() - m.Metrics.QueryDuration.WithLabelValues("GetAll", "transactions").Observe(duration) - m.Metrics.QueriesTotal.WithLabelValues("GetAll", "transactions").Inc() - if err != nil { - m.Metrics.QueryErrors.WithLabelValues("GetAll", "transactions", utils.GetDBErrorType(err)).Inc() - return nil, fmt.Errorf("getting transactions: %w", err) - } - return transactions, nil -} - // BatchGetByAccountAddress gets the transactions that are associated with a single account address. // Uses a MATERIALIZED CTE + LATERAL join pattern to allow TimescaleDB ChunkAppend optimization // on the transactions_accounts hypertable by ordering on ledger_created_at first. diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 0ae2e5a28..b2cd89a5c 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -222,54 +222,6 @@ func TestTransactionModel_GetByHash(t *testing.T) { assert.Equal(t, int64(1), transaction.ToID) } -func TestTransactionModel_GetAll(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - ctx := context.Background() - dbConnectionPool, err := db.OpenDBConnectionPool(ctx, dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - reg := prometheus.NewRegistry() - dbMetrics := metrics.NewMetrics(reg).DB - - m := &TransactionModel{ - DB: dbConnectionPool, - Metrics: dbMetrics, - } - - now := time.Now() - - // Create test transactions - testHash1 := types.HashBytea("1076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") - testHash2 := types.HashBytea("2076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") - testHash3 := types.HashBytea("3076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") - _, err = dbConnectionPool.Exec(ctx, ` - INSERT INTO transactions (hash, to_id, fee_charged, result_code, ledger_number, ledger_created_at, is_fee_bump) - VALUES - ($1, 1, 100, 'TransactionResultCodeTxSuccess', 1, $4, false), - ($2, 2, 200, 'TransactionResultCodeTxSuccess', 2, $4, true), - ($3, 3, 300, 'TransactionResultCodeTxSuccess', 3, $4, false) - `, testHash1, testHash2, testHash3, now) - require.NoError(t, err) - - // Test GetAll without specifying cursor and limit (gets all transactions) - transactions, err := m.GetAll(ctx, "", nil, nil, ASC) - require.NoError(t, err) - assert.Len(t, transactions, 3) - assert.Equal(t, int64(1), transactions[0].CompositeCursor.ID) - assert.Equal(t, int64(2), transactions[1].CompositeCursor.ID) - assert.Equal(t, int64(3), transactions[2].CompositeCursor.ID) - - // Test GetAll with smaller limit - limit := int32(2) - transactions, err = m.GetAll(ctx, "", &limit, nil, ASC) - require.NoError(t, err) - assert.Len(t, transactions, 2) - assert.Equal(t, int64(1), transactions[0].CompositeCursor.ID) - assert.Equal(t, int64(2), transactions[1].CompositeCursor.ID) -} - func TestTransactionModel_BatchGetByAccountAddress(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -558,10 +510,6 @@ func TestTransactionModel_MinimalProjectionHydratesLedgerCreatedAt(t *testing.T) assert.True(t, now.Equal(tx.LedgerCreatedAt), "GetByHash with minimal projection must hydrate ledger_created_at") limit := int32(10) - txs, err := m.GetAll(ctx, "hash", &limit, nil, DESC) - require.NoError(t, err) - require.Len(t, txs, 1) - assert.True(t, now.Equal(txs[0].Transaction.LedgerCreatedAt), "GetAll with minimal projection must hydrate ledger_created_at") // End-to-end: the hydrated timestamp pins the child operations lookup. ops, err := opModel.BatchGetByToID(ctx, tx.ToID, tx.LedgerCreatedAt, "id", &limit, nil, ASC) diff --git a/internal/db/migrations/2025-06-10.2-transactions.sql b/internal/db/migrations/2025-06-10.2-transactions.sql index af0bbd882..b3aae2416 100644 --- a/internal/db/migrations/2025-06-10.2-transactions.sql +++ b/internal/db/migrations/2025-06-10.2-transactions.sql @@ -25,14 +25,11 @@ SELECT enable_chunk_skipping('transactions', 'to_id'); -- non-overlapping chunks at plan time. SELECT enable_chunk_skipping('transactions', 'ledger_number'); --- Replaces the default single-column (ledger_created_at DESC) index TimescaleDB --- auto-creates on the hypertable's partition column. The root GraphQL connection --- sorts by (ledger_created_at DESC, to_id DESC), which the default index can't serve --- for a top-N first page (it still needs a heapsort on to_id within each timestamp). --- This composite index matches the API sort key directly and makes the auto-created --- one redundant. +-- TimescaleDB's default single-column index on the partition column. Retention drops chunks by +-- range metadata, and no query path filters this table by bare ledger_created_at (reads go through +-- the primary key, idx_transactions_hash, or the transactions_accounts table), so it has zero +-- consumers. DROP INDEX IF EXISTS transactions_ledger_created_at_idx; -CREATE INDEX idx_transactions_created_at_sort ON transactions (ledger_created_at DESC, to_id DESC); CREATE INDEX idx_transactions_hash ON transactions(hash); diff --git a/internal/db/migrations/2025-06-10.3-operations.sql b/internal/db/migrations/2025-06-10.3-operations.sql index 9b0abd0df..c148f5545 100644 --- a/internal/db/migrations/2025-06-10.3-operations.sql +++ b/internal/db/migrations/2025-06-10.3-operations.sql @@ -34,14 +34,10 @@ CREATE TABLE operations ( SELECT enable_chunk_skipping('operations', 'id'); --- Replaces the default single-column (ledger_created_at DESC) index TimescaleDB --- auto-creates on the hypertable's partition column. The root GraphQL connection --- sorts by (ledger_created_at DESC, id DESC), which the default index can't serve --- for a top-N first page (it still needs a heapsort on id within each timestamp). --- This composite index matches the API sort key directly and makes the auto-created --- one redundant. +-- TimescaleDB's default single-column index on the partition column. Retention drops chunks by +-- range metadata, and no query path filters this table by bare ledger_created_at (reads go through +-- the primary key or the operations_accounts table), so it has zero consumers. DROP INDEX IF EXISTS operations_ledger_created_at_idx; -CREATE INDEX idx_operations_created_at_sort ON operations (ledger_created_at DESC, id DESC); -- Table: operations_accounts (TimescaleDB hypertable for automatic cleanup with retention) CREATE TABLE operations_accounts ( diff --git a/internal/metrics/dataloader.go b/internal/metrics/dataloader.go new file mode 100644 index 000000000..00ab32061 --- /dev/null +++ b/internal/metrics/dataloader.go @@ -0,0 +1,42 @@ +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +// DataloaderMetrics holds Prometheus collectors for GraphQL dataloader batching behavior. +// Batching efficiency is otherwise invisible in production: these expose how many keys land +// in each batch and how long the batch's fetch takes, per loader. +type DataloaderMetrics struct { + // BatchSize observes the number of distinct keys collected into one batch, labeled by loader. + // A distribution clustered at 1 signals a loader that never gets to batch (each key fires its + // own fetch); a distribution near loaderBatchCapacity signals a page size the loader is + // straining to keep up with. + // + // histogram_quantile(0.50, rate(wallet_graphql_dataloader_batch_size_bucket[5m])) + // + // Labels: loader. + BatchSize *prometheus.HistogramVec + + // FetchDuration observes the wall time of one batch's fetch execution, spanning every + // shape-group fetch the batch was split into. Labels: loader. + // + // histogram_quantile(0.99, rate(wallet_graphql_dataloader_fetch_duration_seconds_bucket[5m])) + FetchDuration *prometheus.HistogramVec +} + +// newDataloaderMetrics creates and registers all dataloader Prometheus collectors. +func newDataloaderMetrics(reg prometheus.Registerer) *DataloaderMetrics { + m := &DataloaderMetrics{ + BatchSize: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "wallet_graphql_dataloader_batch_size", + Help: "Number of distinct keys collected into one dataloader batch.", + Buckets: []float64{1, 2, 5, 10, 25, 50, 100}, + }, []string{"loader"}), + FetchDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "wallet_graphql_dataloader_fetch_duration_seconds", + Help: "Duration of a dataloader batch's fetch execution, spanning all shape-group fetches.", + Buckets: []float64{.001, .0025, .005, .01, .025, .05, .1, .25, 1}, + }, []string{"loader"}), + } + reg.MustRegister(m.BatchSize, m.FetchDuration) + return m +} diff --git a/internal/metrics/dataloader_test.go b/internal/metrics/dataloader_test.go new file mode 100644 index 000000000..3ccfa4b8a --- /dev/null +++ b/internal/metrics/dataloader_test.go @@ -0,0 +1,82 @@ +package metrics + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDataloaderMetrics_Registration(t *testing.T) { + reg := prometheus.NewRegistry() + m := newDataloaderMetrics(reg) + + require.NotNil(t, m.BatchSize) + require.NotNil(t, m.FetchDuration) +} + +func TestDataloaderMetrics_HistogramBuckets(t *testing.T) { + reg := prometheus.NewRegistry() + m := newDataloaderMetrics(reg) + + m.BatchSize.WithLabelValues("OperationsByToIDLoader").Observe(10) + m.FetchDuration.WithLabelValues("OperationsByToIDLoader").Observe(0.01) + + families, err := reg.Gather() + require.NoError(t, err) + + bucketCounts := map[string]int{ + "wallet_graphql_dataloader_batch_size": 7, + "wallet_graphql_dataloader_fetch_duration_seconds": 9, + } + + for _, f := range families { + expected, ok := bucketCounts[f.GetName()] + if !ok { + continue + } + for _, metric := range f.GetMetric() { + h := metric.GetHistogram() + require.NotNil(t, h, "histogram nil for %s", f.GetName()) + assert.Len(t, h.GetBucket(), expected, "bucket count mismatch for %s", f.GetName()) + } + } +} + +func TestDataloaderMetrics_GoldenExposition(t *testing.T) { + reg := prometheus.NewRegistry() + m := newDataloaderMetrics(reg) + + m.BatchSize.WithLabelValues("OperationsByToIDLoader").Observe(50) + + expected := strings.NewReader(` + # HELP wallet_graphql_dataloader_batch_size Number of distinct keys collected into one dataloader batch. + # TYPE wallet_graphql_dataloader_batch_size histogram + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="1"} 0 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="2"} 0 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="5"} 0 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="10"} 0 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="25"} 0 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="50"} 1 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="100"} 1 + wallet_graphql_dataloader_batch_size_bucket{loader="OperationsByToIDLoader",le="+Inf"} 1 + wallet_graphql_dataloader_batch_size_sum{loader="OperationsByToIDLoader"} 50 + wallet_graphql_dataloader_batch_size_count{loader="OperationsByToIDLoader"} 1 + `) + err := testutil.CollectAndCompare(m.BatchSize, expected) + require.NoError(t, err) +} + +func TestDataloaderMetrics_Lint(t *testing.T) { + reg := prometheus.NewRegistry() + m := newDataloaderMetrics(reg) + + for _, c := range []prometheus.Collector{m.BatchSize, m.FetchDuration} { + problems, err := testutil.CollectAndLint(c) + require.NoError(t, err) + assert.Empty(t, problems) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index bee78e568..03f9a9fb9 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -9,14 +9,15 @@ import ( // Metrics holds all Prometheus collectors for the wallet-backend service. type Metrics struct { - DB *DBMetrics - RPC *RPCMetrics - Ingestion *IngestionMetrics - HTTP *HTTPMetrics - GraphQL *GraphQLMetrics - Auth *AuthMetrics - Migration *MigrationMetrics - registry *prometheus.Registry + DB *DBMetrics + RPC *RPCMetrics + Ingestion *IngestionMetrics + HTTP *HTTPMetrics + GraphQL *GraphQLMetrics + Auth *AuthMetrics + Migration *MigrationMetrics + Dataloader *DataloaderMetrics + registry *prometheus.Registry } // NewMetrics creates a new Metrics instance with all sub-struct collectors registered. @@ -28,14 +29,15 @@ func NewMetrics(reg *prometheus.Registry) *Metrics { collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), ) return &Metrics{ - DB: newDBMetrics(reg), - RPC: newRPCMetrics(reg), - Ingestion: newIngestionMetrics(reg), - HTTP: newHTTPMetrics(reg), - GraphQL: NewGraphQLMetrics(reg), - Auth: newAuthMetrics(reg), - Migration: newMigrationMetrics(reg), - registry: reg, + DB: newDBMetrics(reg), + RPC: newRPCMetrics(reg), + Ingestion: newIngestionMetrics(reg), + HTTP: newHTTPMetrics(reg), + GraphQL: NewGraphQLMetrics(reg), + Auth: newAuthMetrics(reg), + Migration: newMigrationMetrics(reg), + Dataloader: newDataloaderMetrics(reg), + registry: reg, } } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index eff258f7c..5729e7100 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -21,6 +21,7 @@ func TestNewMetrics(t *testing.T) { require.NotNil(t, m.GraphQL) require.NotNil(t, m.Auth) require.NotNil(t, m.Migration) + require.NotNil(t, m.Dataloader) assert.Same(t, reg, m.Registry()) } diff --git a/internal/serve/complexity_regression_test.go b/internal/serve/complexity_regression_test.go new file mode 100644 index 000000000..2bbb9e997 --- /dev/null +++ b/internal/serve/complexity_regression_test.go @@ -0,0 +1,243 @@ +package serve + +import ( + "context" + "testing" + + "github.com/99designs/gqlgen/complexity" + "github.com/99designs/gqlgen/graphql" + "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2" + + generated "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + resolvers "github.com/stellar/wallet-backend/internal/serve/graphql/resolvers" +) + +// The queries below mirror the shapes pkg/wbclient/queries.go builds for freighter's +// account-detail views (buildAccountBalancesQuery / buildAccountTransactionsWithOpsAndStateChangesQuery): +// max page size (first: 100, the resolver-enforced cap, see graphqlutils.DefaultPageLimit and the +// resolvers' page-size clamping) with every field of every concrete implementer selected. A single +// query combining both at first:100 is not representative: freighter issues them as separate +// requests, and combined they total ~9500, over budget on their own. Each is tested independently +// as its own worst case. + +const freighterAccountBalancesQuery = ` + query { + accountByAddress(address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF") { + balances(first: 100) { + edges { + node { + balance + tokenId + tokenType + ... on NativeBalance { + minimumBalance + buyingLiabilities + sellingLiabilities + numSubentries + lastModifiedLedger + } + ... on TrustlineBalance { + code + issuer + type + limit + buyingLiabilities + sellingLiabilities + lastModifiedLedger + isAuthorized + isAuthorizedToMaintainLiabilities + } + ... on SACBalance { + code + issuer + decimals + isAuthorized + isClawbackEnabled + } + ... on SEP41Balance { + name + symbol + decimals + lastModifiedLedger + } + ... on LiquidityPoolBalance { + liquidityPoolId + reserves { asset amount } + lastModifiedLedger + } + } + cursor + } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + } +` + +// freighterAccountTransactionsQuery is the "full-detail account-history query" referenced in the +// comments above addComplexityCalculation in serve.go: it selects a full set of Transaction scalar +// fields, plus the AccountTransactionEdge-only operations/stateChanges fields (plain lists, no +// first/last args) with a broad field selection across every BaseStateChange implementer. This is +// the query the AccountTransactionEdge no-multiplier design in addComplexityCalculation exists to protect. +const freighterAccountTransactionsQuery = ` + query { + accountByAddress(address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF") { + transactions(first: 100) { + edges { + node { + hash + feeCharged + resultCode + ledgerNumber + ledgerCreatedAt + isFeeBump + ingestedAt + } + cursor + operations { + id + operationType + operationXdr + resultCode + successful + ledgerNumber + ledgerCreatedAt + ingestedAt + } + stateChanges { + type + reason + ingestedAt + ledgerCreatedAt + ledgerNumber + ... on StandardBalanceChange { + standardBalanceTokenId: tokenId + amount + toMuxedId + } + ... on AccountChange { + funderAddress + deployerAddress + destinationAddress + } + ... on SignerChange { + signerAddress + signerWeights + } + ... on SignerThresholdsChange { + thresholds + } + ... on MetadataChange { + metadataKeyValue: keyValue + } + ... on FlagsChange { + flags + } + ... on TrustlineChange { + trustlineTokenId: tokenId + limit + trustlineLiquidityPoolId: liquidityPoolId + } + ... on ReservesChange { + sponsoredAddress + sponsorAddress + sponsoredData + sponsoredTrustline + claimableBalanceId + liquidityPoolId + } + ... on BalanceAuthorizationChange { + balanceAuthTokenId: tokenId + balanceAuthLiquidityPoolId: liquidityPoolId + flags + } + } + } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + } +` + +// newComplexityCalculationSchema builds an ExecutableSchema wired with the production complexity +// config, with no DB or server behind it: complexity.Calculate only walks the query AST against the +// registered Complexity funcs and never invokes a resolver, so a zero-value Resolver is sufficient. +func newComplexityCalculationSchema(t *testing.T) graphql.ExecutableSchema { + t.Helper() + + cfg := generated.Config{Resolvers: &resolvers.Resolver{}} + addComplexityCalculation(&cfg) + return generated.NewExecutableSchema(cfg) +} + +// TestFreighterFullDetailQueriesStayUnderComplexityLimit locks in that freighter's two heaviest +// account-detail queries stay under the prod GRAPHQL_COMPLEXITY_LIMIT=6000. This only holds because +// AccountTransactionEdge.operations/stateChanges have no complexity multiplier registered (see +// TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultiplier below); if that ever +// changes, this test starts failing too. +func TestFreighterFullDetailQueriesStayUnderComplexityLimit(t *testing.T) { + es := newComplexityCalculationSchema(t) + + testCases := []struct { + name string + query string + // computed complexity on record at time of writing, for both cases well under the + // GRAPHQL_COMPLEXITY_LIMIT=6000 production limit: balances=3901, transactions=5601. + floor int + }{ + {name: "account balances, first:100, every Balance implementer field", query: freighterAccountBalancesQuery, floor: 1000}, + {name: "account transactions, first:100, embedded operations+stateChanges per edge", query: freighterAccountTransactionsQuery, floor: 1000}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + doc, gerr := gqlparser.LoadQueryWithRules(es.Schema(), tc.query, nil) + require.Empty(t, gerr) + + c := complexity.Calculate(context.Background(), es, doc.Operations[0], nil) + t.Logf("%s: computed complexity = %d", tc.name, c) + + require.Greater(t, c, tc.floor, "query should be substantial enough to be a meaningful worst case; a near-zero value likely means LoadQuery silently dropped selections") + require.Less(t, c, 6000, "freighter's full-detail query must stay under the prod complexity limit (GRAPHQL_COMPLEXITY_LIMIT=6000)") + }) + } +} + +// TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultiplier guards the +// invariant documented in serve.go: AccountTransactionEdge.operations/stateChanges must never get a +// complexity multiplier, or freighter's full-detail account-history query breaks the prod limit. +func TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultiplier(t *testing.T) { + es := newComplexityCalculationSchema(t) + ctx := context.Background() + + _, ok := es.Complexity(ctx, "AccountTransactionEdge", "operations", 100, map[string]any{}) + require.False(t, ok, "AccountTransactionEdge.operations must have NO complexity multiplier registered") + + _, ok = es.Complexity(ctx, "AccountTransactionEdge", "stateChanges", 100, map[string]any{}) + require.False(t, ok, "AccountTransactionEdge.stateChanges must have NO complexity multiplier registered") + + // Positive control: Transaction.operations (a different type's field of the same name) SHOULD + // have a multiplier. If this fails, the two assertions above are checking the wrong thing. + _, ok = es.Complexity(ctx, "Transaction", "operations", 100, map[string]any{}) + require.True(t, ok, "Transaction.operations should have a complexity multiplier; if not, this guard test cannot distinguish 'no multiplier' from 'field does not exist'") + + // Break-detection: prove the assertions above actually discriminate. Register the naive x50 + // multiplier a well-meaning future edit might add (matching the pattern every other paginated + // field in addComplexityCalculation uses) and confirm `ok` flips to true, and that the resulting + // schema pushes freighter's transactions query over the prod limit. + regressedCfg := generated.Config{Resolvers: &resolvers.Resolver{}} + addComplexityCalculation(®ressedCfg) + regressedCfg.Complexity.AccountTransactionEdge.Operations = func(childComplexity int) int { return childComplexity * 50 } + regressedCfg.Complexity.AccountTransactionEdge.StateChanges = func(childComplexity int) int { return childComplexity * 50 } + regressedES := generated.NewExecutableSchema(regressedCfg) + + _, ok = regressedES.Complexity(ctx, "AccountTransactionEdge", "operations", 100, map[string]any{}) + require.True(t, ok, "sanity check on the break-detection config: the multiplier should be registered") + + doc, gerr := gqlparser.LoadQueryWithRules(regressedES.Schema(), freighterAccountTransactionsQuery, nil) + require.Empty(t, gerr) + regressed := complexity.Calculate(ctx, regressedES, doc.Operations[0], nil) + t.Logf("freighter transactions query complexity with a hypothetical AccountTransactionEdge multiplier = %d", regressed) + require.Greater(t, regressed, 6000, "this confirms the guard above is load-bearing: without it, the freighter transactions query blows the prod complexity limit") +} diff --git a/internal/serve/complexity_test.go b/internal/serve/complexity_test.go index d5f34c8d9..ef693187c 100644 --- a/internal/serve/complexity_test.go +++ b/internal/serve/complexity_test.go @@ -221,32 +221,36 @@ func TestGraphQLComplexityAccountingUsesSharedDefaultsAndExplicitArgs(t *testing expectedMessage string }{ { - name: "root pagination without explicit args uses shared default page limit", - limit: 149, + name: "account transactions without explicit args uses shared default page limit", + limit: 150, query: `query { - transactions { - edges { - node { - hash + accountByAddress(address: "` + complexityTestAccountAddress + `") { + transactions { + edges { + node { + hash + } } } } }`, - expectedMessage: "operation has complexity 150, which exceeds the limit of 149", + expectedMessage: "operation has complexity 151, which exceeds the limit of 150", }, { - name: "root first argument is used in complexity calculation", - limit: 5, + name: "account transactions first argument is used in complexity calculation", + limit: 6, query: `query { - transactions(first: 2) { - edges { - node { - hash + accountByAddress(address: "` + complexityTestAccountAddress + `") { + transactions(first: 2) { + edges { + node { + hash + } } } } }`, - expectedMessage: "operation has complexity 6, which exceeds the limit of 5", + expectedMessage: "operation has complexity 7, which exceeds the limit of 6", }, { name: "nested last argument is used in complexity calculation", diff --git a/internal/serve/depth_limit_test.go b/internal/serve/depth_limit_test.go new file mode 100644 index 000000000..7023b6565 --- /dev/null +++ b/internal/serve/depth_limit_test.go @@ -0,0 +1,46 @@ +package serve + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildRecursiveAccountsQuery builds a query that nests repetitions levels deep by chaining +// Transaction.accounts -> Account.transactions -> edges -> node (a Transaction again), which lets +// an otherwise-shallow schema be nested arbitrarily deep. first: 1 on each transactions connection +// keeps complexity from also blowing up, so the test isolates the depth limit specifically. +func buildRecursiveAccountsQuery(repetitions int, txHash string) string { + inner := "hash" + for i := 0; i < repetitions; i++ { + inner = fmt.Sprintf(`accounts { transactions(first: 1) { edges { node { %s } } } }`, inner) + } + return fmt.Sprintf(`query { transactionByHash(hash: "%s") { %s } }`, txHash, inner) +} + +func TestGraphQLDepthLimitRejectsDeeplyNestedQueries(t *testing.T) { + // A very high complexity limit isolates the depth check: DepthLimit is registered before + // FixedComplexityLimit in handler(), so a query this deep is rejected on depth before + // complexity is even evaluated. + h := newGraphQLTestHandler(t, 1_000_000_000) + + t.Run("shallow query passes", func(t *testing.T) { + query := buildRecursiveAccountsQuery(1, complexityTestTxHash) + resp := performGraphQLRequest(t, h, query) + require.Empty(t, resp.Errors) + }) + + t.Run("deeply nested query is rejected with QUERY_TOO_DEEP, not masked", func(t *testing.T) { + // repetitions=5 adds 4 levels each (accounts, transactions, edges, node) on top of + // transactionByHash(1) and the leaf hash(1): 1 + 5*4 + 1 = 22, over MaxQueryDepth(15). + query := buildRecursiveAccountsQuery(5, complexityTestTxHash) + resp := performGraphQLRequest(t, h, query) + + require.Len(t, resp.Errors, 1) + assert.Equal(t, "QUERY_TOO_DEEP", resp.Errors[0].Extensions["code"]) + assert.Contains(t, resp.Errors[0].Message, "exceeds the limit of 15") + assert.Equal(t, "null", string(resp.Data)) + }) +} diff --git a/internal/serve/graphql/README.md b/internal/serve/graphql/README.md index 19e33a8fc..3a16508d9 100644 --- a/internal/serve/graphql/README.md +++ b/internal/serve/graphql/README.md @@ -36,7 +36,7 @@ curl -X POST http://localhost:8080/graphql \ **Schema Introspection:** -You can explore the full schema using GraphQL introspection: +You can explore the full schema using GraphQL introspection, when enabled: ```graphql query { @@ -49,18 +49,17 @@ query { } ``` +Introspection (`__schema`, `__type`) is **disabled by default** — it exposes the full schema, including any unreleased or internal-only fields, to anyone who can reach the endpoint. Enable it with the `--graphql-introspection-enabled` flag or `GRAPHQL_INTROSPECTION_ENABLED` environment variable. Production deployments should leave it disabled; dev environments typically enable it. + ## Queries -The GraphQL API provides six root queries for accessing blockchain data. Account balances are fetched through `accountByAddress`: +The GraphQL API provides three root queries for accessing blockchain data — `transactionByHash`, `accountByAddress`, and `operationById`. Account balances are fetched through `accountByAddress`: | # | Query | Description | |---|-------|-------------| | 1 | [`transactionByHash`](#1-get-transaction-by-hash) | Get a specific transaction by its hash | -| 2 | [`transactions`](#2-list-all-transactions) | List all transactions with pagination | -| 3 | [`accountByAddress`](#3-get-account-by-address) | Get account info and related data | -| 4 | [`operations`](#4-list-all-operations) | List all operations with pagination | -| 5 | [`operationById`](#5-get-operation-by-id) | Get a specific operation by ID | -| 6 | [`stateChanges`](#6-list-state-changes) | List all state changes with pagination | +| 2 | [`accountByAddress`](#2-get-account-by-address) | Get account info and related data | +| 3 | [`operationById`](#3-get-operation-by-id) | Get a specific operation by ID | ### 1. Get Transaction by Hash @@ -96,40 +95,7 @@ query GetTransaction { } ``` -### 2. List All Transactions - -Query transactions with cursor-based pagination. - -```graphql -query ListTransactions { - transactions(first: 10, after: "cursor123") { - edges { - node { - hash - ledgerNumber - ledgerCreatedAt - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } -} -``` - -**Pagination Parameters:** -- `first: Int` - Return the first N items (forward pagination) -- `after: String` - Return items after this cursor -- `last: Int` - Return the last N items (backward pagination) -- `before: String` - Return items before this cursor - -Note that you can only use `first/after` and `last/before`. Any other combination will result in an error. - -### 3. Get Account by Address +### 2. Get Account by Address Retrieve account information and related data. @@ -205,58 +171,7 @@ The `stateChanges` field on Account supports an optional `filter` parameter with | `category` | `String` | Filter by state change category (e.g., `BALANCE`, `ACCOUNT`, `SIGNER`, `TRUSTLINE`, `RESERVES`) | | `reason` | `String` | Filter by state change reason (e.g., `CREDIT`, `DEBIT`, `CREATE`, `MERGE`, `ADD`, `REMOVE`) | -### 4. List All Operations - -Query operations across all transactions. - -```graphql -query ListOperations { - operations(first: 20) { - edges { - node { - id - operationType - operationXdr - resultCode - successful - ledgerNumber - ledgerCreatedAt - - # Parent transaction - transaction { - hash - envelopeXdr - } - - # Related accounts - accounts { - address - } - } - cursor - } - pageInfo { - hasNextPage - endCursor - } - } -} -``` - -**Operation Types:** - -The `operationType` field supports all Stellar operation types: -- `CREATE_ACCOUNT`, `PAYMENT`, `PATH_PAYMENT_STRICT_RECEIVE`, `PATH_PAYMENT_STRICT_SEND` -- `MANAGE_SELL_OFFER`, `CREATE_PASSIVE_SELL_OFFER`, `MANAGE_BUY_OFFER` -- `SET_OPTIONS`, `CHANGE_TRUST`, `ALLOW_TRUST`, `ACCOUNT_MERGE` -- `MANAGE_DATA`, `BUMP_SEQUENCE` -- `CREATE_CLAIMABLE_BALANCE`, `CLAIM_CLAIMABLE_BALANCE` -- `BEGIN_SPONSORING_FUTURE_RESERVES`, `END_SPONSORING_FUTURE_RESERVES`, `REVOKE_SPONSORSHIP` -- `CLAWBACK`, `CLAWBACK_CLAIMABLE_BALANCE`, `SET_TRUST_LINE_FLAGS` -- `LIQUIDITY_POOL_DEPOSIT`, `LIQUIDITY_POOL_WITHDRAW` -- `INVOKE_HOST_FUNCTION`, `EXTEND_FOOTPRINT_TTL`, `RESTORE_FOOTPRINT` (Soroban) - -### 5. Get Operation by ID +### 3. Get Operation by ID Retrieve a specific operation by its ID. @@ -294,91 +209,20 @@ query GetOperation { } ``` -### 6. List State Changes - -Query all state changes. - -```graphql -query ListStateChanges { - stateChanges(first: 50) { - edges { - node { - # Common fields (available on all state change types) - type - reason - ledgerNumber - ledgerCreatedAt - ingestedAt - - account { - address - } - - operation { - id - operationType - } - - transaction { - hash - } - - # Type-specific fields using fragments - ... on StandardBalanceChange { - tokenId - amount - } - - ... on AccountChange { - funderAddress # Address that funded the account (for CREATE reason) - } - - ... on SignerChange { - signerAddress - signerWeights - } - - ... on SignerThresholdsChange { - thresholds - } - - ... on MetadataChange { - keyValue - } - - ... on FlagsChange { - flags - } - - ... on TrustlineChange { - tokenId - limit - keyValue # JSON containing additional trustline data - } - - ... on ReservesChange { - sponsoredAddress - sponsorAddress - keyValue # JSON containing additional reserve data - } +**Operation Types:** - ... on BalanceAuthorizationChange { - tokenId - flags - keyValue - } - } - cursor - } - pageInfo { - hasNextPage - endCursor - } - } -} -``` +The `operationType` field supports all Stellar operation types: +- `CREATE_ACCOUNT`, `PAYMENT`, `PATH_PAYMENT_STRICT_RECEIVE`, `PATH_PAYMENT_STRICT_SEND` +- `MANAGE_SELL_OFFER`, `CREATE_PASSIVE_SELL_OFFER`, `MANAGE_BUY_OFFER` +- `SET_OPTIONS`, `CHANGE_TRUST`, `ALLOW_TRUST`, `ACCOUNT_MERGE` +- `MANAGE_DATA`, `BUMP_SEQUENCE` +- `CREATE_CLAIMABLE_BALANCE`, `CLAIM_CLAIMABLE_BALANCE` +- `BEGIN_SPONSORING_FUTURE_RESERVES`, `END_SPONSORING_FUTURE_RESERVES`, `REVOKE_SPONSORSHIP` +- `CLAWBACK`, `CLAWBACK_CLAIMABLE_BALANCE`, `SET_TRUST_LINE_FLAGS` +- `LIQUIDITY_POOL_DEPOSIT`, `LIQUIDITY_POOL_WITHDRAW` +- `INVOKE_HOST_FUNCTION`, `EXTEND_FOOTPRINT_TTL`, `RESTORE_FOOTPRINT` (Soroban) -### 7. Get Account Balances +### 4. Get Account Balances Retrieve account balances through a Relay-style connection, including native XLM, classic trustlines, and contract tokens. @@ -553,8 +397,8 @@ This query returns structured GraphQL errors with error codes in the `extensions | Error Code | Description | |------------|-------------| | `INVALID_ADDRESS` | The provided address is not a valid Stellar account (G...) or contract (C...) address | -| `RPC_UNAVAILABLE` | Failed to fetch balance data from Stellar RPC | -| `INTERNAL_ERROR` | An unexpected error occurred while processing the balance request | +| `BAD_USER_INPUT` | `first`/`last` exceeds the page size cap, or an invalid pagination argument combination was given | +| `INTERNAL_ERROR` | An unexpected error occurred while fetching or processing balance data (storage or RPC failure) | **Error Response Example:** @@ -585,28 +429,32 @@ The API uses **Relay-style cursor-based pagination** for all list queries. This ```graphql # Get first page query { - transactions(first: 10) { - edges { - node { hash } - cursor - } - pageInfo { - hasNextPage - endCursor + accountByAddress(address: "GABC...") { + transactions(first: 10) { + edges { + node { hash } + cursor + } + pageInfo { + hasNextPage + endCursor + } } } } # Get next page query { - transactions(first: 10, after: "endCursorFromPreviousPage") { - edges { - node { hash } - cursor - } - pageInfo { - hasNextPage - endCursor + accountByAddress(address: "GABC...") { + transactions(first: 10, after: "endCursorFromPreviousPage") { + edges { + node { hash } + cursor + } + pageInfo { + hasNextPage + endCursor + } } } } @@ -617,28 +465,32 @@ query { ```graphql # Get last page query { - transactions(last: 10) { - edges { - node { hash } - cursor - } - pageInfo { - hasPreviousPage - startCursor + accountByAddress(address: "GABC...") { + transactions(last: 10) { + edges { + node { hash } + cursor + } + pageInfo { + hasPreviousPage + startCursor + } } } } # Get previous page query { - transactions(last: 10, before: "startCursorFromCurrentPage") { - edges { - node { hash } - cursor - } - pageInfo { - hasPreviousPage - startCursor + accountByAddress(address: "GABC...") { + transactions(last: 10, before: "startCursorFromCurrentPage") { + edges { + node { hash } + cursor + } + pageInfo { + hasPreviousPage + startCursor + } } } } @@ -650,6 +502,10 @@ query { - `startCursor: String` - Cursor of the first item in the page - `endCursor: String` - Cursor of the last item in the page +**Page Size Limits:** + +Every connection in the schema — account-scoped (`Account.transactions`/`operations`/`stateChanges`/`balances`/`sep41Allowances`) and nested (`Transaction.operations`/`stateChanges`, `Operation.stateChanges`) — caps `first`/`last` at **100**. A page size above the cap is rejected with a `BAD_USER_INPUT` error rather than silently clamped, so callers get an explicit signal instead of a smaller-than-requested page. + ## State Changes State changes represent modifications to an account's state. The API uses an **interface-based design** where all state changes implement the `BaseStateChange` interface. @@ -700,20 +556,22 @@ Reasons provide context for why a state change occurred: ```graphql query GetBalanceChanges { - stateChanges(first: 100) { - edges { - node { - type - reason + accountByAddress(address: "GABC...") { + stateChanges(first: 100) { + edges { + node { + type + reason - ... on StandardBalanceChange { - tokenId - amount - account { - address - } - transaction { - hash + ... on StandardBalanceChange { + tokenId + amount + account { + address + } + transaction { + hash + } } } } @@ -795,7 +653,7 @@ Several state change fields return JSON-formatted strings containing old and new ## Error Handling -The GraphQL API returns structured errors with detailed information: +The GraphQL API returns structured errors with an `extensions.code` field so clients can branch on error type without parsing the message. **Error Response Format:** @@ -803,11 +661,12 @@ The GraphQL API returns structured errors with detailed information: { "errors": [ { - "message": "Account already exists", + "message": "invalid transaction hash format: must be a 64-character hex string", "extensions": { - "code": "ACCOUNT_ALREADY_EXISTS" + "code": "INVALID_TRANSACTION_HASH", + "hash": "not-a-hash" }, - "path": ["registerAccount"] + "path": ["transactionByHash"] } ], "data": null @@ -834,6 +693,24 @@ Some errors include additional context in the `extensions` field. For example, w } ``` +**Error Codes:** + +| Error Code | Meaning | +|------------|---------| +| `BAD_USER_INPUT` | Client-correctable validation failure — an invalid pagination combination, a page size over the cap, or similar | +| `INVALID_ADDRESS` | The provided address is not a valid Stellar account (G...) or contract (C...) address | +| `INVALID_TRANSACTION_HASH` | The provided hash is not a 64-character hex string | +| `INTERNAL_ERROR` | A sanitized, generic failure from a specific resolver (e.g. the balances query) that already masks its own internal detail | +| `GRAPHQL_VALIDATION_FAILED` | The query failed schema validation (unknown field, bad argument, ...) | +| `GRAPHQL_PARSE_FAILED` | The query failed to parse | +| `COMPLEXITY_LIMIT_EXCEEDED` | The query's computed complexity exceeds the configured limit (see [Complexity Limits](#2-complexity-limits)) | +| `QUERY_TOO_DEEP` | The query's selection set nests deeper than the depth limit (see [Depth Limit](#3-depth-limit)) | +| `INTERNAL_SERVER_ERROR` | An unmasked internal failure — see below | + +**Error Masking:** + +Any error surfaced without one of the codes above is treated as an internal failure: the server logs the underlying error server-side and returns a generic `"internal server error"` message under `INTERNAL_SERVER_ERROR` instead of forwarding the raw error text to the client. This prevents a bare SQL driver error, a wrapped Go error, or other internal detail (query text, table/column names, etc.) from leaking to callers. + ## Performance Features The GraphQL API is optimized for production use with several performance enhancements: @@ -852,15 +729,17 @@ For example, without dataloader, the following query would: However, with dataloader, the individual DB calls to get operations get converted to a single DB call for all batched operations for all transactions. ```graphql query ListTransactions { - transactions(first: 5, after: "cursor123") { - edges { - node { - operations { - id - operationType - operationXdr - ledgerNumber - ledgerCreatedAt + accountByAddress(address: "GABC...") { + transactions(first: 5, after: "cursor123") { + edges { + node { + operations { + id + operationType + operationXdr + ledgerNumber + ledgerCreatedAt + } } } } @@ -870,24 +749,49 @@ query ListTransactions { ### 2. Complexity Limits -Queries are limited by a configurable complexity score (default: **1000**) to prevent resource exhaustion. Complexity is calculated based on: +Queries are limited by a configurable complexity score to prevent resource exhaustion. Complexity is calculated based on: - Number of fields requested - Pagination parameters (`first`/`last` multiplied by field complexity) -The complexity limit can be configured via the `--graphql-complexity-limit` flag or the `GRAPHQL_COMPLEXITY_LIMIT` environment variable. +The complexity limit is set via the `--graphql-complexity-limit` flag (see `cmd/utils/global_options.go` for the built-in default) or the `GRAPHQL_COMPLEXITY_LIMIT` environment variable; deployments commonly override the built-in default to fit their own query patterns. If a query exceeds the limit, you'll receive an error: ```json { "errors": [ { - "message": "operation has complexity 1100, which exceeds the limit of 1000" + "message": "operation has complexity 1100, which exceeds the limit of 1000", + "extensions": { + "code": "COMPLEXITY_LIMIT_EXCEEDED" + } + } + ] +} +``` + +### 3. Depth Limit + +Independent of the complexity limit, queries are also limited by selection-set nesting depth (default: **15**). A chain of `first: 1` connections costs only ~1 in complexity per level regardless of how deep it goes, so depth is capped separately to reject pathologically deep queries that would otherwise slip under the complexity budget. Fragment spreads are resolved against the query's fragments before measuring depth, so nesting can't be hidden behind a fragment indirection. + +If a query exceeds the limit, you'll receive an error with code `QUERY_TOO_DEEP`: +```json +{ + "errors": [ + { + "message": "operation has depth 18, which exceeds the limit of 15", + "extensions": { + "code": "QUERY_TOO_DEEP" + } } ] } ``` -### 3. Automatic Persisted Queries (APQ) +### 4. Request Timeout + +Each request's context is bounded to **30 seconds**. A resolver or database query still running when the timeout elapses is canceled and the request fails; this bounds worst-case resource usage per request independent of the complexity and depth limits. + +### 5. Automatic Persisted Queries (APQ) Reduces bandwidth by allowing clients to send query hashes instead of full query strings: @@ -895,7 +799,7 @@ Reduces bandwidth by allowing clients to send query hashes instead of full query # First request: Send full query with hash POST /graphql { - "query": "{ transactions(first: 10) { ... } }", + "query": "{ accountByAddress(address: \"GABC...\") { transactions(first: 10) { ... } } }", "extensions": { "persistedQuery": { "version": 1, @@ -916,18 +820,20 @@ POST /graphql } ``` -### 4. Field Selection Optimization +### 6. Field Selection Optimization The API only queries database columns that are requested in the GraphQL query, reducing unnecessary data transfer: ```graphql # Only queries 'hash' and 'ledgerNumber' columns query { - transactions(first: 10) { - edges { - node { - hash - ledgerNumber + accountByAddress(address: "GABC...") { + transactions(first: 10) { + edges { + node { + hash + ledgerNumber + } } } } diff --git a/internal/serve/graphql/dataloaders/account_loaders.go b/internal/serve/graphql/dataloaders/account_loaders.go index 5116c12d6..ddcbdcaf9 100644 --- a/internal/serve/graphql/dataloaders/account_loaders.go +++ b/internal/serve/graphql/dataloaders/account_loaders.go @@ -7,6 +7,7 @@ import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" ) type AccountColumnsKey struct { @@ -18,7 +19,7 @@ type AccountColumnsKey struct { // accountsByToIDLoader creates a dataloader for fetching accounts by transaction ToID // This prevents N+1 queries when multiple transactions request their accounts // The loader batches multiple transaction ToIDs into a single database query -func accountsByToIDLoader(models *data.Models) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { +func accountsByToIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { return newOneToManyLoader( func(ctx context.Context, keys []AccountColumnsKey) ([]*types.AccountWithToID, error) { toIDs := make([]int64, len(keys)) @@ -37,13 +38,16 @@ func accountsByToIDLoader(models *data.Models) *dataloadgen.Loader[AccountColumn func(item *types.AccountWithToID) types.Account { return item.Account }, + accountColumnsKeyShape, + "AccountsByToIDLoader", + m, ) } // accountsByOperationIDLoader creates a dataloader for fetching accounts by operation ID // This prevents N+1 queries when multiple operations request their accounts // The loader batches multiple operation IDs into a single database query -func accountsByOperationIDLoader(models *data.Models) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { +func accountsByOperationIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { return newOneToManyLoader( func(ctx context.Context, keys []AccountColumnsKey) ([]*types.AccountWithOperationID, error) { operationIDs := make([]int64, len(keys)) @@ -62,5 +66,15 @@ func accountsByOperationIDLoader(models *data.Models) *dataloadgen.Loader[Accoun func(item *types.AccountWithOperationID) types.Account { return item.Account }, + accountColumnsKeyShape, + "AccountsByOperationIDLoader", + m, ) } + +// accountColumnsKeyShape is the query shape for AccountColumnsKey: Columns is the only field that +// determines the SQL statement the fetcher builds (these loaders take no limit or cursor), so any +// two keys requesting different columns must land in different batch groups. +func accountColumnsKeyShape(key AccountColumnsKey) QueryShape { + return QueryShape{Columns: key.Columns} +} diff --git a/internal/serve/graphql/dataloaders/account_loaders_test.go b/internal/serve/graphql/dataloaders/account_loaders_test.go index 493df33c6..792e31182 100644 --- a/internal/serve/graphql/dataloaders/account_loaders_test.go +++ b/internal/serve/graphql/dataloaders/account_loaders_test.go @@ -69,7 +69,7 @@ func TestAccountScopedLoaders_NoCrossAccountLeakInSharedBatch(t *testing.T) { require.NoError(t, err) t.Run("operations loader does not leak across accounts in one batch", func(t *testing.T) { - loaders := NewDataloaders(models) + loaders := NewDataloaders(models, m.Dataloader) results, err := loaders.AccountOperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ {AccountID: acctA, ToID: toID, LedgerCreatedAt: now}, {AccountID: acctB, ToID: toID, LedgerCreatedAt: now}, @@ -83,7 +83,7 @@ func TestAccountScopedLoaders_NoCrossAccountLeakInSharedBatch(t *testing.T) { }) t.Run("state-changes loader does not leak across accounts in one batch", func(t *testing.T) { - loaders := NewDataloaders(models) + loaders := NewDataloaders(models, m.Dataloader) results, err := loaders.AccountStateChangesByToIDLoader.LoadAll(ctx, []StateChangeColumnsKey{ {AccountID: acctA, ToID: toID, LedgerCreatedAt: now}, {AccountID: acctB, ToID: toID, LedgerCreatedAt: now}, @@ -139,7 +139,7 @@ func TestAccountScopedLoaders_PerColumnsFetchInSharedBatch(t *testing.T) { `, now, toID+1, types.AddressBytea(acct)) require.NoError(t, err) - loaders := NewDataloaders(models) + loaders := NewDataloaders(models, m.Dataloader) results, err := loaders.AccountOperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ {AccountID: acct, ToID: toID, LedgerCreatedAt: now, Columns: "id"}, {AccountID: acct, ToID: toID, LedgerCreatedAt: now, Columns: "id, operation_type"}, @@ -218,7 +218,7 @@ func TestAccountScopedLoaders_BatchRoutesPerToID(t *testing.T) { require.NoError(t, err) t.Run("operations loader routes per to_id", func(t *testing.T) { - loaders := NewDataloaders(models) + loaders := NewDataloaders(models, m.Dataloader) results, err := loaders.AccountOperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ {AccountID: acct, ToID: toIDA, LedgerCreatedAt: now}, {AccountID: acct, ToID: toIDB, LedgerCreatedAt: now}, @@ -241,7 +241,7 @@ func TestAccountScopedLoaders_BatchRoutesPerToID(t *testing.T) { }) t.Run("state-changes loader routes per to_id", func(t *testing.T) { - loaders := NewDataloaders(models) + loaders := NewDataloaders(models, m.Dataloader) results, err := loaders.AccountStateChangesByToIDLoader.LoadAll(ctx, []StateChangeColumnsKey{ {AccountID: acct, ToID: toIDA, LedgerCreatedAt: now}, {AccountID: acct, ToID: toIDB, LedgerCreatedAt: now}, diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index d498489a9..2034ecaa0 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -11,8 +11,19 @@ import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" ) +// loaderBatchCapacity fires a batch as soon as this many distinct keys are enqueued. +// It matches the maximum page size, so a full page's fan-out never waits on the timer. +const loaderBatchCapacity = 100 + +// loaderWait is the collection window before an under-capacity batch fires. Keys for one +// GraphQL request arrive in a near-instant burst (sibling resolvers run concurrently), so +// the window only exists to catch that burst; each nested loader level serializes one +// window, so this is a per-level latency floor, not a throughput knob. +const loaderWait = 1 * time.Millisecond + // Dataloaders struct holds all dataloader instances for GraphQL resolvers // Each dataloader batches requests for a specific type of data relationship // GraphQL resolvers use these to efficiently load related data @@ -62,28 +73,68 @@ type Dataloaders struct { // This is called during GraphQL server initialization // The dataloaders are then injected into GraphQL context by middleware // GraphQL resolvers access these loaders to batch database queries efficiently -func NewDataloaders(models *data.Models) *Dataloaders { +// +// m may be nil (e.g. in tests constructing loaders directly without a metrics handle); every +// instrumentation call site guards against it. +func NewDataloaders(models *data.Models, m *metrics.DataloaderMetrics) *Dataloaders { return &Dataloaders{ - OperationsByToIDLoader: operationsByToIDLoader(models), - OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), - TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), - TransactionsByOperationIDLoader: transactionByOperationIDLoader(models), - StateChangesByToIDLoader: stateChangesByToIDLoader(models), - StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models), - AccountsByToIDLoader: accountsByToIDLoader(models), - AccountsByOperationIDLoader: accountsByOperationIDLoader(models), - AccountOperationsByToIDLoader: accountOperationsByToIDLoader(models), - AccountStateChangesByToIDLoader: accountStateChangesByToIDLoader(models), + OperationsByToIDLoader: operationsByToIDLoader(models, m), + OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models, m), + TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models, m), + TransactionsByOperationIDLoader: transactionByOperationIDLoader(models, m), + StateChangesByToIDLoader: stateChangesByToIDLoader(models, m), + StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models, m), + AccountsByToIDLoader: accountsByToIDLoader(models, m), + AccountsByOperationIDLoader: accountsByOperationIDLoader(models, m), + AccountOperationsByToIDLoader: accountOperationsByToIDLoader(models, m), + AccountStateChangesByToIDLoader: accountStateChangesByToIDLoader(models, m), + } +} + +// observeBatch records batch-size and fetch-duration metrics for one dataloader batch. It +// returns a func to call when the batch's fetch work is complete (deferred by the caller), so +// duration spans every shape-group fetch the batch was split into. m may be nil. +func observeBatch(m *metrics.DataloaderMetrics, name string, keyCount int) func() { + if m == nil { + return func() {} + } + m.BatchSize.WithLabelValues(name).Observe(float64(keyCount)) + start := time.Now() + return func() { + m.FetchDuration.WithLabelValues(name).Observe(time.Since(start).Seconds()) } } +// QueryShape captures the query-shaping parameters of a dataloader key: the parameters a fetcher +// closure reads off of keys[0] and applies to the whole slice it's given (Columns, Limit, Cursor, +// SortOrder). dataloadgen hands a batch to the fetch function as one flat, ungrouped slice of +// keys, so two keys differing in any of these fields (e.g. two GraphQL aliases of the same field +// with different sub-selections) must never be fetched together — the fetcher would apply one +// key's shape to both. newOneToManyLoader and newOneToOneLoader group each batch by QueryShape +// before calling the fetcher, so every slice the fetcher sees is shape-homogeneous and keys[0] is +// representative of the whole slice. One-to-one loaders only vary in Columns, so they leave +// Limit/Cursor/SortOrder at their zero values. +type QueryShape struct { + Columns string + Limit int32 + HasLimit bool + Cursor any + HasCursor bool + SortOrder data.SortOrder +} + // newOneToManyLoader is a generic helper function that creates a dataloader for one-to-many relationships. // It abstracts the common pattern of fetching items, grouping them by a key, and returning them in the // order of the original keys. This reduces boilerplate code in dataloader definitions. // // Parameters: -// - fetcher: A function that fetches all items for a given set of keys. +// - fetcher: A function that fetches all items for a given set of keys. Every call is guaranteed +// to receive a shape-homogeneous slice (see QueryShape), so it may safely derive query +// parameters from keys[0] and apply them to the whole slice. // - getKey: A function that extracts the grouping key from an item. +// - shapeOf: Extracts the query shape from a key, used to group the batch before fetching. +// - name: A static loader name used only as the "loader" metrics label (fixed set, bounded cardinality). +// - m: Metrics handle for batch-size/fetch-duration instrumentation; may be nil. // // Returns: // - A configured dataloadgen.Loader for one-to-many relationships. @@ -92,48 +143,93 @@ func newOneToManyLoader[K comparable, PK comparable, V any, T any]( getPKFromItem func(item T) PK, getPKFromKey func(key K) PK, transform func(item T) V, + shapeOf func(key K) QueryShape, + name string, + m *metrics.DataloaderMetrics, ) *dataloadgen.Loader[K, []*V] { return dataloadgen.NewLoader( func(ctx context.Context, keys []K) ([][]*V, []error) { - items, err := fetcher(ctx, keys) - if err != nil { - // if the fetcher function returns an error, we'll return it for each key. - // this is a requirement for dataloadgen, which expects an error for each key. - errors := make([]error, len(keys)) - for i := range keys { - errors[i] = err + done := observeBatch(m, name, len(keys)) + defer done() + + result := make([][]*V, len(keys)) + errs := make([]error, len(keys)) + + for _, indices := range groupIndicesByShape(keys, shapeOf) { + items, err := fetcher(ctx, subset(keys, indices)) + if err != nil { + for _, i := range indices { + errs[i] = err + } + continue } - return nil, errors - } - // An item is the actual data from the database that we want to return. - // The key contains the primary key and the set of columns to return. - // - // For e.g. if we want to get all operations for a transaction, the key will - // be the a struct containing the transaction hash and the columns to return. - // The items will be a slice of operations which will be grouped by the primary key, which is the transaction hash. - // We can do this by creating a map with the primary key as the key and the items as the value. - // We can then return the items in the order of the keys. - itemsByPK := make(map[PK][]*V) - for _, item := range items { - key := getPKFromItem(item) - transformedItem := transform(item) - itemsByPK[key] = append(itemsByPK[key], &transformedItem) - } + // An item is the actual data from the database that we want to return. + // The key contains the primary key and the set of columns to return. + // + // For e.g. if we want to get all operations for a transaction, the key will + // be the a struct containing the transaction hash and the columns to return. + // The items will be a slice of operations which will be grouped by the primary key, which is the transaction hash. + // We can do this by creating a map with the primary key as the key and the items as the value. + // We can then return the items in the order of the keys. + itemsByPK := make(map[PK][]*V) + for _, item := range items { + pk := getPKFromItem(item) + transformedItem := transform(item) + itemsByPK[pk] = append(itemsByPK[pk], &transformedItem) + } - // Build result in the order of the keys. - result := make([][]*V, len(keys)) - for i, key := range keys { - result[i] = itemsByPK[getPKFromKey(key)] + for _, i := range indices { + result[i] = itemsByPK[getPKFromKey(keys[i])] + } } - return result, nil + return result, errs }, - dataloadgen.WithBatchCapacity(100), - dataloadgen.WithWait(5*time.Millisecond), + dataloadgen.WithBatchCapacity(loaderBatchCapacity), + dataloadgen.WithWait(loaderWait), ) } +// groupIndicesByShape partitions a batch's key indices by QueryShape, so the fetcher only ever +// sees shape-homogeneous slices. A group whose shape carries a cursor and holds more than one key +// is split into singleton groups instead: the multi-key data-layer methods take no cursor +// parameter, so a >1-key cursored group must be fetched one key at a time to honor each key's +// cursor rather than silently dropping it. +func groupIndicesByShape[K comparable](keys []K, shapeOf func(K) QueryShape) [][]int { + indicesByShape := make(map[QueryShape][]int) + var order []QueryShape + for i, key := range keys { + shape := shapeOf(key) + if _, seen := indicesByShape[shape]; !seen { + order = append(order, shape) + } + indicesByShape[shape] = append(indicesByShape[shape], i) + } + + groups := make([][]int, 0, len(order)) + for _, shape := range order { + indices := indicesByShape[shape] + if shape.HasCursor && len(indices) > 1 { + for _, i := range indices { + groups = append(groups, []int{i}) + } + continue + } + groups = append(groups, indices) + } + return groups +} + +// subset returns the keys at the given indices, preserving order. +func subset[K any](keys []K, indices []int) []K { + out := make([]K, len(indices)) + for j, i := range indices { + out[j] = keys[i] + } + return out +} + // newAccountScopedLoader creates a dataloader for account-scoped one-to-many lookups keyed by // transaction ToID. A single GraphQL request can alias accountByAddress for multiple accounts, and // can alias one account with differing field sub-selections, so a single batch may contain keys for @@ -148,9 +244,14 @@ func newAccountScopedLoader[K comparable, V any]( toID func(key K) int64, ledgerCreatedAt func(key K) time.Time, itemToID func(item *V) int64, + name string, + m *metrics.DataloaderMetrics, ) *dataloadgen.Loader[K, []*V] { return dataloadgen.NewLoader( func(ctx context.Context, keys []K) ([][]*V, []error) { + done := observeBatch(m, name, len(keys)) + defer done() + result := make([][]*V, len(keys)) errs := make([]error, len(keys)) @@ -191,8 +292,8 @@ func newAccountScopedLoader[K comparable, V any]( return result, errs }, - dataloadgen.WithBatchCapacity(100), - dataloadgen.WithWait(5*time.Millisecond), + dataloadgen.WithBatchCapacity(loaderBatchCapacity), + dataloadgen.WithWait(loaderWait), ) } @@ -201,10 +302,15 @@ func newAccountScopedLoader[K comparable, V any]( // order of the original keys. This is useful for relationships where each key maps to exactly one item. // // Parameters: -// - fetcher: A function that fetches all items for a given set of keys. +// - fetcher: A function that fetches all items for a given set of keys. Every call is guaranteed +// to receive a shape-homogeneous slice (see QueryShape), so it may safely derive query +// parameters from keys[0] and apply them to the whole slice. // - getKey: A function that extracts the grouping key from an item. // - setKey: A function that associates a fetched item with its corresponding key. This is necessary // because the fetcher may not return items in the same order as the input keys. +// - shapeOf: Extracts the query shape from a key, used to group the batch before fetching. +// - name: A static loader name used only as the "loader" metrics label (fixed set, bounded cardinality). +// - m: Metrics handle for batch-size/fetch-duration instrumentation; may be nil. // // Returns: // - A configured dataloadgen.Loader for one-to-one relationships. @@ -213,33 +319,42 @@ func newOneToOneLoader[K comparable, PK comparable, V any, T any]( getPKFromItem func(item T) PK, getPKFromKey func(key K) PK, transform func(item T) V, + shapeOf func(key K) QueryShape, + name string, + m *metrics.DataloaderMetrics, ) *dataloadgen.Loader[K, *V] { return dataloadgen.NewLoader( func(ctx context.Context, keys []K) ([]*V, []error) { - items, err := fetcher(ctx, keys) - if err != nil { - errors := make([]error, len(keys)) - for i := range keys { - errors[i] = err + done := observeBatch(m, name, len(keys)) + defer done() + + result := make([]*V, len(keys)) + errs := make([]error, len(keys)) + + for _, indices := range groupIndicesByShape(keys, shapeOf) { + items, err := fetcher(ctx, subset(keys, indices)) + if err != nil { + for _, i := range indices { + errs[i] = err + } + continue } - return nil, errors - } - itemsByKey := make(map[PK]*V) - for _, item := range items { - key := getPKFromItem(item) - transformedItem := transform(item) - itemsByKey[key] = &transformedItem - } + itemsByPK := make(map[PK]*V) + for _, item := range items { + pk := getPKFromItem(item) + transformedItem := transform(item) + itemsByPK[pk] = &transformedItem + } - result := make([]*V, len(keys)) - for i, key := range keys { - result[i] = itemsByKey[getPKFromKey(key)] + for _, i := range indices { + result[i] = itemsByPK[getPKFromKey(keys[i])] + } } - return result, nil + return result, errs }, - dataloadgen.WithBatchCapacity(100), - dataloadgen.WithWait(5*time.Millisecond), + dataloadgen.WithBatchCapacity(loaderBatchCapacity), + dataloadgen.WithWait(loaderWait), ) } diff --git a/internal/serve/graphql/dataloaders/loaders_test.go b/internal/serve/graphql/dataloaders/loaders_test.go new file mode 100644 index 000000000..682e6829c --- /dev/null +++ b/internal/serve/graphql/dataloaders/loaders_test.go @@ -0,0 +1,67 @@ +package dataloaders + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeItem struct { + pk int + val string +} + +// TestNewOneToManyLoader_ConcurrentLoadsCoalesceIntoOneFetch is dataloadgen's whole reason to +// exist: N goroutines calling Load concurrently for same-shape keys must land in the same batch +// window and produce exactly one fetcher call, not N. A regression that widened the batch window +// wouldn't break this (it would still coalesce, just slower), but a regression that dropped +// batching entirely — e.g. capacity/wait misconfigured to fire per-key — would turn one fetch +// call into loadCount and this test catches that deterministically via the call counter, with no +// dependency on wall-clock timing. +func TestNewOneToManyLoader_ConcurrentLoadsCoalesceIntoOneFetch(t *testing.T) { + type key struct{ id int } + + var fetchCalls int32 + loader := newOneToManyLoader( + func(_ context.Context, keys []key) ([]fakeItem, error) { + atomic.AddInt32(&fetchCalls, 1) + items := make([]fakeItem, len(keys)) + for i, k := range keys { + items[i] = fakeItem{pk: k.id, val: "v"} + } + return items, nil + }, + func(item fakeItem) int { return item.pk }, + func(k key) int { return k.id }, + func(item fakeItem) fakeItem { return item }, + func(key) QueryShape { return QueryShape{} }, + "test", + nil, + ) + + const loadCount = 20 + ctx := context.Background() + results := make([][]*fakeItem, loadCount) + errs := make([]error, loadCount) + + var wg sync.WaitGroup + wg.Add(loadCount) + for i := range loadCount { + go func(i int) { + defer wg.Done() + results[i], errs[i] = loader.Load(ctx, key{id: i}) + }(i) + } + wg.Wait() + + for i := range loadCount { + require.NoError(t, errs[i]) + require.Len(t, results[i], 1) + assert.Equal(t, i, results[i][0].pk) + } + assert.Equal(t, int32(1), atomic.LoadInt32(&fetchCalls), "N concurrent same-shape Loads must coalesce into a single fetch call") +} diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go index 3b0a51ea6..fc8f182d9 100644 --- a/internal/serve/graphql/dataloaders/operation_loaders.go +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -9,11 +9,7 @@ import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" -) - -const ( - // TODO: this should be configurable via config - MaxOperationsPerBatch = 10 + "github.com/stellar/wallet-backend/internal/metrics" ) type OperationColumnsKey struct { @@ -38,12 +34,15 @@ type OperationColumnsKey struct { // tx_to_id = operation.ID &^ 0xFFF // // This is the inverse of the query: given an operation, find its parent transaction. -func operationsByToIDLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.OperationWithCursor] { +func operationsByToIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[OperationColumnsKey, []*types.OperationWithCursor] { return newOneToManyLoader( func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithCursor, error) { columns := keys[0].Columns sortOrder := keys[0].SortOrder limit := keys[0].Limit + if limit == nil || *limit <= 0 { + return nil, fmt.Errorf("operations loader requires a positive limit") + } // If there is only one key, we can use a simpler query without resorting to the CTE expressions. // Also, when a single key is requested, we can allow using normal cursor based pagination. @@ -53,12 +52,11 @@ func operationsByToIDLoader(models *data.Models) *dataloadgen.Loader[OperationCo toIDs := make([]int64, len(keys)) ledgerCreatedAts := make([]time.Time, len(keys)) - maxLimit := min(*limit, MaxOperationsPerBatch) for i, key := range keys { toIDs[i] = key.ToID ledgerCreatedAts[i] = key.LedgerCreatedAt } - return models.Operations.BatchGetByToIDs(ctx, toIDs, ledgerCreatedAts, columns, &maxLimit, sortOrder) + return models.Operations.BatchGetByToIDs(ctx, toIDs, ledgerCreatedAts, columns, limit, sortOrder) }, func(item *types.OperationWithCursor) int64 { // Derive tx_to_id from operation ID using TOID bit masking @@ -72,14 +70,41 @@ func operationsByToIDLoader(models *data.Models) *dataloadgen.Loader[OperationCo func(item *types.OperationWithCursor) types.OperationWithCursor { return *item }, + operationColumnsKeyShape, + "OperationsByToIDLoader", + m, ) } +// operationColumnsKeyShape is the query shape for one-to-many operation loaders keyed by +// OperationColumnsKey (see operationsByToIDLoader): Columns, Limit, Cursor and SortOrder all +// determine the SQL statement the fetcher builds, so any two keys differing in one of these +// fields must land in different batch groups. +func operationColumnsKeyShape(key OperationColumnsKey) QueryShape { + shape := QueryShape{Columns: key.Columns, SortOrder: key.SortOrder} + if key.Limit != nil { + shape.Limit = *key.Limit + shape.HasLimit = true + } + if key.Cursor != nil { + shape.Cursor = *key.Cursor + shape.HasCursor = true + } + return shape +} + +// operationColumnsKeyShapeByColumns is the query shape for one-to-one operation loaders keyed by +// OperationColumnsKey (see operationByStateChangeIDLoader): only Columns varies per key there, so +// grouping on it alone is sufficient. +func operationColumnsKeyShapeByColumns(key OperationColumnsKey) QueryShape { + return QueryShape{Columns: key.Columns} +} + // accountOperationsByToIDLoader batches account-scoped operation lookups by transaction ToID, // grouping the batch by account so a multi-account request never cross-contaminates edges (see // newAccountScopedLoader). Operations carry no account column, so the grouping key is derived from // the operation ID via TOID bit masking: tx_to_id = operation.ID &^ 0xFFF. -func accountOperationsByToIDLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { +func accountOperationsByToIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { return newAccountScopedLoader( models.Operations.BatchGetAccountOperationsByToIDs, func(key OperationColumnsKey) string { return key.AccountID }, @@ -87,13 +112,15 @@ func accountOperationsByToIDLoader(models *data.Models) *dataloadgen.Loader[Oper func(key OperationColumnsKey) int64 { return key.ToID }, func(key OperationColumnsKey) time.Time { return key.LedgerCreatedAt }, func(item *types.Operation) int64 { return item.ID &^ 0xFFF }, + "AccountOperationsByToIDLoader", + m, ) } // operationByStateChangeIDLoader creates a dataloader for fetching operations by state change ID // This prevents N+1 queries when multiple state changes request their operations // The loader batches multiple state change IDs into a single database query -func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, *types.Operation] { +func operationByStateChangeIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[OperationColumnsKey, *types.Operation] { return newOneToOneLoader( func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithStateChangeID, error) { columns := keys[0].Columns @@ -118,5 +145,8 @@ func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[Ope func(item *types.OperationWithStateChangeID) types.Operation { return item.Operation }, + operationColumnsKeyShapeByColumns, + "OperationByStateChangeIDLoader", + m, ) } diff --git a/internal/serve/graphql/dataloaders/operation_loaders_test.go b/internal/serve/graphql/dataloaders/operation_loaders_test.go new file mode 100644 index 000000000..6f7ba32e1 --- /dev/null +++ b/internal/serve/graphql/dataloaders/operation_loaders_test.go @@ -0,0 +1,246 @@ +package dataloaders + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" +) + +func int32Ptr(v int32) *int32 { return &v } + +// TestOperationsByToIDLoader_ColumnsPerKeyInSharedBatch covers a single request that aliases the +// `operations` field on the SAME transaction with different sub-selections (e.g. a lean alias +// selecting only `id` and a full alias also selecting `operationType`). Both aliases produce +// OperationColumnsKey values with the same ToID but different Columns, and dataloadgen hands the +// whole batch to the fetch function as one flat slice with no grouping. The fetcher reads +// keys[0].Columns and applies it to the whole batch, so unless the loader groups the batch by +// shape before calling the fetcher, whichever key happens to be first "wins" the columns for +// every other key. The narrow key is listed first so a regression reproduces deterministically. +func TestOperationsByToIDLoader_ColumnsPerKeyInSharedBatch(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + Operations: &data.OperationModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + const toID int64 = 1 << 48 + + t.Cleanup(func() { + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM operations WHERE id = $1`, toID+1) //nolint:errcheck + }) + + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) + VALUES ($2, 'PAYMENT', $3, 'op_success', true, 1, $1) + `, now, toID+1, types.XDRBytea([]byte("xdr"))) + require.NoError(t, err) + + limit := int32Ptr(10) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.OperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ + {ToID: toID, Columns: "id", Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toID, Columns: "id, operation_type", Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + require.NotEmpty(t, results[0]) + require.NotEmpty(t, results[1]) + assert.Empty(t, results[0][0].OperationType, "key selecting only id receives no operation_type") + assert.Equal(t, types.OperationTypePayment, results[1][0].OperationType, "key selecting operation_type must receive it, not the narrow key's columns") +} + +// TestOperationsByToIDLoader_MultiKeyBatchHonorsFullLimit covers a batch with two DIFFERENT +// transaction ToIDs (the multi-key branch). One parent has 12 operations; the loader must never +// clamp the multi-key branch's limit to a fixed constant independent of what the caller asked +// for, and it must honor a smaller limit exactly (same as the single-key branch already does). +func TestOperationsByToIDLoader_MultiKeyBatchHonorsFullLimit(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + Operations: &data.OperationModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + const ( + toIDA int64 = 1 << 49 + toIDB int64 = 1 << 50 + ) + + t.Cleanup(func() { + ids := make([]int64, 0, 13) + for i := int64(1); i <= 12; i++ { + ids = append(ids, toIDA+i) + } + ids = append(ids, toIDB+1) + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM operations WHERE id = ANY($1)`, ids) //nolint:errcheck + }) + + // Parent A has 12 operations; parent B has 1. Both share the same shape (no columns, same + // limit, same sort order) so they land in the same batch group, forcing the multi-key branch. + for i := int64(1); i <= 12; i++ { + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) + VALUES ($1, 'PAYMENT', $2, 'op_success', true, 1, $3) + `, toIDA+i, types.XDRBytea([]byte("xdr")), now) + require.NoError(t, err) + } + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) + VALUES ($1, 'PAYMENT', $2, 'op_success', true, 1, $3) + `, toIDB+1, types.XDRBytea([]byte("xdr")), now) + require.NoError(t, err) + + t.Run("limit above available rows returns everything, not a fixed cap", func(t *testing.T) { + limit := int32Ptr(51) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.OperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ + {ToID: toIDA, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toIDB, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + assert.Len(t, results[0], 12, "parent A's 12 operations must all come back uncapped") + assert.Len(t, results[1], 1) + }) + + t.Run("limit below available rows is honored exactly", func(t *testing.T) { + limit := int32Ptr(6) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.OperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ + {ToID: toIDA, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toIDB, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + assert.Len(t, results[0], 6) + }) +} + +// TestOperationsByToIDLoader_DuplicateCursorInSharedBatchIsHonoredPerKey covers two keys for +// DIFFERENT transactions that happen to carry the exact same Cursor value (e.g. two aliased +// `operations(after: $cursor)` fields sharing a client-supplied cursor). The multi-key data-layer +// method (BatchGetByToIDs) has no cursor parameter at all, so merging these into one multi-key +// fetch would silently drop the cursor for both. The loader must recognize that the group has +// more than one key sharing a cursor and fall back to fetching each key individually so cursor +// filtering still applies. +func TestOperationsByToIDLoader_DuplicateCursorInSharedBatchIsHonoredPerKey(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + Operations: &data.OperationModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + const ( + toIDA int64 = 1 << 51 + toIDB int64 = 1 << 52 + ) + + t.Cleanup(func() { + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM operations WHERE id IN ($1, $2, $3)`, toIDA+1, toIDA+2, toIDB+1) //nolint:errcheck + }) + + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) + VALUES ($2, 'PAYMENT', $3, 'op_success', true, 1, $1), + ($4, 'PAYMENT', $3, 'op_success', true, 1, $1), + ($5, 'PAYMENT', $3, 'op_success', true, 1, $1) + `, now, toIDA+1, types.XDRBytea([]byte("xdr")), toIDA+2, toIDB+1) + require.NoError(t, err) + + limit := int32Ptr(10) + cursor := toIDA + 1 + results, err := NewDataloaders(models, m.Dataloader).OperationsByToIDLoader.LoadAll(ctx, []OperationColumnsKey{ + {ToID: toIDA, Limit: limit, Cursor: &cursor, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toIDB, Limit: limit, Cursor: &cursor, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + + // Parent A: cursor excludes id <= toIDA+1, so only toIDA+2 remains. + require.Len(t, results[0], 1) + assert.Equal(t, toIDA+2, results[0][0].Operation.ID) + + // Parent B: unaffected by A's cursor value, but if the cursor were dropped for the whole + // group (bug), an unfiltered multi-key query could still change what comes back here too. + require.Len(t, results[1], 1) + assert.Equal(t, toIDB+1, results[1][0].Operation.ID) +} + +// TestOperationsByToIDLoader_NilLimitErrors covers the defensive guard: a key with no limit must +// fail closed with an error, never dereference a nil pointer (panic) or send an unbounded query. +func TestOperationsByToIDLoader_NilLimitErrors(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + Operations: &data.OperationModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + loaders := NewDataloaders(models, m.Dataloader) + _, err := loaders.OperationsByToIDLoader.Load(ctx, OperationColumnsKey{ToID: 1 << 53, SortOrder: data.ASC}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a positive limit") +} + +// TestTransactionsByOperationIDLoader_ColumnsPerKeyInSharedBatch covers the one-to-one loader +// equivalent of the columns bug: a single request aliasing Operation.transaction twice with +// different sub-selections. Both keys share the same OperationID but request different Columns; +// the fetcher must not apply the first key's columns to the second. +func TestTransactionsByOperationIDLoader_ColumnsPerKeyInSharedBatch(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + Transactions: &data.TransactionModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + const txToID int64 = 1 << 54 + const opID int64 = txToID + 1 + + t.Cleanup(func() { + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM operations WHERE id = $1`, opID) //nolint:errcheck + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM transactions WHERE to_id = $1`, txToID) //nolint:errcheck + }) + + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO transactions (hash, to_id, fee_charged, result_code, ledger_number, ledger_created_at, is_fee_bump) + VALUES ($2, $3, 4200, 'TransactionResultCodeTxSuccess', 1, $1, false) + `, now, types.HashBytea("00000000000000000000000000000000000000000000000000000000000000ee"), txToID) + require.NoError(t, err) + _, err = testDBConnectionPool.Exec(ctx, ` + INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) + VALUES ($2, 'PAYMENT', $3, 'op_success', true, 1, $1) + `, now, opID, types.XDRBytea([]byte("xdr"))) + require.NoError(t, err) + + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.TransactionsByOperationIDLoader.LoadAll(ctx, []TransactionColumnsKey{ + {OperationID: opID, Columns: "hash"}, + {OperationID: opID, Columns: "hash, fee_charged"}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + require.NotNil(t, results[0]) + require.NotNil(t, results[1]) + assert.Zero(t, results[0].FeeCharged, "key selecting only hash receives no fee_charged") + assert.EqualValues(t, 4200, results[1].FeeCharged, "key selecting fee_charged must receive it, not the first key's narrower columns") +} diff --git a/internal/serve/graphql/dataloaders/statechange_loaders.go b/internal/serve/graphql/dataloaders/statechange_loaders.go index 871881864..41242d429 100644 --- a/internal/serve/graphql/dataloaders/statechange_loaders.go +++ b/internal/serve/graphql/dataloaders/statechange_loaders.go @@ -9,11 +9,7 @@ import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" -) - -const ( - // TODO: this should be configurable via config - MaxStateChangesPerBatch = 10 + "github.com/stellar/wallet-backend/internal/metrics" ) type StateChangeColumnsKey struct { @@ -30,7 +26,7 @@ type StateChangeColumnsKey struct { // stateChangesByToIDLoader creates a dataloader for fetching state changes by to_id // This prevents N+1 queries when multiple transactions request their state changes // The loader batches multiple to_ids into a single database query -func stateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { +func stateChangesByToIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { return newOneToManyLoader( func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChangeWithCursor, error) { // Add the to_id column since that will be used as the primary key to group the state changes @@ -41,6 +37,9 @@ func stateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[StateChan } sortOrder := keys[0].SortOrder limit := keys[0].Limit + if limit == nil || *limit <= 0 { + return nil, fmt.Errorf("state changes loader requires a positive limit") + } // If there is only one key, we can use a simpler query without resorting to the CTE expressions. // Also, when a single key is requested, we can allow using normal cursor based pagination. @@ -50,12 +49,11 @@ func stateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[StateChan toIDs := make([]int64, len(keys)) ledgerCreatedAts := make([]time.Time, len(keys)) - maxLimit := min(*limit, MaxStateChangesPerBatch) for i, key := range keys { toIDs[i] = key.ToID ledgerCreatedAts[i] = key.LedgerCreatedAt } - return models.StateChanges.BatchGetByToIDs(ctx, toIDs, ledgerCreatedAts, columns, &maxLimit, sortOrder) + return models.StateChanges.BatchGetByToIDs(ctx, toIDs, ledgerCreatedAts, columns, limit, sortOrder) }, func(item *types.StateChangeWithCursor) int64 { return item.StateChange.ToID @@ -66,13 +64,33 @@ func stateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[StateChan func(item *types.StateChangeWithCursor) types.StateChangeWithCursor { return *item }, + stateChangeColumnsKeyShape, + "StateChangesByToIDLoader", + m, ) } +// stateChangeColumnsKeyShape is the query shape for one-to-many state-change loaders keyed by +// StateChangeColumnsKey (see stateChangesByToIDLoader, stateChangesByOperationIDLoader): Columns, +// Limit, Cursor and SortOrder all determine the SQL statement the fetcher builds, so any two keys +// differing in one of these fields must land in different batch groups. +func stateChangeColumnsKeyShape(key StateChangeColumnsKey) QueryShape { + shape := QueryShape{Columns: key.Columns, SortOrder: key.SortOrder} + if key.Limit != nil { + shape.Limit = *key.Limit + shape.HasLimit = true + } + if key.Cursor != nil { + shape.Cursor = *key.Cursor + shape.HasCursor = true + } + return shape +} + // stateChangesByOperationIDLoader creates a dataloader for fetching state changes by operation ID // This prevents N+1 queries when multiple operations request their state changes // The loader batches multiple operation IDs into a single database query -func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { +func stateChangesByOperationIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { return newOneToManyLoader( func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChangeWithCursor, error) { // Add the operation_id column since that will be used as the primary key to group the state changes @@ -83,6 +101,9 @@ func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[St } sortOrder := keys[0].SortOrder limit := keys[0].Limit + if limit == nil || *limit <= 0 { + return nil, fmt.Errorf("state changes loader requires a positive limit") + } // If there is only one key, we can use a simpler query without resorting to the CTE expressions. // Also, when a single key is requested, we can allow using normal cursor based pagination. @@ -107,6 +128,9 @@ func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[St func(item *types.StateChangeWithCursor) types.StateChangeWithCursor { return *item }, + stateChangeColumnsKeyShape, + "StateChangesByOperationIDLoader", + m, ) } @@ -115,7 +139,7 @@ func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[St // newAccountScopedLoader). BatchGetAccountStateChangesByToIDs forces the to_id grouping key into the // SELECT via prepareColumnsWithID, so — unlike stateChangesByToIDLoader — no manual column injection // is needed here. -func accountStateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { +func accountStateChangesByToIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { return newAccountScopedLoader( models.StateChanges.BatchGetAccountStateChangesByToIDs, func(key StateChangeColumnsKey) string { return key.AccountID }, @@ -123,5 +147,7 @@ func accountStateChangesByToIDLoader(models *data.Models) *dataloadgen.Loader[St func(key StateChangeColumnsKey) int64 { return key.ToID }, func(key StateChangeColumnsKey) time.Time { return key.LedgerCreatedAt }, func(item *types.StateChange) int64 { return item.ToID }, + "AccountStateChangesByToIDLoader", + m, ) } diff --git a/internal/serve/graphql/dataloaders/statechange_loaders_test.go b/internal/serve/graphql/dataloaders/statechange_loaders_test.go new file mode 100644 index 000000000..56364f8e0 --- /dev/null +++ b/internal/serve/graphql/dataloaders/statechange_loaders_test.go @@ -0,0 +1,136 @@ +package dataloaders + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" +) + +// TestStateChangesByToIDLoader_ColumnsPerKeyInSharedBatch is the state-changes equivalent of +// TestOperationsByToIDLoader_ColumnsPerKeyInSharedBatch: two aliases of the same transaction's +// `stateChanges` field with different sub-selections must each receive their own columns, not +// whichever key happens to be first in the batch. +func TestStateChangesByToIDLoader_ColumnsPerKeyInSharedBatch(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + StateChanges: &data.StateChangeModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + acct := keypair.MustRandom().Address() + const toID int64 = 1 << 55 + + t.Cleanup(func() { + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM state_changes WHERE to_id = $1`, toID) //nolint:errcheck + }) + + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO state_changes (to_id, state_change_id, state_change_category, state_change_reason, ledger_created_at, ledger_number, account_id, operation_id) + VALUES ($1, 1, 'BALANCE', 'CREDIT', $2, 1, $3, 0) + `, toID, now, types.AddressBytea(acct)) + require.NoError(t, err) + + limit := int32Ptr(10) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.StateChangesByToIDLoader.LoadAll(ctx, []StateChangeColumnsKey{ + {ToID: toID, Columns: "state_change_category", Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toID, Columns: "state_change_category, state_change_reason", Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + require.NotEmpty(t, results[0]) + require.NotEmpty(t, results[1]) + assert.Empty(t, results[0][0].StateChangeReason, "key selecting only the category receives no reason") + assert.Equal(t, types.StateChangeReasonCredit, results[1][0].StateChangeReason, "key selecting the reason must receive it, not the first key's narrower columns") +} + +// TestStateChangesByToIDLoader_MultiKeyBatchHonorsFullLimit is the state-changes equivalent of +// TestOperationsByToIDLoader_MultiKeyBatchHonorsFullLimit: the multi-key branch must not clamp +// the limit to a fixed constant, and must honor a smaller limit exactly. +func TestStateChangesByToIDLoader_MultiKeyBatchHonorsFullLimit(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + StateChanges: &data.StateChangeModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + now := time.Now().UTC().Truncate(time.Microsecond) + acct := keypair.MustRandom().Address() + const ( + toIDA int64 = 1 << 56 + toIDB int64 = 1 << 57 + ) + + t.Cleanup(func() { + _, _ = testDBConnectionPool.Exec(ctx, `DELETE FROM state_changes WHERE to_id IN ($1, $2)`, toIDA, toIDB) //nolint:errcheck + }) + + for i := int64(1); i <= 12; i++ { + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO state_changes (to_id, state_change_id, state_change_category, state_change_reason, ledger_created_at, ledger_number, account_id, operation_id) + VALUES ($1, $2, 'BALANCE', 'CREDIT', $3, 1, $4, 0) + `, toIDA, i, now, types.AddressBytea(acct)) + require.NoError(t, err) + } + _, err := testDBConnectionPool.Exec(ctx, ` + INSERT INTO state_changes (to_id, state_change_id, state_change_category, state_change_reason, ledger_created_at, ledger_number, account_id, operation_id) + VALUES ($1, 1, 'BALANCE', 'CREDIT', $2, 1, $3, 0) + `, toIDB, now, types.AddressBytea(acct)) + require.NoError(t, err) + + t.Run("limit above available rows returns everything, not a fixed cap", func(t *testing.T) { + limit := int32Ptr(51) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.StateChangesByToIDLoader.LoadAll(ctx, []StateChangeColumnsKey{ + {ToID: toIDA, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toIDB, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + assert.Len(t, results[0], 12, "parent A's 12 state changes must all come back uncapped") + assert.Len(t, results[1], 1) + }) + + t.Run("limit below available rows is honored exactly", func(t *testing.T) { + limit := int32Ptr(6) + loaders := NewDataloaders(models, m.Dataloader) + results, err := loaders.StateChangesByToIDLoader.LoadAll(ctx, []StateChangeColumnsKey{ + {ToID: toIDA, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + {ToID: toIDB, Limit: limit, SortOrder: data.ASC, LedgerCreatedAt: now}, + }) + require.NoError(t, err) + require.Len(t, results, 2) + assert.Len(t, results[0], 6) + }) +} + +// TestStateChangesByToIDLoader_NilLimitErrors mirrors TestOperationsByToIDLoader_NilLimitErrors +// for the state-changes loader: a key with no limit must fail closed with an error. +func TestStateChangesByToIDLoader_NilLimitErrors(t *testing.T) { + ctx := context.Background() + reg := prometheus.NewRegistry() + m := metrics.NewMetrics(reg) + + models := &data.Models{ + StateChanges: &data.StateChangeModel{DB: testDBConnectionPool, Metrics: m.DB}, + } + + loaders := NewDataloaders(models, m.Dataloader) + _, err := loaders.StateChangesByToIDLoader.Load(ctx, StateChangeColumnsKey{ToID: 1 << 58, SortOrder: data.ASC}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a positive limit") +} diff --git a/internal/serve/graphql/dataloaders/transaction_loaders.go b/internal/serve/graphql/dataloaders/transaction_loaders.go index 4c50a370a..454baa5a5 100644 --- a/internal/serve/graphql/dataloaders/transaction_loaders.go +++ b/internal/serve/graphql/dataloaders/transaction_loaders.go @@ -9,6 +9,7 @@ import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" ) type TransactionColumnsKey struct { @@ -22,7 +23,7 @@ type TransactionColumnsKey struct { // txByOperationIDLoader creates a dataloader for fetching transactions by operation ID // This prevents N+1 queries when multiple operations request their transaction // The loader batches multiple operation IDs into a single database query -func transactionByOperationIDLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { +func transactionByOperationIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { return newOneToOneLoader( func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithOperationID, error) { operationIDs := make([]int64, len(keys)) @@ -41,13 +42,16 @@ func transactionByOperationIDLoader(models *data.Models) *dataloadgen.Loader[Tra func(item *types.TransactionWithOperationID) types.Transaction { return item.Transaction }, + transactionColumnsKeyShape, + "TransactionsByOperationIDLoader", + m, ) } // transactionByStateChangeIDLoader creates a dataloader for fetching transactions by state change ID // This prevents N+1 queries when multiple state changes request their transactions // The loader batches multiple state change IDs into a single database query -func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { +func transactionByStateChangeIDLoader(models *data.Models, m *metrics.DataloaderMetrics) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { return newOneToOneLoader( func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithStateChangeID, error) { columns := keys[0].Columns @@ -72,5 +76,15 @@ func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[T func(item *types.TransactionWithStateChangeID) types.Transaction { return item.Transaction }, + transactionColumnsKeyShape, + "TransactionByStateChangeIDLoader", + m, ) } + +// transactionColumnsKeyShape is the query shape for TransactionColumnsKey: Columns is the only +// field that determines the SQL statement the fetcher builds (these loaders take no limit or +// cursor), so any two keys requesting different columns must land in different batch groups. +func transactionColumnsKeyShape(key TransactionColumnsKey) QueryShape { + return QueryShape{Columns: key.Columns} +} diff --git a/internal/serve/graphql/dataloaders/utils.go b/internal/serve/graphql/dataloaders/utils.go index dd476f986..0e653e624 100644 --- a/internal/serve/graphql/dataloaders/utils.go +++ b/internal/serve/graphql/dataloaders/utils.go @@ -5,8 +5,20 @@ import ( "fmt" "strconv" "strings" + + "github.com/vektah/gqlparser/v2/gqlerror" ) +// badUserInputError normalizes client-correctable validation failures to the GraphQL error code +// used elsewhere in the API for bad input, so the custom error presenter's internal-error masking +// (GQL-05) does not swallow these behind a generic message. +func badUserInputError(message string) error { + return &gqlerror.Error{ + Message: message, + Extensions: map[string]interface{}{"code": "BAD_USER_INPUT"}, + } +} + // parseStateChangeIDs parses composite state change IDs (format: "to_id-operation_id-state_change_id") // into separate slices of to_id, operation_id, and state_change_id values. func parseStateChangeIDs(stateChangeIDs []string) ([]int64, []int64, []int64, error) { @@ -17,22 +29,22 @@ func parseStateChangeIDs(stateChangeIDs []string) ([]int64, []int64, []int64, er for i, id := range stateChangeIDs { parts := strings.Split(id, "-") if len(parts) != 3 { - return nil, nil, nil, fmt.Errorf("invalid state change ID format: %s (expected format: to_id-operation_id-state_change_id)", id) + return nil, nil, nil, badUserInputError(fmt.Sprintf("invalid state change ID format: %s (expected format: to_id-operation_id-state_change_id)", id)) } toID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { - return nil, nil, nil, fmt.Errorf("invalid to_id in state change ID %s: %w", id, err) + return nil, nil, nil, badUserInputError(fmt.Sprintf("invalid to_id in state change ID %s: %s", id, err)) } opID, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { - return nil, nil, nil, fmt.Errorf("invalid operation_id in state change ID %s: %w", id, err) + return nil, nil, nil, badUserInputError(fmt.Sprintf("invalid operation_id in state change ID %s: %s", id, err)) } stateChangeID, err := strconv.ParseInt(parts[2], 10, 64) if err != nil { - return nil, nil, nil, fmt.Errorf("invalid state_change_id in state change ID %s: %w", id, err) + return nil, nil, nil, badUserInputError(fmt.Sprintf("invalid state_change_id in state change ID %s: %s", id, err)) } toIDs[i] = toID diff --git a/internal/serve/graphql/dataloaders/utils_test.go b/internal/serve/graphql/dataloaders/utils_test.go new file mode 100644 index 000000000..12cdaa56d --- /dev/null +++ b/internal/serve/graphql/dataloaders/utils_test.go @@ -0,0 +1,51 @@ +package dataloaders + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +func TestParseStateChangeIDs(t *testing.T) { + t.Run("valid IDs parse successfully", func(t *testing.T) { + toIDs, opIDs, scIDs, err := parseStateChangeIDs([]string{"100-200-1", "300-400-2"}) + require.NoError(t, err) + assert.Equal(t, []int64{100, 300}, toIDs) + assert.Equal(t, []int64{200, 400}, opIDs) + assert.Equal(t, []int64{1, 2}, scIDs) + }) + + t.Run("wrong number of parts returns BAD_USER_INPUT", func(t *testing.T) { + _, _, _, err := parseStateChangeIDs([]string{"100-200"}) + require.Error(t, err) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, "BAD_USER_INPUT", gqlErr.Extensions["code"]) + }) + + t.Run("non-numeric to_id returns BAD_USER_INPUT", func(t *testing.T) { + _, _, _, err := parseStateChangeIDs([]string{"abc-200-1"}) + require.Error(t, err) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, "BAD_USER_INPUT", gqlErr.Extensions["code"]) + }) + + t.Run("non-numeric operation_id returns BAD_USER_INPUT", func(t *testing.T) { + _, _, _, err := parseStateChangeIDs([]string{"100-abc-1"}) + require.Error(t, err) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, "BAD_USER_INPUT", gqlErr.Extensions["code"]) + }) + + t.Run("non-numeric state_change_id returns BAD_USER_INPUT", func(t *testing.T) { + _, _, _, err := parseStateChangeIDs([]string{"100-200-abc"}) + require.Error(t, err) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, "BAD_USER_INPUT", gqlErr.Extensions["code"]) + }) +} diff --git a/internal/serve/graphql/depth_limit.go b/internal/serve/graphql/depth_limit.go new file mode 100644 index 000000000..f72391e84 --- /dev/null +++ b/internal/serve/graphql/depth_limit.go @@ -0,0 +1,99 @@ +package graphql + +import ( + "context" + + "github.com/99designs/gqlgen/graphql" + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +// MaxQueryDepth bounds how deeply nested a single GraphQL operation's selection set may be. +// gqlgen has no built-in depth limit, and the complexity limit does not substitute for one: a +// chain of first:1 connections costs only ~1 per level regardless of how deep it goes, so an +// attacker can nest arbitrarily deep while staying under the complexity budget. Freighter's +// deepest legitimate query (e.g. accountByAddress -> transactions -> edges -> node -> +// operations/stateChanges -> a BaseStateChange implementer field) nests about 6-8 levels deep, so +// 15 leaves generous headroom while still rejecting pathological deeply-nested queries. +const MaxQueryDepth = 15 + +// errQueryTooDeep is the extensions "code" returned when an operation exceeds MaxQueryDepth. +const errQueryTooDeep = "QUERY_TOO_DEEP" + +// DepthLimit is a gqlgen HandlerExtension + OperationContextMutator (the same shape as gqlgen's +// own extension.ComplexityLimit) that rejects operations whose selection set nests deeper than +// MaxDepth. It resolves fragment spreads against the query document's fragments so depth can't be +// hidden behind a fragment indirection, and guards against fragment cycles. +type DepthLimit struct { + MaxDepth int +} + +var _ interface { + graphql.HandlerExtension + graphql.OperationContextMutator +} = DepthLimit{} + +func (DepthLimit) ExtensionName() string { return "DepthLimit" } + +func (DepthLimit) Validate(_ graphql.ExecutableSchema) error { return nil } + +// MutateOperationContext rejects the operation with a QUERY_TOO_DEEP error if it nests deeper +// than MaxDepth (or MaxQueryDepth, if MaxDepth is unset). +func (d DepthLimit) MutateOperationContext(_ context.Context, opCtx *graphql.OperationContext) *gqlerror.Error { + maxDepth := d.MaxDepth + if maxDepth <= 0 { + maxDepth = MaxQueryDepth + } + + op := opCtx.Doc.Operations.ForName(opCtx.OperationName) + if op == nil { + return nil + } + + depth := selectionSetDepth(op.SelectionSet, opCtx.Doc.Fragments, map[string]bool{}, 0) + if depth > maxDepth { + err := gqlerror.Errorf("operation has depth %d, which exceeds the limit of %d", depth, maxDepth) + err.Extensions = map[string]interface{}{"code": errQueryTooDeep} + return err + } + return nil +} + +// selectionSetDepth returns the maximum nesting depth reachable from set, starting at depth. +// Every Field adds one level (whether or not it has children — a leaf scalar still counts as the +// level it was selected at); InlineFragments are transparent (they don't add a level, matching +// how they read in the query); FragmentSpreads are resolved against fragments and otherwise +// treated the same as an InlineFragment. +// +// visitedFragments tracks fragment names currently on the recursion stack, guarding against a +// fragment that (directly or transitively) spreads itself: rather than recursing forever, a +// repeated name is simply skipped. +func selectionSetDepth(set ast.SelectionSet, fragments ast.FragmentDefinitionList, visitedFragments map[string]bool, depth int) int { + maxDepth := depth + for _, sel := range set { + var childDepth int + switch sel := sel.(type) { + case *ast.Field: + childDepth = selectionSetDepth(sel.SelectionSet, fragments, visitedFragments, depth+1) + case *ast.InlineFragment: + childDepth = selectionSetDepth(sel.SelectionSet, fragments, visitedFragments, depth) + case *ast.FragmentSpread: + if visitedFragments[sel.Name] { + continue + } + def := fragments.ForName(sel.Name) + if def == nil { + continue + } + visitedFragments[sel.Name] = true + childDepth = selectionSetDepth(def.SelectionSet, fragments, visitedFragments, depth) + delete(visitedFragments, sel.Name) + default: + continue + } + if childDepth > maxDepth { + maxDepth = childDepth + } + } + return maxDepth +} diff --git a/internal/serve/graphql/depth_limit_test.go b/internal/serve/graphql/depth_limit_test.go new file mode 100644 index 000000000..07c229e18 --- /dev/null +++ b/internal/serve/graphql/depth_limit_test.go @@ -0,0 +1,160 @@ +package graphql + +import ( + "context" + "testing" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2/ast" +) + +// field builds an *ast.Field with the given nested selection set (nil for a leaf scalar). +func field(name string, sub ast.SelectionSet) *ast.Field { + return &ast.Field{Name: name, SelectionSet: sub} +} + +func TestSelectionSetDepth(t *testing.T) { + t.Run("flat selection set is depth 1", func(t *testing.T) { + set := ast.SelectionSet{field("hash", nil), field("ledgerNumber", nil)} + assert.Equal(t, 1, selectionSetDepth(set, nil, map[string]bool{}, 0)) + }) + + t.Run("nested fields add one level each", func(t *testing.T) { + // accountByAddress(1) { transactions(2) { edges(3) { node(4) { hash(5) } } } } => depth 5 + set := ast.SelectionSet{ + field("accountByAddress", ast.SelectionSet{ + field("transactions", ast.SelectionSet{ + field("edges", ast.SelectionSet{ + field("node", ast.SelectionSet{ + field("hash", nil), + }), + }), + }), + }), + } + assert.Equal(t, 5, selectionSetDepth(set, nil, map[string]bool{}, 0)) + }) + + t.Run("inline fragment does not add a level", func(t *testing.T) { + set := ast.SelectionSet{ + field("node", ast.SelectionSet{ + &ast.InlineFragment{ + SelectionSet: ast.SelectionSet{ + field("balance", nil), + }, + }, + }), + } + // node=1, inline fragment transparent, balance=2 + assert.Equal(t, 2, selectionSetDepth(set, nil, map[string]bool{}, 0)) + }) + + t.Run("fragment spread is resolved against the document's fragments and adds no level itself", func(t *testing.T) { + fragments := ast.FragmentDefinitionList{ + { + Name: "TxFields", + SelectionSet: ast.SelectionSet{ + field("hash", nil), + field("operations", ast.SelectionSet{ + field("id", nil), + }), + }, + }, + } + set := ast.SelectionSet{ + field("transactionByHash", ast.SelectionSet{ + &ast.FragmentSpread{Name: "TxFields"}, + }), + } + // transactionByHash=1, spread transparent, operations=2, id=3 + assert.Equal(t, 3, selectionSetDepth(set, fragments, map[string]bool{}, 0)) + }) + + t.Run("unresolvable fragment spread is skipped without panicking", func(t *testing.T) { + set := ast.SelectionSet{ + field("node", ast.SelectionSet{ + &ast.FragmentSpread{Name: "DoesNotExist"}, + }), + } + assert.Equal(t, 1, selectionSetDepth(set, ast.FragmentDefinitionList{}, map[string]bool{}, 0)) + }) + + t.Run("fragment cycle does not hang", func(t *testing.T) { + fragments := ast.FragmentDefinitionList{ + {Name: "A", SelectionSet: ast.SelectionSet{&ast.FragmentSpread{Name: "B"}}}, + {Name: "B", SelectionSet: ast.SelectionSet{&ast.FragmentSpread{Name: "A"}}}, + } + set := ast.SelectionSet{&ast.FragmentSpread{Name: "A"}} + + done := make(chan int, 1) + go func() { + done <- selectionSetDepth(set, fragments, map[string]bool{}, 0) + }() + + select { + case <-done: + // Terminated; the exact depth value doesn't matter, only that it returned. + case <-time.After(2 * time.Second): + t.Fatal("selectionSetDepth did not terminate on a cyclic fragment spread") + } + }) + + t.Run("a fragment spread more than once (not a cycle) is still counted each time", func(t *testing.T) { + fragments := ast.FragmentDefinitionList{ + {Name: "Shared", SelectionSet: ast.SelectionSet{field("value", nil)}}, + } + set := ast.SelectionSet{ + field("a", ast.SelectionSet{&ast.FragmentSpread{Name: "Shared"}}), + field("b", ast.SelectionSet{&ast.FragmentSpread{Name: "Shared"}}), + } + // a=1/b=1, Shared transparent, value=2 + assert.Equal(t, 2, selectionSetDepth(set, fragments, map[string]bool{}, 0)) + }) +} + +func TestDepthLimitMutateOperationContext(t *testing.T) { + newOpCtx := func(depth int) *graphql.OperationContext { + set := ast.SelectionSet{field("leaf", nil)} + for i := 0; i < depth-1; i++ { + set = ast.SelectionSet{field("wrap", set)} + } + op := &ast.OperationDefinition{SelectionSet: set} + return &graphql.OperationContext{ + Doc: &ast.QueryDocument{Operations: ast.OperationList{op}}, + } + } + + t.Run("operation within the limit is allowed", func(t *testing.T) { + d := DepthLimit{MaxDepth: 15} + err := d.MutateOperationContext(context.Background(), newOpCtx(10)) + require.Nil(t, err) + }) + + t.Run("operation exceeding the limit is rejected with QUERY_TOO_DEEP", func(t *testing.T) { + d := DepthLimit{MaxDepth: 15} + err := d.MutateOperationContext(context.Background(), newOpCtx(16)) + require.NotNil(t, err) + assert.Equal(t, errQueryTooDeep, err.Extensions["code"]) + assert.Contains(t, err.Message, "exceeds the limit of 15") + }) + + t.Run("MaxDepth <= 0 falls back to MaxQueryDepth", func(t *testing.T) { + d := DepthLimit{} + err := d.MutateOperationContext(context.Background(), newOpCtx(MaxQueryDepth+1)) + require.NotNil(t, err) + assert.Contains(t, err.Message, "exceeds the limit of 15") + }) + + t.Run("unresolvable operation name returns no error", func(t *testing.T) { + d := DepthLimit{MaxDepth: 1} + opCtx := &graphql.OperationContext{ + OperationName: "Missing", + Doc: &ast.QueryDocument{Operations: ast.OperationList{{Name: "Other"}}}, + } + err := d.MutateOperationContext(context.Background(), opCtx) + require.Nil(t, err) + }) +} diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 3a729a694..b8efa2b5f 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -192,10 +192,7 @@ type ComplexityRoot struct { Query struct { AccountByAddress func(childComplexity int, address string) int OperationByID func(childComplexity int, id int64) int - Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int - StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int TransactionByHash func(childComplexity int, hash string) int - Transactions func(childComplexity int, first *int32, after *string, last *int32, before *string) int } ReservesChange struct { @@ -317,16 +314,6 @@ type ComplexityRoot struct { StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int } - TransactionConnection struct { - Edges func(childComplexity int) int - PageInfo func(childComplexity int) int - } - - TransactionEdge struct { - Cursor func(childComplexity int) int - Node func(childComplexity int) int - } - TrustlineBalance struct { Balance func(childComplexity int) int BuyingLiabilities func(childComplexity int) int @@ -418,11 +405,8 @@ type OperationResolver interface { } type QueryResolver interface { TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) - Transactions(ctx context.Context, first *int32, after *string, last *int32, before *string) (*TransactionConnection, error) AccountByAddress(ctx context.Context, address string) (*types.Account, error) - Operations(ctx context.Context, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) OperationByID(ctx context.Context, id int64) (*types.Operation, error) - StateChanges(ctx context.Context, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) } type ReservesChangeResolver interface { Type(ctx context.Context, obj *types.ReservesStateChangeModel) (types.StateChangeCategory, error) @@ -1116,28 +1100,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.OperationByID(childComplexity, args["id"].(int64)), true - case "Query.operations": - if e.ComplexityRoot.Query.Operations == nil { - break - } - - args, err := ec.field_Query_operations_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.ComplexityRoot.Query.Operations(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true - case "Query.stateChanges": - if e.ComplexityRoot.Query.StateChanges == nil { - break - } - - args, err := ec.field_Query_stateChanges_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.ComplexityRoot.Query.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Query.transactionByHash": if e.ComplexityRoot.Query.TransactionByHash == nil { break @@ -1149,17 +1111,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.TransactionByHash(childComplexity, args["hash"].(string)), true - case "Query.transactions": - if e.ComplexityRoot.Query.Transactions == nil { - break - } - - args, err := ec.field_Query_transactions_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.ComplexityRoot.Query.Transactions(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "ReservesChange.account": if e.ComplexityRoot.ReservesChange.Account == nil { @@ -1681,32 +1632,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Transaction.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true - case "TransactionConnection.edges": - if e.ComplexityRoot.TransactionConnection.Edges == nil { - break - } - - return e.ComplexityRoot.TransactionConnection.Edges(childComplexity), true - case "TransactionConnection.pageInfo": - if e.ComplexityRoot.TransactionConnection.PageInfo == nil { - break - } - - return e.ComplexityRoot.TransactionConnection.PageInfo(childComplexity), true - - case "TransactionEdge.cursor": - if e.ComplexityRoot.TransactionEdge.Cursor == nil { - break - } - - return e.ComplexityRoot.TransactionEdge.Cursor(childComplexity), true - case "TransactionEdge.node": - if e.ComplexityRoot.TransactionEdge.Node == nil { - break - } - - return e.ComplexityRoot.TransactionEdge.Node(childComplexity), true - case "TrustlineBalance.balance": if e.ComplexityRoot.TrustlineBalance.Balance == nil { break @@ -2197,17 +2122,7 @@ type Operation{ stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, - {Name: "../schema/pagination.graphqls", Input: `type TransactionConnection { - edges: [TransactionEdge!] - pageInfo: PageInfo! -} - -type TransactionEdge { - node: Transaction - cursor: String! -} - -type OperationConnection { + {Name: "../schema/pagination.graphqls", Input: `type OperationConnection { edges: [OperationEdge!] pageInfo: PageInfo! } @@ -2270,11 +2185,10 @@ type AccountTransactionEdge { # In GraphQL, the Query type is the entry point for read operations type Query { transactionByHash(hash: String!): Transaction - transactions(first: Int, after: String, last: Int, before: String): TransactionConnection + accountByAddress(address: String!): Account - operations(first: Int, after: String, last: Int, before: String): OperationConnection + operationById(id: Int64!): Operation - stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, {Name: "../schema/scalars.graphqls", Input: `# GraphQL Custom Scalars - extend GraphQL's built-in scalar types @@ -2702,58 +2616,6 @@ func (ec *executionContext) field_Query_operationById_args(ctx context.Context, return args, nil } -func (ec *executionContext) field_Query_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["first"] = arg0 - arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["after"] = arg1 - arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["last"] = arg2 - arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["before"] = arg3 - return args, nil -} - -func (ec *executionContext) field_Query_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["first"] = arg0 - arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["after"] = arg1 - arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["last"] = arg2 - arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["before"] = arg3 - return args, nil -} - func (ec *executionContext) field_Query_transactionByHash_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -2765,32 +2627,6 @@ func (ec *executionContext) field_Query_transactionByHash_args(ctx context.Conte return args, nil } -func (ec *executionContext) field_Query_transactions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["first"] = arg0 - arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["after"] = arg1 - arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", ec.unmarshalOInt2ᚖint32) - if err != nil { - return nil, err - } - args["last"] = arg2 - arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", ec.unmarshalOString2ᚖstring) - if err != nil { - return nil, err - } - args["before"] = arg3 - return args, nil -} - func (ec *executionContext) field_Transaction_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -6109,53 +5945,6 @@ func (ec *executionContext) fieldContext_Query_transactionByHash(ctx context.Con return fc, nil } -func (ec *executionContext) _Query_transactions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Query_transactions, - func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().Transactions(ctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) - }, - nil, - ec.marshalOTransactionConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionConnection, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Query", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "edges": - return ec.fieldContext_TransactionConnection_edges(ctx, field) - case "pageInfo": - return ec.fieldContext_TransactionConnection_pageInfo(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type TransactionConnection", field.Name) - }, - } - defer func() { - if r := recover(); r != nil { - err = ec.Recover(ctx, r) - ec.Error(ctx, err) - } - }() - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_transactions_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } - return fc, nil -} - func (ec *executionContext) _Query_accountByAddress(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6211,53 +6000,6 @@ func (ec *executionContext) fieldContext_Query_accountByAddress(ctx context.Cont return fc, nil } -func (ec *executionContext) _Query_operations(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Query_operations, - func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().Operations(ctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) - }, - nil, - ec.marshalOOperationConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationConnection, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_Query_operations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Query", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "edges": - return ec.fieldContext_OperationConnection_edges(ctx, field) - case "pageInfo": - return ec.fieldContext_OperationConnection_pageInfo(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type OperationConnection", field.Name) - }, - } - defer func() { - if r := recover(); r != nil { - err = ec.Recover(ctx, r) - ec.Error(ctx, err) - } - }() - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_operations_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } - return fc, nil -} - func (ec *executionContext) _Query_operationById(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6323,53 +6065,6 @@ func (ec *executionContext) fieldContext_Query_operationById(ctx context.Context return fc, nil } -func (ec *executionContext) _Query_stateChanges(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Query_stateChanges, - func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().StateChanges(ctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) - }, - nil, - ec.marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Query", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "edges": - return ec.fieldContext_StateChangeConnection_edges(ctx, field) - case "pageInfo": - return ec.fieldContext_StateChangeConnection_pageInfo(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type StateChangeConnection", field.Name) - }, - } - defer func() { - if r := recover(); r != nil { - err = ec.Recover(ctx, r) - ec.Error(ctx, err) - } - }() - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_stateChanges_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } - return fc, nil -} - func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9221,177 +8916,23 @@ func (ec *executionContext) fieldContext_Transaction_stateChanges(ctx context.Co return fc, nil } -func (ec *executionContext) _TransactionConnection_edges(ctx context.Context, field graphql.CollectedField, obj *TransactionConnection) (ret graphql.Marshaler) { +func (ec *executionContext) _TrustlineBalance_balance(ctx context.Context, field graphql.CollectedField, obj *TrustlineBalance) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_TransactionConnection_edges, + ec.fieldContext_TrustlineBalance_balance, func(ctx context.Context) (any, error) { - return obj.Edges, nil + return obj.Balance, nil }, nil, - ec.marshalOTransactionEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdgeᚄ, + ec.marshalNString2string, + true, true, - false, ) } -func (ec *executionContext) fieldContext_TransactionConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "TransactionConnection", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "node": - return ec.fieldContext_TransactionEdge_node(ctx, field) - case "cursor": - return ec.fieldContext_TransactionEdge_cursor(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type TransactionEdge", field.Name) - }, - } - return fc, nil -} - -func (ec *executionContext) _TransactionConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *TransactionConnection) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_TransactionConnection_pageInfo, - func(ctx context.Context) (any, error) { - return obj.PageInfo, nil - }, - nil, - ec.marshalNPageInfo2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐPageInfo, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_TransactionConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "TransactionConnection", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "startCursor": - return ec.fieldContext_PageInfo_startCursor(ctx, field) - case "endCursor": - return ec.fieldContext_PageInfo_endCursor(ctx, field) - case "hasNextPage": - return ec.fieldContext_PageInfo_hasNextPage(ctx, field) - case "hasPreviousPage": - return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) - }, - } - return fc, nil -} - -func (ec *executionContext) _TransactionEdge_node(ctx context.Context, field graphql.CollectedField, obj *TransactionEdge) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_TransactionEdge_node, - func(ctx context.Context) (any, error) { - return obj.Node, nil - }, - nil, - ec.marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_TransactionEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "TransactionEdge", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "hash": - return ec.fieldContext_Transaction_hash(ctx, field) - case "feeCharged": - return ec.fieldContext_Transaction_feeCharged(ctx, field) - case "resultCode": - return ec.fieldContext_Transaction_resultCode(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Transaction_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) - case "isFeeBump": - return ec.fieldContext_Transaction_isFeeBump(ctx, field) - case "ingestedAt": - return ec.fieldContext_Transaction_ingestedAt(ctx, field) - case "operations": - return ec.fieldContext_Transaction_operations(ctx, field) - case "accounts": - return ec.fieldContext_Transaction_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Transaction_stateChanges(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) - }, - } - return fc, nil -} - -func (ec *executionContext) _TransactionEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *TransactionEdge) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_TransactionEdge_cursor, - func(ctx context.Context) (any, error) { - return obj.Cursor, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_TransactionEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "TransactionEdge", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _TrustlineBalance_balance(ctx context.Context, field graphql.CollectedField, obj *TrustlineBalance) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_TrustlineBalance_balance, - func(ctx context.Context) (any, error) { - return obj.Balance, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_TrustlineBalance_balance(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_TrustlineBalance_balance(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "TrustlineBalance", Field: field, @@ -13956,25 +13497,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "transactions": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Query_transactions(ctx, field) - return res - } - - rrm := func(ctx context.Context) graphql.Marshaler { - return ec.OperationContext.RootResolverMiddleware(ctx, - func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "accountByAddress": field := field @@ -13994,25 +13516,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "operations": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Query_operations(ctx, field) - return res - } - - rrm := func(ctx context.Context) graphql.Marshaler { - return ec.OperationContext.RootResolverMiddleware(ctx, - func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "operationById": field := field @@ -14032,25 +13535,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "stateChanges": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Query_stateChanges(ctx, field) - return res - } - - rrm := func(ctx context.Context) graphql.Marshaler { - return ec.OperationContext.RootResolverMiddleware(ctx, - func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -15965,88 +15449,6 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS return out } -var transactionConnectionImplementors = []string{"TransactionConnection"} - -func (ec *executionContext) _TransactionConnection(ctx context.Context, sel ast.SelectionSet, obj *TransactionConnection) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, transactionConnectionImplementors) - - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("TransactionConnection") - case "edges": - out.Values[i] = ec._TransactionConnection_edges(ctx, field, obj) - case "pageInfo": - out.Values[i] = ec._TransactionConnection_pageInfo(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.Deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.ProcessDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - -var transactionEdgeImplementors = []string{"TransactionEdge"} - -func (ec *executionContext) _TransactionEdge(ctx context.Context, sel ast.SelectionSet, obj *TransactionEdge) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, transactionEdgeImplementors) - - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("TransactionEdge") - case "node": - out.Values[i] = ec._TransactionEdge_node(ctx, field, obj) - case "cursor": - out.Values[i] = ec._TransactionEdge_cursor(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.Deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.ProcessDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - var trustlineBalanceImplementors = []string{"TrustlineBalance", "Balance"} func (ec *executionContext) _TrustlineBalance(ctx context.Context, sel ast.SelectionSet, obj *TrustlineBalance) graphql.Marshaler { @@ -17250,16 +16652,6 @@ func (ec *executionContext) marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwall return ec._Transaction(ctx, sel, v) } -func (ec *executionContext) marshalNTransactionEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdge(ctx context.Context, sel ast.SelectionSet, v *TransactionEdge) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._TransactionEdge(ctx, sel, v) -} - func (ec *executionContext) unmarshalNUInt322uint32(ctx context.Context, v any) (uint32, error) { res, err := scalars.UnmarshalUInt32(v) return res, graphql.ErrorOnPath(ctx, err) @@ -17614,32 +17006,6 @@ func (ec *executionContext) marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwall return ec._Transaction(ctx, sel, v) } -func (ec *executionContext) marshalOTransactionConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionConnection(ctx context.Context, sel ast.SelectionSet, v *TransactionConnection) graphql.Marshaler { - if v == nil { - return graphql.Null - } - return ec._TransactionConnection(ctx, sel, v) -} - -func (ec *executionContext) marshalOTransactionEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []*TransactionEdge) graphql.Marshaler { - if v == nil { - return graphql.Null - } - ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { - fc := graphql.GetFieldContext(ctx) - fc.Result = &v[i] - return ec.marshalNTransactionEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdge(ctx, sel, v[i]) - }) - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index 045f3d056..15a77008b 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -179,16 +179,6 @@ type StateChangeEdge struct { Cursor string `json:"cursor"` } -type TransactionConnection struct { - Edges []*TransactionEdge `json:"edges,omitempty"` - PageInfo *PageInfo `json:"pageInfo"` -} - -type TransactionEdge struct { - Node *types.Transaction `json:"node,omitempty"` - Cursor string `json:"cursor"` -} - type TrustlineBalance struct { Balance string `json:"balance"` TokenID string `json:"tokenId"` diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index 365bbe584..e47c368d5 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -35,7 +35,7 @@ func (r *accountResolver) Balances(ctx context.Context, obj *types.Account, firs func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) (*graphql1.AccountTransactionConnection, error) { params, err := parseAccountPaginationParams(first, after, last, before, CursorTypeComposite) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page @@ -74,7 +74,7 @@ func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { params, err := parseAccountPaginationParams(first, after, last, before, CursorTypeComposite) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page @@ -111,7 +111,7 @@ func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, si func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account, filter *graphql1.AccountStateChangeFilterInput, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { params, err := parseAccountPaginationParams(first, after, last, before, CursorTypeStateChange) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 4eccb7d31..31004c235 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -125,24 +125,24 @@ func TestAccountResolver_Transactions(t *testing.T) { before := encodeCursor(int64(1)) _, err := resolver.Transactions(ctx, parentAccount, nil, nil, &first, &after, nil, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + assert.Contains(t, err.Error(), "first must be greater than 0") first = int32(1) _, err = resolver.Transactions(ctx, parentAccount, nil, nil, &first, nil, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + assert.Contains(t, err.Error(), "first and last cannot be used together") _, err = resolver.Transactions(ctx, parentAccount, nil, nil, nil, &after, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + assert.Contains(t, err.Error(), "after and before cannot be used together") _, err = resolver.Transactions(ctx, parentAccount, nil, nil, &first, nil, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + assert.Contains(t, err.Error(), "first and before cannot be used together") _, err = resolver.Transactions(ctx, parentAccount, nil, nil, nil, &after, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + assert.Contains(t, err.Error(), "last and after cannot be used together") }) } diff --git a/internal/serve/graphql/resolvers/operation.resolvers.go b/internal/serve/graphql/resolvers/operation.resolvers.go index 191dbecae..c227400b4 100644 --- a/internal/serve/graphql/resolvers/operation.resolvers.go +++ b/internal/serve/graphql/resolvers/operation.resolvers.go @@ -74,9 +74,9 @@ func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) // Field resolvers receive the parent object (Operation) and return the field value func (r *operationResolver) StateChanges(ctx context.Context, obj *types.Operation, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) - params, err := parsePaginationParams(first, after, last, before, CursorTypeStateChange) + params, err := parseNestedPaginationParams(first, after, last, before, CursorTypeStateChange) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index a42ec875a..0813c9a9e 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -31,7 +31,7 @@ func TestOperationResolver_Transaction(t *testing.T) { parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64(), LedgerCreatedAt: sharedTestLedgerCreatedAt} t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transaction, err := resolver.Transaction(ctx, parentOperation) @@ -42,7 +42,7 @@ func TestOperationResolver_Transaction(t *testing.T) { }) t.Run("nil operation panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -52,7 +52,7 @@ func TestOperationResolver_Transaction(t *testing.T) { t.Run("operation with non-existent transaction", func(t *testing.T) { nonExistentOperation := &types.Operation{ID: 9999} - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transaction, err := resolver.Transaction(ctx, nonExistentOperation) @@ -77,7 +77,7 @@ func TestOperationResolver_Accounts(t *testing.T) { parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64(), LedgerCreatedAt: sharedTestLedgerCreatedAt} t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentOperation) @@ -88,7 +88,7 @@ func TestOperationResolver_Accounts(t *testing.T) { }) t.Run("nil operation panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -98,7 +98,7 @@ func TestOperationResolver_Accounts(t *testing.T) { t.Run("operation with no associated accounts", func(t *testing.T) { nonExistentOperation := &types.Operation{ID: 9999} - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, nonExistentOperation) @@ -128,7 +128,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { nonExistentOperation := &types.Operation{ID: 9999} t.Run("success without pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentOperation, nil, nil, nil, nil) @@ -157,7 +157,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { }) t.Run("get state changes with first/after pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) opID := toid.New(1000, 1, 1).ToInt64() @@ -193,7 +193,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { }) t.Run("get state changes with last/before pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) opID := toid.New(1000, 1, 1).ToInt64() @@ -230,7 +230,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { }) t.Run("invalid pagination params", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) first := int32(0) last := int32(1) @@ -239,28 +239,28 @@ func TestOperationResolver_StateChanges(t *testing.T) { _, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + assert.Contains(t, err.Error(), "first must be greater than 0") first = int32(1) _, err = resolver.StateChanges(ctx, parentOperation, &first, nil, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + assert.Contains(t, err.Error(), "first and last cannot be used together") _, err = resolver.StateChanges(ctx, parentOperation, nil, &after, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + assert.Contains(t, err.Error(), "after and before cannot be used together") _, err = resolver.StateChanges(ctx, parentOperation, &first, nil, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + assert.Contains(t, err.Error(), "first and before cannot be used together") _, err = resolver.StateChanges(ctx, parentOperation, nil, &after, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + assert.Contains(t, err.Error(), "last and after cannot be used together") }) t.Run("pagination with larger limit than available data", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) first := int32(100) stateChanges, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) @@ -270,8 +270,17 @@ func TestOperationResolver_StateChanges(t *testing.T) { assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) + t.Run("first exceeds the nested page limit", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) + first := int32(101) + _, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "first must be less than or equal to 100") + }) + t.Run("nil operation panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -280,7 +289,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { }) t.Run("operation with no state changes", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, nonExistentOperation, nil, nil, nil, nil) diff --git a/internal/serve/graphql/resolvers/pagination_resolvers_test.go b/internal/serve/graphql/resolvers/pagination_resolvers_test.go index c1765f6e9..c2009c835 100644 --- a/internal/serve/graphql/resolvers/pagination_resolvers_test.go +++ b/internal/serve/graphql/resolvers/pagination_resolvers_test.go @@ -72,7 +72,7 @@ func TestAccountTransactionEdgeResolver(t *testing.T) { } t.Run("operations are scoped to the account", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(models) + loaders := dataloaders.NewDataloaders(models, m.Dataloader) c := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) ops, err := resolver.Operations(c, edge) require.NoError(t, err) @@ -84,7 +84,7 @@ func TestAccountTransactionEdgeResolver(t *testing.T) { }) t.Run("state changes are scoped to the account", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(models) + loaders := dataloaders.NewDataloaders(models, m.Dataloader) // Select "type" (state_change_category): it is the discriminator convertStateChangeTypes // switches on, so it must be among the loaded columns for the row to resolve to a concrete // type rather than a nil BaseStateChange. The loader force-loads to_id via prepareColumnsWithID. @@ -163,7 +163,7 @@ func TestAccountTransactionEdge_NestedOperations_NoLedgerCreatedAtSelected(t *te require.False(t, edge.Node.LedgerCreatedAt.IsZero(), "node must carry the real ledger_created_at for partition pinning") require.Equal(t, types.AddressBytea(acct), edge.AccountAddress, "Transactions must stamp the parent account onto each edge so nested resolvers scope correctly") - loaders := dataloaders.NewDataloaders(models) + loaders := dataloaders.NewDataloaders(models, m.Dataloader) opCtx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) ops, err := edgeResolver.Operations(opCtx, edge) require.NoError(t, err) diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 9f9f46183..aa1ba2130 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -7,7 +7,6 @@ package resolvers import ( "context" - "fmt" "strings" "github.com/vektah/gqlparser/v2/gqlerror" @@ -34,40 +33,6 @@ func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*ty return r.models.Transactions.GetByHash(ctx, hash, strings.Join(dbColumns, ", ")) } -// Transactions is the resolver for the transactions field. -// This resolver handles the "transactions" query. -// It demonstrates handling optional arguments (limit can be nil) -func (r *queryResolver) Transactions(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.TransactionConnection, error) { - params, err := parsePaginationParams(first, after, last, before, CursorTypeComposite) - if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) - } - queryLimit := *params.Limit + 1 // +1 to check if there is a next page - - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) - transactions, err := r.models.Transactions.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.CompositeCursor, params.SortOrder) - if err != nil { - return nil, fmt.Errorf("getting transactions from db: %w", err) - } - - conn := NewConnectionWithRelayPagination(transactions, params, func(t *types.TransactionWithCursor) string { - return fmt.Sprintf("%d:%d", t.CompositeCursor.LedgerCreatedAt.UnixNano(), t.CompositeCursor.ID) - }) - - edges := make([]*graphql1.TransactionEdge, len(conn.Edges)) - for i, edge := range conn.Edges { - edges[i] = &graphql1.TransactionEdge{ - Node: &edge.Node.Transaction, - Cursor: edge.Cursor, - } - } - - return &graphql1.TransactionConnection{ - Edges: edges, - PageInfo: conn.PageInfo, - }, nil -} - // AccountByAddress is the resolver for the accountByAddress field. func (r *queryResolver) AccountByAddress(ctx context.Context, address string) (*types.Account, error) { if !utils.IsValidStellarAddress(address) { @@ -82,78 +47,12 @@ func (r *queryResolver) AccountByAddress(ctx context.Context, address string) (* return &types.Account{StellarAddress: types.AddressBytea(address)}, nil } -// Operations is the resolver for the operations field. -// This resolver handles the "operations" query. -func (r *queryResolver) Operations(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { - params, err := parsePaginationParams(first, after, last, before, CursorTypeComposite) - if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) - } - queryLimit := *params.Limit + 1 // +1 to check if there is a next page - - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) - operations, err := r.models.Operations.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.CompositeCursor, params.SortOrder) - if err != nil { - return nil, fmt.Errorf("getting operations from db: %w", err) - } - - conn := NewConnectionWithRelayPagination(operations, params, func(o *types.OperationWithCursor) string { - return fmt.Sprintf("%d:%d", o.CompositeCursor.LedgerCreatedAt.UnixNano(), o.CompositeCursor.ID) - }) - - edges := make([]*graphql1.OperationEdge, len(conn.Edges)) - for i, edge := range conn.Edges { - edges[i] = &graphql1.OperationEdge{ - Node: &edge.Node.Operation, - Cursor: edge.Cursor, - } - } - - return &graphql1.OperationConnection{ - Edges: edges, - PageInfo: conn.PageInfo, - }, nil -} - // OperationByID is the resolver for the operationById field. func (r *queryResolver) OperationByID(ctx context.Context, id int64) (*types.Operation, error) { dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) return r.models.Operations.GetByID(ctx, id, strings.Join(dbColumns, ", ")) } -// StateChanges is the resolver for the stateChanges field. -func (r *queryResolver) StateChanges(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { - params, err := parsePaginationParams(first, after, last, before, CursorTypeStateChange) - if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) - } - queryLimit := *params.Limit + 1 // +1 to check if there is a next page - - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) - stateChanges, err := r.models.StateChanges.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.StateChangeCursor, params.SortOrder) - if err != nil { - return nil, fmt.Errorf("getting state changes from db: %w", err) - } - - convertedStateChanges := convertStateChangeToBaseStateChange(stateChanges) - conn := NewConnectionWithRelayPagination(convertedStateChanges, params, func(sc *baseStateChangeWithCursor) string { - return fmt.Sprintf("%d:%d:%d:%d", sc.cursor.LedgerCreatedAt.UnixNano(), sc.cursor.ToID, sc.cursor.OperationID, sc.cursor.StateChangeID) - }) - - edges := make([]*graphql1.StateChangeEdge, len(conn.Edges)) - for i, edge := range conn.Edges { - edges[i] = &graphql1.StateChangeEdge{ - Node: edge.Node.stateChange, - Cursor: edge.Cursor, - } - } - - return &graphql1.StateChangeConnection{ - Edges: edges, - PageInfo: conn.PageInfo, - }, nil -} - // Query returns graphql1.QueryResolver implementation. func (r *Resolver) Query() graphql1.QueryResolver { return &queryResolver{r} } diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index 2e14581bc..0df9ff469 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -75,157 +75,6 @@ func TestQueryResolver_TransactionByHash(t *testing.T) { }) } -func TestQueryResolver_Transactions(t *testing.T) { - reg := prometheus.NewRegistry() - m := metrics.NewMetrics(reg) - - resolver := &queryResolver{ - &Resolver{ - models: &data.Models{ - Transactions: &data.TransactionModel{ - DB: testDBConnectionPool, - Metrics: m.DB, - }, - }, - }, - } - - t.Run("get all transactions", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - transactions, err := resolver.Transactions(ctx, nil, nil, nil, nil) - - require.NoError(t, err) - require.Len(t, transactions.Edges, 4) - assert.Equal(t, testTxHash1, transactions.Edges[0].Node.Hash.String()) - assert.Equal(t, testTxHash2, transactions.Edges[1].Node.Hash.String()) - assert.Equal(t, testTxHash3, transactions.Edges[2].Node.Hash.String()) - assert.Equal(t, testTxHash4, transactions.Edges[3].Node.Hash.String()) - }) - - t.Run("get transactions with first/after limit and cursor", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - first := int32(2) - txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 2) - assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) - assert.Equal(t, testTxHash2, txs.Edges[1].Node.Hash.String()) - assert.True(t, txs.PageInfo.HasNextPage) - assert.False(t, txs.PageInfo.HasPreviousPage) - - // Get the next cursor - first = int32(1) - nextCursor := txs.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 1) - assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) - assert.True(t, txs.PageInfo.HasNextPage) - assert.True(t, txs.PageInfo.HasPreviousPage) - - // Get the previous cursor - first = int32(10) - nextCursor = txs.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 1) - assert.Equal(t, testTxHash4, txs.Edges[0].Node.Hash.String()) - assert.False(t, txs.PageInfo.HasNextPage) - assert.True(t, txs.PageInfo.HasPreviousPage) - }) - - t.Run("get transactions with last/before limit and cursor", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - last := int32(2) - txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 2) - assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) - assert.Equal(t, testTxHash4, txs.Edges[1].Node.Hash.String()) - assert.False(t, txs.PageInfo.HasNextPage) - assert.True(t, txs.PageInfo.HasPreviousPage) - - // Get the next cursor (going backward, use StartCursor per Relay spec) - last = int32(1) - nextCursor := txs.PageInfo.StartCursor - assert.NotNil(t, nextCursor) - txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) - require.NoError(t, err) - assert.Len(t, txs.Edges, 1) - assert.Equal(t, testTxHash2, txs.Edges[0].Node.Hash.String()) - assert.True(t, txs.PageInfo.HasNextPage) - assert.True(t, txs.PageInfo.HasPreviousPage) - - nextCursor = txs.PageInfo.StartCursor - assert.NotNil(t, nextCursor) - last = int32(10) - txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) - require.NoError(t, err) - assert.Len(t, txs.Edges, 1) - assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) - assert.True(t, txs.PageInfo.HasNextPage) - assert.False(t, txs.PageInfo.HasPreviousPage) - }) - - t.Run("returns error when first is negative", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - first := int32(-1) - txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, txs) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is negative", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - last := int32(-1) - txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, txs) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("returns error when first is zero", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - first := int32(0) - txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, txs) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is zero", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - last := int32(0) - txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, txs) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("first parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - first := int32(100) - txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 4) - assert.False(t, txs.PageInfo.HasNextPage) - assert.False(t, txs.PageInfo.HasPreviousPage) - }) - - t.Run("last parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash"}) - last := int32(100) - txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, txs.Edges, 4) - assert.False(t, txs.PageInfo.HasNextPage) - assert.False(t, txs.PageInfo.HasPreviousPage) - }) -} - func TestQueryResolver_Account(t *testing.T) { resolver := &queryResolver{&Resolver{}} @@ -249,168 +98,6 @@ func TestQueryResolver_Account(t *testing.T) { }) } -func TestQueryResolver_Operations(t *testing.T) { - reg := prometheus.NewRegistry() - m := metrics.NewMetrics(reg) - - resolver := &queryResolver{ - &Resolver{ - models: &data.Models{ - Operations: &data.OperationModel{ - DB: testDBConnectionPool, - Metrics: m.DB, - }, - }, - }, - } - - t.Run("get all operations", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - operations, err := resolver.Operations(ctx, nil, nil, nil, nil) - - require.NoError(t, err) - require.Len(t, operations.Edges, 8) - // Operations are ordered by ID ascending - assert.Equal(t, testOpXDR(1), operations.Edges[0].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(2), operations.Edges[1].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(3), operations.Edges[2].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(4), operations.Edges[3].Node.OperationXDR.String()) - }) - - t.Run("get operations with first/after limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - first := int32(2) - ops, err := resolver.Operations(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 2) - assert.Equal(t, testOpXDR(1), ops.Edges[0].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(2), ops.Edges[1].Node.OperationXDR.String()) - assert.True(t, ops.PageInfo.HasNextPage) - assert.False(t, ops.PageInfo.HasPreviousPage) - - // Get the next cursor - first = int32(1) - nextCursor := ops.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 1) - assert.Equal(t, testOpXDR(3), ops.Edges[0].Node.OperationXDR.String()) - assert.True(t, ops.PageInfo.HasNextPage) - assert.True(t, ops.PageInfo.HasPreviousPage) - - // Get the next page - first = int32(10) - nextCursor = ops.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 5) - assert.Equal(t, testOpXDR(4), ops.Edges[0].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(5), ops.Edges[1].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(6), ops.Edges[2].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(7), ops.Edges[3].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(8), ops.Edges[4].Node.OperationXDR.String()) - assert.False(t, ops.PageInfo.HasNextPage) - assert.True(t, ops.PageInfo.HasPreviousPage) - }) - - t.Run("get operations with last/before limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - last := int32(2) - ops, err := resolver.Operations(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 2) - // With backward pagination, we get the last 2 items - assert.Equal(t, testOpXDR(7), ops.Edges[0].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(8), ops.Edges[1].Node.OperationXDR.String()) - assert.False(t, ops.PageInfo.HasNextPage) - assert.True(t, ops.PageInfo.HasPreviousPage) - - // Get the previous page (use StartCursor per Relay spec) - last = int32(1) - prevCursor := ops.PageInfo.StartCursor - assert.NotNil(t, prevCursor) - ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) - require.NoError(t, err) - assert.Len(t, ops.Edges, 1) - assert.Equal(t, testOpXDR(6), ops.Edges[0].Node.OperationXDR.String()) - assert.True(t, ops.PageInfo.HasNextPage) - assert.True(t, ops.PageInfo.HasPreviousPage) - - prevCursor = ops.PageInfo.StartCursor - assert.NotNil(t, prevCursor) - last = int32(10) - ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) - require.NoError(t, err) - // There are 5 operations before (2,1): (2,2), (3,1), (3,2), (4,1), (4,2) - assert.Len(t, ops.Edges, 5) - assert.Equal(t, testOpXDR(1), ops.Edges[0].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(2), ops.Edges[1].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(3), ops.Edges[2].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(4), ops.Edges[3].Node.OperationXDR.String()) - assert.Equal(t, testOpXDR(5), ops.Edges[4].Node.OperationXDR.String()) - assert.True(t, ops.PageInfo.HasNextPage) - assert.False(t, ops.PageInfo.HasPreviousPage) - }) - - t.Run("returns error when first is negative", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - first := int32(-1) - ops, err := resolver.Operations(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, ops) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is negative", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - last := int32(-1) - ops, err := resolver.Operations(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, ops) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("returns error when first is zero", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - first := int32(0) - ops, err := resolver.Operations(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, ops) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is zero", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - last := int32(0) - ops, err := resolver.Operations(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, ops) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("first parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - first := int32(100) - ops, err := resolver.Operations(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 8) - assert.False(t, ops.PageInfo.HasNextPage) - assert.False(t, ops.PageInfo.HasPreviousPage) - }) - - t.Run("last parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_xdr"}) - last := int32(100) - ops, err := resolver.Operations(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, ops.Edges, 8) - assert.False(t, ops.PageInfo.HasNextPage) - assert.False(t, ops.PageInfo.HasPreviousPage) - }) -} - func TestQueryResolver_OperationByID(t *testing.T) { reg := prometheus.NewRegistry() m := metrics.NewMetrics(reg) @@ -460,246 +147,3 @@ func TestQueryResolver_OperationByID(t *testing.T) { assert.Nil(t, op) }) } - -func TestQueryResolver_StateChanges(t *testing.T) { - reg := prometheus.NewRegistry() - m := metrics.NewMetrics(reg) - - resolver := &queryResolver{ - &Resolver{ - models: &data.Models{ - StateChanges: &data.StateChangeModel{ - DB: testDBConnectionPool, - Metrics: m.DB, - }, - }, - }, - } - - t.Run("get all", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - stateChanges, err := resolver.StateChanges(ctx, nil, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 20) - - tx1ToID := toid.New(1000, 1, 0).ToInt64() - op1ID := toid.New(1000, 1, 1).ToInt64() - - // Edge 0: tx1 fee state change - cursor := extractStateChangeIDs(stateChanges.Edges[0].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, int64(0), cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - // Edge 1: tx1 op1 first state change - cursor = extractStateChangeIDs(stateChanges.Edges[1].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, op1ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - // Edge 2: tx1 op1 second state change - cursor = extractStateChangeIDs(stateChanges.Edges[2].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, op1ID, cursor.OperationID) - assert.Equal(t, int64(2), cursor.StateChangeID) - }) - - t.Run("get state changes with first/after limit and cursor", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - - tx1ToID := toid.New(1000, 1, 0).ToInt64() - tx1Op1ID := toid.New(1000, 1, 1).ToInt64() - tx1Op2ID := toid.New(1000, 1, 2).ToInt64() - tx2ToID := toid.New(1000, 2, 0).ToInt64() - - first := int32(2) - scs, err := resolver.StateChanges(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, scs.Edges, 2) - - // Page 1: tx1 fee and tx1 op1 sc1 - cursor := extractStateChangeIDs(scs.Edges[0].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, int64(0), cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - cursor = extractStateChangeIDs(scs.Edges[1].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, tx1Op1ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - assert.True(t, scs.PageInfo.HasNextPage) - assert.False(t, scs.PageInfo.HasPreviousPage) - - // Get the next cursor - nextCursor := scs.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - scs, err = resolver.StateChanges(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, scs.Edges, 2) - - // Page 2: tx1 op1 sc2 and tx1 op2 sc1 - cursor = extractStateChangeIDs(scs.Edges[0].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, tx1Op1ID, cursor.OperationID) - assert.Equal(t, int64(2), cursor.StateChangeID) - - cursor = extractStateChangeIDs(scs.Edges[1].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, tx1Op2ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - assert.True(t, scs.PageInfo.HasNextPage) - assert.True(t, scs.PageInfo.HasPreviousPage) - - // Get the next page with larger limit - first = int32(20) - nextCursor = scs.PageInfo.EndCursor - assert.NotNil(t, nextCursor) - scs, err = resolver.StateChanges(ctx, &first, nextCursor, nil, nil) - require.NoError(t, err) - assert.Len(t, scs.Edges, 16) // Should return next 16 items - - // Page 3: tx1 op2 sc2 and tx2 fee - cursor = extractStateChangeIDs(scs.Edges[0].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, tx1Op2ID, cursor.OperationID) - assert.Equal(t, int64(2), cursor.StateChangeID) - - cursor = extractStateChangeIDs(scs.Edges[1].Node) - assert.Equal(t, tx2ToID, cursor.ToID) - assert.Equal(t, int64(0), cursor.OperationID) // Fee state change - assert.Equal(t, int64(1), cursor.StateChangeID) - - assert.False(t, scs.PageInfo.HasNextPage) - assert.True(t, scs.PageInfo.HasPreviousPage) - }) - - t.Run("get state changes with last/before limit and cursor", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - - tx1ToID := toid.New(1000, 1, 0).ToInt64() - tx1Op1ID := toid.New(1000, 1, 1).ToInt64() - tx4ToID := toid.New(1000, 4, 0).ToInt64() - tx4Op1ID := toid.New(1000, 4, 1).ToInt64() - tx4Op2ID := toid.New(1000, 4, 2).ToInt64() - - last := int32(2) - stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 2) - - // Last 2: tx4 op2 sc1 and tx4 op2 sc2 - cursor := extractStateChangeIDs(stateChanges.Edges[0].Node) - assert.Equal(t, tx4ToID, cursor.ToID) - assert.Equal(t, tx4Op2ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - cursor = extractStateChangeIDs(stateChanges.Edges[1].Node) - assert.Equal(t, tx4ToID, cursor.ToID) - assert.Equal(t, tx4Op2ID, cursor.OperationID) - assert.Equal(t, int64(2), cursor.StateChangeID) - - assert.False(t, stateChanges.PageInfo.HasNextPage) - assert.True(t, stateChanges.PageInfo.HasPreviousPage) - - // Get the previous page (use StartCursor per Relay spec) - prevCursor := stateChanges.PageInfo.StartCursor - assert.NotNil(t, prevCursor) - stateChanges, err = resolver.StateChanges(ctx, nil, nil, &last, prevCursor) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 2) - - // Previous 2: tx4 op1 sc1 and tx4 op1 sc2 - cursor = extractStateChangeIDs(stateChanges.Edges[0].Node) - assert.Equal(t, tx4ToID, cursor.ToID) - assert.Equal(t, tx4Op1ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - cursor = extractStateChangeIDs(stateChanges.Edges[1].Node) - assert.Equal(t, tx4ToID, cursor.ToID) - assert.Equal(t, tx4Op1ID, cursor.OperationID) - assert.Equal(t, int64(2), cursor.StateChangeID) - - assert.True(t, stateChanges.PageInfo.HasNextPage) - assert.True(t, stateChanges.PageInfo.HasPreviousPage) - - // Get more previous items (use StartCursor per Relay spec) - prevCursor = stateChanges.PageInfo.StartCursor - assert.NotNil(t, prevCursor) - last = int32(20) - stateChanges, err = resolver.StateChanges(ctx, nil, nil, &last, prevCursor) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 16) - - // First page: tx1 fee, tx1 op1 sc1, ... - cursor = extractStateChangeIDs(stateChanges.Edges[0].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, int64(0), cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - cursor = extractStateChangeIDs(stateChanges.Edges[1].Node) - assert.Equal(t, tx1ToID, cursor.ToID) - assert.Equal(t, tx1Op1ID, cursor.OperationID) - assert.Equal(t, int64(1), cursor.StateChangeID) - - assert.True(t, stateChanges.PageInfo.HasNextPage) - assert.False(t, stateChanges.PageInfo.HasPreviousPage) // We're at the beginning - }) - - t.Run("returns error when first is negative", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - first := int32(-1) - stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, stateChanges) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is negative", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - last := int32(-1) - stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, stateChanges) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("returns error when first is zero", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - first := int32(0) - stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) - require.Error(t, err) - assert.Nil(t, stateChanges) - assert.Contains(t, err.Error(), "first must be greater than 0") - }) - - t.Run("returns error when last is zero", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - last := int32(0) - stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) - require.Error(t, err) - assert.Nil(t, stateChanges) - assert.Contains(t, err.Error(), "last must be greater than 0") - }) - - t.Run("first parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - first := int32(100) - stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 20) // Total available state changes - assert.False(t, stateChanges.PageInfo.HasNextPage) - assert.False(t, stateChanges.PageInfo.HasPreviousPage) - }) - - t.Run("last parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{}) - last := int32(100) - stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) - require.NoError(t, err) - assert.Len(t, stateChanges.Edges, 20) // Total available state changes - assert.False(t, stateChanges.PageInfo.HasNextPage) - assert.False(t, stateChanges.PageInfo.HasPreviousPage) - }) -} diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 78cab3617..5d29a6b3c 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -343,7 +343,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { } t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) op, err := resolver.Operation(ctx, &parentSC) @@ -352,7 +352,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { }) t.Run("nil state change panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -369,7 +369,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { StateChangeCategory: types.StateChangeCategoryBalance, }, } - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) op, err := resolver.Operation(ctx, &nonExistentSC) @@ -400,7 +400,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { } t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) tx, err := resolver.Transaction(ctx, &parentSC) @@ -409,7 +409,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { }) t.Run("nil state change panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -425,7 +425,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { StateChangeCategory: types.StateChangeCategoryBalance, }, } - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) tx, err := resolver.Transaction(ctx, &nonExistentSC) diff --git a/internal/serve/graphql/resolvers/transaction.resolvers.go b/internal/serve/graphql/resolvers/transaction.resolvers.go index 38794b909..bc80a9c90 100644 --- a/internal/serve/graphql/resolvers/transaction.resolvers.go +++ b/internal/serve/graphql/resolvers/transaction.resolvers.go @@ -26,9 +26,9 @@ func (r *transactionResolver) Hash(ctx context.Context, obj *types.Transaction) // It's called when a GraphQL query requests the operations within a transaction func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) - params, err := parsePaginationParams(first, after, last, before, CursorTypeInt64) + params, err := parseNestedPaginationParams(first, after, last, before, CursorTypeInt64) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page @@ -91,9 +91,9 @@ func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transacti // It's called when a GraphQL query requests the state changes within a transaction func (r *transactionResolver) StateChanges(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) - params, err := parsePaginationParams(first, after, last, before, CursorTypeStateChange) + params, err := parseNestedPaginationParams(first, after, last, before, CursorTypeStateChange) if err != nil { - return nil, fmt.Errorf("parsing pagination params: %w", err) + return nil, err } queryLimit := *params.Limit + 1 // +1 to check if there is a next page diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index 00cfcd180..d3c7e4be7 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -40,7 +40,7 @@ func TestTransactionResolver_Operations(t *testing.T) { parentTx := &types.Transaction{Hash: types.HashBytea(testTxHash1), ToID: toid.New(1000, 1, 0).ToInt64(), LedgerCreatedAt: sharedTestLedgerCreatedAt} t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, parentTx, nil, nil, nil, nil) @@ -52,7 +52,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("nil transaction panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -62,7 +62,7 @@ func TestTransactionResolver_Operations(t *testing.T) { t.Run("transaction with no operations", func(t *testing.T) { nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 999999} - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, nonExistentTx, nil, nil, nil, nil) @@ -72,7 +72,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("get operations with first/after pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) first := int32(1) ops, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) @@ -94,7 +94,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("get operations with last/before pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) last := int32(1) ops, err := resolver.Operations(ctx, parentTx, nil, nil, &last, nil) @@ -116,7 +116,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("invalid pagination params", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) first := int32(0) last := int32(1) @@ -125,28 +125,28 @@ func TestTransactionResolver_Operations(t *testing.T) { _, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + assert.Contains(t, err.Error(), "first must be greater than 0") first = int32(1) _, err = resolver.Operations(ctx, parentTx, &first, nil, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + assert.Contains(t, err.Error(), "first and last cannot be used together") _, err = resolver.Operations(ctx, parentTx, nil, &after, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + assert.Contains(t, err.Error(), "after and before cannot be used together") _, err = resolver.Operations(ctx, parentTx, &first, nil, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + assert.Contains(t, err.Error(), "first and before cannot be used together") _, err = resolver.Operations(ctx, parentTx, nil, &after, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + assert.Contains(t, err.Error(), "last and after cannot be used together") }) t.Run("pagination with larger limit than available data", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) first := int32(100) ops, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) @@ -155,6 +155,24 @@ func TestTransactionResolver_Operations(t *testing.T) { assert.False(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) + + t.Run("first exceeds the nested page limit", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + first := int32(101) + _, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "first must be less than or equal to 100") + }) + + t.Run("last exceeds the nested page limit", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + last := int32(101) + _, err := resolver.Operations(ctx, parentTx, nil, nil, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "last must be less than or equal to 100") + }) } func TestTransactionResolver_Accounts(t *testing.T) { @@ -172,7 +190,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { parentTx := &types.Transaction{ToID: toid.New(1000, 1, 0).ToInt64(), Hash: "1376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877"} t.Run("success", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentTx) @@ -183,7 +201,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { }) t.Run("nil transaction panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -193,7 +211,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { t.Run("transaction with no associated accounts", func(t *testing.T) { nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877"} - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, nonExistentTx) @@ -223,7 +241,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 0} t.Run("success without pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentTx, nil, nil, nil, nil) @@ -271,7 +289,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }) t.Run("get state changes with first/after pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) txToID := toid.New(1000, 1, 0).ToInt64() @@ -320,7 +338,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }) t.Run("get state changes with last/before pagination", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) txToID := toid.New(1000, 1, 0).ToInt64() @@ -375,7 +393,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }) t.Run("invalid pagination params", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) first := int32(0) last := int32(1) @@ -384,28 +402,28 @@ func TestTransactionResolver_StateChanges(t *testing.T) { _, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + assert.Contains(t, err.Error(), "first must be greater than 0") first = int32(1) _, err = resolver.StateChanges(ctx, parentTx, &first, nil, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + assert.Contains(t, err.Error(), "first and last cannot be used together") _, err = resolver.StateChanges(ctx, parentTx, nil, &after, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + assert.Contains(t, err.Error(), "after and before cannot be used together") _, err = resolver.StateChanges(ctx, parentTx, &first, nil, nil, &before) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + assert.Contains(t, err.Error(), "first and before cannot be used together") _, err = resolver.StateChanges(ctx, parentTx, nil, &after, &last, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + assert.Contains(t, err.Error(), "last and after cannot be used together") }) t.Run("pagination with larger limit than available data", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) first := int32(100) stateChanges, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) @@ -415,8 +433,17 @@ func TestTransactionResolver_StateChanges(t *testing.T) { assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) + t.Run("first exceeds the nested page limit", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + first := int32(101) + _, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "first must be less than or equal to 100") + }) + t.Run("nil transaction panics", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { @@ -425,7 +452,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }) t.Run("transaction with no state changes", func(t *testing.T) { - loaders := dataloaders.NewDataloaders(resolver.models) + loaders := dataloaders.NewDataloaders(resolver.models, m.Dataloader) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, nonExistentTx, nil, nil, nil, nil) diff --git a/internal/serve/graphql/resolvers/utils.go b/internal/serve/graphql/resolvers/utils.go index cbaff3561..5d3ea324b 100644 --- a/internal/serve/graphql/resolvers/utils.go +++ b/internal/serve/graphql/resolvers/utils.go @@ -202,12 +202,12 @@ func decodeInt64Cursor(s *string) (*int64, error) { decoded, err := base64.StdEncoding.DecodeString(*s) if err != nil { - return nil, fmt.Errorf("decoding cursor string %s: %w", *s, err) + return nil, badUserInputError(fmt.Sprintf("decoding cursor string %s: %s", *s, err)) } id, err := strconv.ParseInt(string(decoded), 10, 64) if err != nil { - return nil, fmt.Errorf("parsing cursor %s: %w", string(decoded), err) + return nil, badUserInputError(fmt.Sprintf("parsing cursor %s: %s", string(decoded), err)) } return &id, nil @@ -220,7 +220,7 @@ func decodeStringCursor(s *string) (*string, error) { decoded, err := base64.StdEncoding.DecodeString(*s) if err != nil { - return nil, fmt.Errorf("decoding cursor string %s: %w", *s, err) + return nil, badUserInputError(fmt.Sprintf("decoding cursor string %s: %s", *s, err)) } decodedStr := string(decoded) @@ -305,23 +305,52 @@ func badUserInputError(message string) error { } } +// capPaginationLimit returns a BAD_USER_INPUT error if first or last exceeds maxLimit. Shared by +// every capped connection's parseXPaginationParams wrapper below (reject, not clamp, so a client +// gets a clear error instead of a silently-shrunk page). +func capPaginationLimit(first *int32, last *int32, maxLimit int32) error { + if first != nil && *first > maxLimit { + return badUserInputError(fmt.Sprintf("first must be less than or equal to %d", maxLimit)) + } + if last != nil && *last > maxLimit { + return badUserInputError(fmt.Sprintf("last must be less than or equal to %d", maxLimit)) + } + return nil +} + // parseAccountPaginationParams caps page size for the account history connections // before delegating to parsePaginationParams. It mirrors parseBalancePaginationParams' // cap policy for the multi-source balances connection. func parseAccountPaginationParams(first *int32, after *string, last *int32, before *string, cursorType CursorType) (PaginationParams, error) { - if first != nil && *first > maxAccountPageLimit { - return PaginationParams{}, badUserInputError(fmt.Sprintf("first must be less than or equal to %d", maxAccountPageLimit)) + if err := capPaginationLimit(first, last, maxAccountPageLimit); err != nil { + return PaginationParams{}, err } - if last != nil && *last > maxAccountPageLimit { - return PaginationParams{}, badUserInputError(fmt.Sprintf("last must be less than or equal to %d", maxAccountPageLimit)) + return parsePaginationParams(first, after, last, before, cursorType) +} + +// maxNestedPageLimit bounds page size on nested relation connections (Transaction.operations, +// Transaction.stateChanges, Operation.stateChanges). Their dataloader keys carry the requested +// limit as part of their batch shape, so an uncapped first/last would send an unbounded query +// once a batch's keys share that shape. +const maxNestedPageLimit int32 = 100 + +// parseNestedPaginationParams caps page size for nested relation connections before delegating to +// parsePaginationParams. It mirrors parseAccountPaginationParams' cap policy (reject, not clamp). +func parseNestedPaginationParams(first *int32, after *string, last *int32, before *string, cursorType CursorType) (PaginationParams, error) { + if err := capPaginationLimit(first, last, maxNestedPageLimit); err != nil { + return PaginationParams{}, err } return parsePaginationParams(first, after, last, before, cursorType) } func parsePaginationParams(first *int32, after *string, last *int32, before *string, cursorType CursorType) (PaginationParams, error) { + // validatePaginationParams and the cursor decoders below already return a self-describing, + // client-safe *gqlerror.Error (BAD_USER_INPUT); returning it unwrapped avoids stuttering, + // noisy prefixes (fmt.Errorf's %w formatting would otherwise sit alongside gqlerror.Error's own + // "input: " location prefix) while errors.As still finds it fine through the call chain. err := validatePaginationParams(first, after, last, before) if err != nil { - return PaginationParams{}, fmt.Errorf("validating pagination params: %w", err) + return PaginationParams{}, err } var cursor *string @@ -348,25 +377,25 @@ func parsePaginationParams(first *int32, after *string, last *int32, before *str case CursorTypeStateChange: stateChangeCursor, err := parseStateChangeCursor(cursor) if err != nil { - return PaginationParams{}, fmt.Errorf("parsing state change cursor: %w", err) + return PaginationParams{}, err } paginationParams.StateChangeCursor = stateChangeCursor case CursorTypeComposite: compositeCursor, err := parseCompositeCursor(cursor) if err != nil { - return PaginationParams{}, fmt.Errorf("parsing composite cursor: %w", err) + return PaginationParams{}, err } paginationParams.CompositeCursor = compositeCursor case CursorTypeString: decodedCursor, err := decodeStringCursor(cursor) if err != nil { - return PaginationParams{}, fmt.Errorf("decoding cursor: %w", err) + return PaginationParams{}, err } paginationParams.StringCursor = decodedCursor default: decodedCursor, err := decodeInt64Cursor(cursor) if err != nil { - return PaginationParams{}, fmt.Errorf("decoding cursor: %w", err) + return PaginationParams{}, err } paginationParams.Cursor = decodedCursor } @@ -381,32 +410,32 @@ func parseStateChangeCursor(s *string) (*types.StateChangeCursor, error) { decodedCursor, err := decodeStringCursor(s) if err != nil { - return nil, fmt.Errorf("decoding cursor: %w", err) + return nil, err } parts := strings.Split(*decodedCursor, ":") if len(parts) != 4 { - return nil, fmt.Errorf("invalid cursor format: %s (expected format: ledger_created_at_nano:to_id:operation_id:state_change_id)", *s) + return nil, badUserInputError(fmt.Sprintf("invalid cursor format: %s (expected format: ledger_created_at_nano:to_id:operation_id:state_change_id)", *s)) } nanos, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing ledger_created_at: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing ledger_created_at: %s", err)) } toID, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing to_id: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing to_id: %s", err)) } operationID, err := strconv.ParseInt(parts[2], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing operation_id: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing operation_id: %s", err)) } stateChangeID, err := strconv.ParseInt(parts[3], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing state_change_id: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing state_change_id: %s", err)) } return &types.StateChangeCursor{ @@ -424,22 +453,22 @@ func parseCompositeCursor(s *string) (*types.CompositeCursor, error) { decodedCursor, err := decodeStringCursor(s) if err != nil { - return nil, fmt.Errorf("decoding cursor: %w", err) + return nil, err } parts := strings.Split(*decodedCursor, ":") if len(parts) != 2 { - return nil, fmt.Errorf("invalid cursor format: %s (expected format: ledger_created_at_nano:id)", *s) + return nil, badUserInputError(fmt.Sprintf("invalid cursor format: %s (expected format: ledger_created_at_nano:id)", *s)) } nanos, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing ledger_created_at: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing ledger_created_at: %s", err)) } id, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { - return nil, fmt.Errorf("parsing id: %w", err) + return nil, badUserInputError(fmt.Sprintf("parsing id: %s", err)) } return &types.CompositeCursor{ @@ -456,34 +485,34 @@ func buildTimeRange(since *time.Time, until *time.Time) (*data.TimeRange, error) return nil, nil } if since != nil && until != nil && until.Before(*since) { - return nil, fmt.Errorf("until must not be before since") + return nil, badUserInputError("until must not be before since") } return &data.TimeRange{Since: since, Until: until}, nil } func validatePaginationParams(first *int32, after *string, last *int32, before *string) error { if first != nil && last != nil { - return fmt.Errorf("first and last cannot be used together") + return badUserInputError("first and last cannot be used together") } if after != nil && before != nil { - return fmt.Errorf("after and before cannot be used together") + return badUserInputError("after and before cannot be used together") } if first != nil && *first <= 0 { - return fmt.Errorf("first must be greater than 0") + return badUserInputError("first must be greater than 0") } if last != nil && *last <= 0 { - return fmt.Errorf("last must be greater than 0") + return badUserInputError("last must be greater than 0") } if first != nil && before != nil { - return fmt.Errorf("first and before cannot be used together") + return badUserInputError("first and before cannot be used together") } if last != nil && after != nil { - return fmt.Errorf("last and after cannot be used together") + return badUserInputError("last and after cannot be used together") } return nil diff --git a/internal/serve/graphql/resolvers/utils_test.go b/internal/serve/graphql/resolvers/utils_test.go index 78ca1329a..d2b87ce84 100644 --- a/internal/serve/graphql/resolvers/utils_test.go +++ b/internal/serve/graphql/resolvers/utils_test.go @@ -47,3 +47,119 @@ func TestParseAccountPaginationParams(t *testing.T) { assert.Equal(t, maxAccountPageLimit, *params.Limit) }) } + +// requireBadUserInput asserts that err chains to a *gqlerror.Error carrying the BAD_USER_INPUT +// code, the shape the custom error presenter (GQL-05) requires to avoid masking a legitimate +// client-input error behind a generic "internal server error". +func requireBadUserInput(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, "BAD_USER_INPUT", gqlErr.Extensions["code"]) +} + +func TestValidatePaginationParamsReturnsBadUserInput(t *testing.T) { + first := int32(1) + last := int32(1) + after := "cursor" + before := "cursor" + zero := int32(0) + negative := int32(-1) + + requireBadUserInput(t, validatePaginationParams(&first, nil, &last, nil)) + requireBadUserInput(t, validatePaginationParams(nil, &after, nil, &before)) + requireBadUserInput(t, validatePaginationParams(&zero, nil, nil, nil)) + requireBadUserInput(t, validatePaginationParams(nil, nil, &zero, nil)) + requireBadUserInput(t, validatePaginationParams(&negative, nil, nil, nil)) + requireBadUserInput(t, validatePaginationParams(nil, nil, &negative, nil)) + requireBadUserInput(t, validatePaginationParams(&first, nil, nil, &before)) + requireBadUserInput(t, validatePaginationParams(nil, &after, &last, nil)) +} + +func TestDecodeInt64CursorReturnsBadUserInput(t *testing.T) { + t.Run("invalid base64 returns BAD_USER_INPUT", func(t *testing.T) { + bad := "not-valid-base64!!!" + _, err := decodeInt64Cursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("non-numeric decoded value returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("not-a-number") + _, err := decodeInt64Cursor(&bad) + requireBadUserInput(t, err) + }) +} + +func TestDecodeStringCursorReturnsBadUserInput(t *testing.T) { + bad := "not-valid-base64!!!" + _, err := decodeStringCursor(&bad) + requireBadUserInput(t, err) +} + +func TestParseCompositeCursorReturnsBadUserInput(t *testing.T) { + t.Run("invalid base64 returns BAD_USER_INPUT", func(t *testing.T) { + bad := "not-valid-base64!!!" + _, err := parseCompositeCursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("wrong number of parts returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("only-one-part") + _, err := parseCompositeCursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("non-numeric ledger_created_at returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("notanumber:5") + _, err := parseCompositeCursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("non-numeric id returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("5:notanumber") + _, err := parseCompositeCursor(&bad) + requireBadUserInput(t, err) + }) +} + +func TestParseStateChangeCursorReturnsBadUserInput(t *testing.T) { + t.Run("invalid base64 returns BAD_USER_INPUT", func(t *testing.T) { + bad := "not-valid-base64!!!" + _, err := parseStateChangeCursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("wrong number of parts returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("only:three:parts") + _, err := parseStateChangeCursor(&bad) + requireBadUserInput(t, err) + }) + + t.Run("non-numeric part returns BAD_USER_INPUT", func(t *testing.T) { + bad := encodeCursor("5:6:notanumber:8") + _, err := parseStateChangeCursor(&bad) + requireBadUserInput(t, err) + }) +} + +// TestParsePaginationParamsMalformedCursorIsBadUserInput locks in the GQL-11 interplay with GQL-05: +// a malformed client cursor must still surface as BAD_USER_INPUT after being wrapped by +// parsePaginationParams's fmt.Errorf("...: %w", err) — the presenter's errors.As must find the +// coded error through the wrap chain rather than it being masked as an internal error. +func TestParsePaginationParamsMalformedCursorIsBadUserInput(t *testing.T) { + first := int32(1) + bad := "not-valid-base64!!!" + + _, err := parsePaginationParams(&first, &bad, nil, nil, CursorTypeComposite) + requireBadUserInput(t, err) + + _, err = parsePaginationParams(&first, &bad, nil, nil, CursorTypeStateChange) + requireBadUserInput(t, err) + + _, err = parsePaginationParams(&first, &bad, nil, nil, CursorTypeString) + requireBadUserInput(t, err) + + _, err = parsePaginationParams(&first, &bad, nil, nil, CursorTypeInt64) + requireBadUserInput(t, err) +} diff --git a/internal/serve/graphql/scalars/uint32.go b/internal/serve/graphql/scalars/uint32.go index 58e1fee92..15eb5f560 100644 --- a/internal/serve/graphql/scalars/uint32.go +++ b/internal/serve/graphql/scalars/uint32.go @@ -4,8 +4,11 @@ package scalars import ( + "encoding/json" "fmt" "io" + "math" + "strconv" graphql "github.com/99designs/gqlgen/graphql" ) @@ -27,14 +30,42 @@ func MarshalUInt32(i uint32) graphql.Marshaler { // UnmarshalUInt32 converts GraphQL input to Go uint32 // This function is called by gqlgen when parsing UInt32 arguments and input fields -// GraphQL clients can send integers, and we convert them to uint32 +// GraphQL clients can send integers, floats, numeric strings, or json.Number +// (variables decoded from a JSON request body), so all of these are accepted here. func UnmarshalUInt32(v any) (uint32, error) { - // Handle different input types that GraphQL clients might send switch v := v.(type) { case int: - // Convert int to uint32 - this is the most common case - return uint32(v), nil + return int64ToUInt32(int64(v)) + case int32: + return int64ToUInt32(int64(v)) + case int64: + return int64ToUInt32(v) + case json.Number: + i, err := v.Int64() + if err != nil { + return 0, fmt.Errorf("parsing %q as UInt32: %w", v, err) + } + return int64ToUInt32(i) + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%v is not an integral UInt32", v) + } + return int64ToUInt32(int64(v)) + case string: + i, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing %q as UInt32: %w", v, err) + } + return int64ToUInt32(i) default: return 0, fmt.Errorf("%T is not a UInt32", v) } } + +// int64ToUInt32 range-checks a signed 64-bit value against the uint32 domain. +func int64ToUInt32(i int64) (uint32, error) { + if i < 0 || i > math.MaxUint32 { + return 0, fmt.Errorf("%d is out of range for UInt32 [0, %d]", i, uint32(math.MaxUint32)) + } + return uint32(i), nil +} diff --git a/internal/serve/graphql/scalars/uint32_test.go b/internal/serve/graphql/scalars/uint32_test.go new file mode 100644 index 000000000..377ec2bd9 --- /dev/null +++ b/internal/serve/graphql/scalars/uint32_test.go @@ -0,0 +1,50 @@ +package scalars + +import ( + "encoding/json" + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnmarshalUInt32(t *testing.T) { + testCases := []struct { + name string + input any + want uint32 + wantError bool + }{ + {name: "int", input: int(42), want: 42}, + {name: "int32", input: int32(42), want: 42}, + {name: "int64", input: int64(42), want: 42}, + {name: "json.Number integral", input: json.Number("42"), want: 42}, + {name: "float64 integral", input: float64(42), want: 42}, + {name: "string numeric", input: "42", want: 42}, + {name: "zero", input: int(0), want: 0}, + {name: "max uint32", input: int64(math.MaxUint32), want: math.MaxUint32}, + + {name: "negative int rejected", input: int(-1), wantError: true}, + {name: "negative int64 rejected", input: int64(-1), wantError: true}, + {name: "negative string rejected", input: "-1", wantError: true}, + {name: "int64 above max rejected", input: int64(math.MaxUint32) + 1, wantError: true}, + {name: "json.Number above max rejected", input: json.Number("4294967296"), wantError: true}, + {name: "float above max rejected", input: float64(math.MaxUint32) + 1, wantError: true}, + {name: "non-integral float rejected", input: float64(42.5), wantError: true}, + {name: "non-numeric string rejected", input: "not-a-number", wantError: true}, + {name: "unsupported type rejected", input: true, wantError: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := UnmarshalUInt32(tc.input) + if tc.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/serve/graphql/schema/pagination.graphqls b/internal/serve/graphql/schema/pagination.graphqls index 4d308a3a8..e924b4cf0 100644 --- a/internal/serve/graphql/schema/pagination.graphqls +++ b/internal/serve/graphql/schema/pagination.graphqls @@ -1,13 +1,3 @@ -type TransactionConnection { - edges: [TransactionEdge!] - pageInfo: PageInfo! -} - -type TransactionEdge { - node: Transaction - cursor: String! -} - type OperationConnection { edges: [OperationEdge!] pageInfo: PageInfo! diff --git a/internal/serve/graphql/schema/queries.graphqls b/internal/serve/graphql/schema/queries.graphqls index dd58bcefb..b5ccfe46f 100644 --- a/internal/serve/graphql/schema/queries.graphqls +++ b/internal/serve/graphql/schema/queries.graphqls @@ -2,9 +2,8 @@ # In GraphQL, the Query type is the entry point for read operations type Query { transactionByHash(hash: String!): Transaction - transactions(first: Int, after: String, last: Int, before: String): TransactionConnection + accountByAddress(address: String!): Account - operations(first: Int, after: String, last: Int, before: String): OperationConnection + operationById(id: Int64!): Operation - stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } diff --git a/internal/serve/graphql/utils.go b/internal/serve/graphql/utils.go index b7082ce1a..b42ed814f 100644 --- a/internal/serve/graphql/utils.go +++ b/internal/serve/graphql/utils.go @@ -8,14 +8,40 @@ import ( "fmt" "github.com/99designs/gqlgen/graphql" + "github.com/stellar/go-stellar-sdk/support/log" "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/stellar/wallet-backend/internal/serve/middleware" ) // DefaultPageLimit is the implicit page size used when GraphQL pagination args // are omitted. Execution and complexity accounting must share this value. const DefaultPageLimit int32 = 50 -// CustomErrorPresenter provides more detailed error messages for GraphQL validation errors +// clientSafeErrorCodes enumerates every GraphQL error "code" extension the API deliberately +// constructs (or that gqlgen itself assigns) for client consumption: validation/parse errors, +// the complexity and depth limits, and the app's own coded resolver errors (see +// resolvers/errors.go and the badUserInputError/balanceBadUserInputError helpers). Any error +// presented without one of these codes is treated as an internal failure by CustomErrorPresenter +// and masked before it reaches the client. +var clientSafeErrorCodes = map[string]bool{ + "BAD_USER_INPUT": true, + "INVALID_TRANSACTION_HASH": true, + "INVALID_ADDRESS": true, + "INTERNAL_ERROR": true, // resolvers/account_balances.go: already a sanitized, generic message + "GRAPHQL_VALIDATION_FAILED": true, // gqlparser query validation (unknown field, bad args, ...) + "GRAPHQL_PARSE_FAILED": true, // gqlparser query parse errors + "COMPLEXITY_LIMIT_EXCEEDED": true, // gqlgen extension.FixedComplexityLimit + "QUERY_TOO_DEEP": true, // query depth limit extension + "UNAUTHENTICATED": true, + "FORBIDDEN": true, +} + +// CustomErrorPresenter provides more detailed error messages for GraphQL validation errors, and +// masks any error that isn't one of clientSafeErrorCodes behind a generic message. Resolvers and +// the data layer can return plain Go errors (a SQL driver error, a wrapped fmt.Errorf, ...); left +// unmasked, DefaultErrorPresenter would forward err.Error() verbatim to the client, potentially +// leaking query text, table/column names, or other internal detail (GQL-05). func CustomErrorPresenter(ctx context.Context, err error) *gqlerror.Error { var gqlErr *gqlerror.Error if errors.As(err, &gqlErr) { @@ -42,5 +68,38 @@ func CustomErrorPresenter(ctx context.Context, err error) *gqlerror.Error { } } } - return graphql.DefaultErrorPresenter(ctx, err) + + presented := graphql.DefaultErrorPresenter(ctx, err) + + if code, ok := presented.Extensions["code"].(string); ok && clientSafeErrorCodes[code] { + return presented + } + + oc := safeGetOperationContext(ctx) + fc := graphql.GetFieldContext(ctx) + log.Ctx(ctx).WithFields(log.F{ + "graphql_operation": middleware.GetOperationIdentifier(oc), + "graphql_field": middleware.GetFieldPath(fc), + }).Errorf("internal GraphQL error: %v", err) + + return &gqlerror.Error{ + Message: "internal server error", + Path: presented.Path, + Locations: presented.Locations, + Extensions: map[string]interface{}{"code": "INTERNAL_SERVER_ERROR"}, + } +} + +// safeGetOperationContext returns the request's OperationContext, or nil if none is set. +// graphql.GetOperationContext panics when no context has been attached; errors dispatched before +// DispatchOperation runs (parse errors, validation errors, and operationContextMutator errors such +// as the complexity and depth limits) go through DispatchError, which never attaches one, so the +// presenter must tolerate that instead of panicking on every such error. +func safeGetOperationContext(ctx context.Context) (oc *graphql.OperationContext) { + defer func() { + if recover() != nil { + oc = nil + } + }() + return graphql.GetOperationContext(ctx) } diff --git a/internal/serve/graphql/utils_test.go b/internal/serve/graphql/utils_test.go index 52126f544..f4ae31cc6 100644 --- a/internal/serve/graphql/utils_test.go +++ b/internal/serve/graphql/utils_test.go @@ -5,6 +5,7 @@ package graphql import ( "context" "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -16,16 +17,17 @@ import ( func TestCustomErrorPresenter(t *testing.T) { ctx := context.Background() - t.Run("non-gqlerror passes through to default presenter", func(t *testing.T) { - regularErr := errors.New("regular error") + t.Run("non-gqlerror (e.g. a raw driver/SQL error) is masked as an internal error", func(t *testing.T) { + regularErr := errors.New("pq: relation \"transactions\" does not exist") result := CustomErrorPresenter(ctx, regularErr) - // The default presenter should convert this to a gqlerror require.NotNil(t, result) - assert.Equal(t, "regular error", result.Message) + assert.Equal(t, "internal server error", result.Message) + assert.NotContains(t, result.Message, "transactions") + assert.Equal(t, "INTERNAL_SERVER_ERROR", result.Extensions["code"]) }) - t.Run("gqlerror without extensions passes through", func(t *testing.T) { + t.Run("gqlerror without extensions (no code) is masked as an internal error", func(t *testing.T) { gqlErr := &gqlerror.Error{ Message: "some graphql error", Path: ast.Path{ast.PathName("field")}, @@ -33,10 +35,11 @@ func TestCustomErrorPresenter(t *testing.T) { result := CustomErrorPresenter(ctx, gqlErr) require.NotNil(t, result) - assert.Equal(t, "some graphql error", result.Message) + assert.Equal(t, "internal server error", result.Message) + assert.Equal(t, "INTERNAL_SERVER_ERROR", result.Extensions["code"]) }) - t.Run("gqlerror with non-validation code passes through", func(t *testing.T) { + t.Run("gqlerror with an unrecognized code is masked as an internal error", func(t *testing.T) { gqlErr := &gqlerror.Error{ Message: "some other error", Path: ast.Path{ast.PathName("field")}, @@ -47,9 +50,57 @@ func TestCustomErrorPresenter(t *testing.T) { result := CustomErrorPresenter(ctx, gqlErr) require.NotNil(t, result) - assert.Equal(t, "some other error", result.Message) + assert.Equal(t, "internal server error", result.Message) + assert.Equal(t, "INTERNAL_SERVER_ERROR", result.Extensions["code"]) + // The masked error still carries the original path, so clients can tell which field failed. + assert.Equal(t, gqlErr.Path, result.Path) }) + t.Run("gqlerror with a known client-safe code passes through unchanged", func(t *testing.T) { + gqlErr := &gqlerror.Error{ + Message: "first must be less than or equal to 100", + Path: ast.Path{ast.PathName("field")}, + Extensions: map[string]interface{}{ + "code": "BAD_USER_INPUT", + }, + } + + result := CustomErrorPresenter(ctx, gqlErr) + require.NotNil(t, result) + assert.Equal(t, "first must be less than or equal to 100", result.Message) + assert.Equal(t, "BAD_USER_INPUT", result.Extensions["code"]) + }) + + t.Run("gqlerror wrapped by fmt.Errorf still resolves its code through the chain", func(t *testing.T) { + inner := &gqlerror.Error{ + Message: "invalid cursor format", + Extensions: map[string]interface{}{"code": "BAD_USER_INPUT"}, + } + // errors.As only finds *gqlerror.Error through an Unwrap chain built by %w, so build one the + // same way resolver call sites do (fmt.Errorf("...: %w", err)). + wrapped := fmt.Errorf("parsing pagination params: %w", inner) + + result := CustomErrorPresenter(ctx, wrapped) + require.NotNil(t, result) + assert.Equal(t, "BAD_USER_INPUT", result.Extensions["code"]) + }) + + for _, code := range []string{ + "INVALID_TRANSACTION_HASH", "INVALID_ADDRESS", "INTERNAL_ERROR", + "COMPLEXITY_LIMIT_EXCEEDED", "QUERY_TOO_DEEP", "UNAUTHENTICATED", "FORBIDDEN", + } { + t.Run("known code "+code+" passes through unchanged", func(t *testing.T) { + gqlErr := &gqlerror.Error{ + Message: "a client-facing message", + Extensions: map[string]interface{}{"code": code}, + } + result := CustomErrorPresenter(ctx, gqlErr) + require.NotNil(t, result) + assert.Equal(t, "a client-facing message", result.Message) + assert.Equal(t, code, result.Extensions["code"]) + }) + } + t.Run("validation error with unknown field - variable/input path", func(t *testing.T) { gqlErr := &gqlerror.Error{ Message: "unknown field", @@ -122,7 +173,7 @@ func TestCustomErrorPresenter(t *testing.T) { assert.Equal(t, "different validation error", result.Message) }) - t.Run("validation error with non-string code", func(t *testing.T) { + t.Run("validation error with non-string code is masked (not a recognized code)", func(t *testing.T) { gqlErr := &gqlerror.Error{ Message: "unknown field", Path: ast.Path{ast.PathName("field")}, @@ -133,8 +184,10 @@ func TestCustomErrorPresenter(t *testing.T) { result := CustomErrorPresenter(ctx, gqlErr) require.NotNil(t, result) - // Should remain unchanged since code is not a string - assert.Equal(t, "unknown field", result.Message) + // The unknown-field rewrite only fires for "code" == "GRAPHQL_VALIDATION_FAILED" (a string + // comparison), so a non-string code skips it same as before; but since it's also not a + // recognized client-safe code, the error is now masked. + assert.Equal(t, "internal server error", result.Message) }) t.Run("validation error with mixed path types", func(t *testing.T) { diff --git a/internal/serve/introspection_test.go b/internal/serve/introspection_test.go new file mode 100644 index 000000000..cd8e2f6ae --- /dev/null +++ b/internal/serve/introspection_test.go @@ -0,0 +1,49 @@ +package serve + +import ( + "net/http" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/metrics" +) + +const introspectionQuery = `{ __schema { queryType { name } } }` + +// newIntrospectionTestHandler mirrors newGraphQLTestHandler but also lets the test control the +// introspection flag (GQL-06), which newGraphQLTestHandler doesn't expose since every other test +// relies on its default (disabled). +func newIntrospectionTestHandler(t *testing.T, introspectionEnabled bool) http.Handler { + t.Helper() + + registry := prometheus.NewRegistry() + m := metrics.NewMetrics(registry) + models, err := data.NewModels(complexityTestPool, m.DB) + require.NoError(t, err) + + return handler(handlerDeps{ + Models: models, + Metrics: m, + GraphQLComplexityLimit: 1000, + GraphQLIntrospectionEnabled: introspectionEnabled, + }) +} + +func TestGraphQLIntrospectionGating(t *testing.T) { + t.Run("disabled by default: introspection query is rejected", func(t *testing.T) { + h := newIntrospectionTestHandler(t, false) + resp := performGraphQLRequest(t, h, introspectionQuery) + require.NotEmpty(t, resp.Errors, "introspection must be rejected when the extension is not registered") + }) + + t.Run("enabled: introspection query succeeds", func(t *testing.T) { + h := newIntrospectionTestHandler(t, true) + resp := performGraphQLRequest(t, h, introspectionQuery) + require.Empty(t, resp.Errors) + assert.Contains(t, string(resp.Data), "Query") + }) +} diff --git a/internal/serve/middleware/dataloader_middleware.go b/internal/serve/middleware/dataloader_middleware.go index c762e7646..f7faf5a29 100644 --- a/internal/serve/middleware/dataloader_middleware.go +++ b/internal/serve/middleware/dataloader_middleware.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" ) @@ -14,14 +15,14 @@ const ( LoadersKey = ctxKey("dataloaders") ) -func DataloaderMiddleware(models *data.Models) func(next http.Handler) http.Handler { +func DataloaderMiddleware(models *data.Models, m *metrics.DataloaderMetrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Dataloaders are created per-request to avoid data sharing between requests. // This ensures each request has a fresh view of the data. This also helps prevent // inconsistent data view across horizontally scaled services. // More info about this here: https://github.com/graphql/dataloader/issues/62#issue-193854091 - loaders := dataloaders.NewDataloaders(models) + loaders := dataloaders.NewDataloaders(models, m) ctx := context.WithValue(r.Context(), LoadersKey, loaders) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/serve/middleware/graphql_field_metrics_test.go b/internal/serve/middleware/graphql_field_metrics_test.go index 5c8e2bab7..e0951d7b0 100644 --- a/internal/serve/middleware/graphql_field_metrics_test.go +++ b/internal/serve/middleware/graphql_field_metrics_test.go @@ -26,11 +26,15 @@ import ( // Passing an ast.DirectiveList containing {Name: "deprecated"} simulates a field // declared as `oldField: String @deprecated(reason: "Use newField")` in the schema. // Passing nil simulates a normal, non-deprecated field. +// operationName is used as both the (now-ignored) OperationContext.OperationName and the root +// field name, since GetOperationIdentifier derives its label from the root SelectionSet rather +// than the client-controlled OperationName (see GetOperationIdentifier's cardinality-safety doc). func fieldTestContext(operationName string, fieldName string, directives ast.DirectiveList) context.Context { oc := &graphql.OperationContext{ OperationName: operationName, Operation: &ast.OperationDefinition{ - Operation: ast.Query, + Operation: ast.Query, + SelectionSet: ast.SelectionSet{&ast.Field{Name: operationName}}, }, } ctx := graphql.WithOperationContext(context.Background(), oc) diff --git a/internal/serve/middleware/graphql_operation_metrics.go b/internal/serve/middleware/graphql_operation_metrics.go index 64b26014f..1901f2e75 100644 --- a/internal/serve/middleware/graphql_operation_metrics.go +++ b/internal/serve/middleware/graphql_operation_metrics.go @@ -90,7 +90,7 @@ func classifyGraphQLError(err error) string { return "auth_error" case "FORBIDDEN": return "forbidden" - case "INTERNAL_SERVER_ERROR": + case "INTERNAL_SERVER_ERROR", "INTERNAL_ERROR": return "internal_error" default: return "unknown" diff --git a/internal/serve/middleware/graphql_operation_metrics_test.go b/internal/serve/middleware/graphql_operation_metrics_test.go index 760eb57ff..3f4765d98 100644 --- a/internal/serve/middleware/graphql_operation_metrics_test.go +++ b/internal/serve/middleware/graphql_operation_metrics_test.go @@ -23,13 +23,16 @@ func newTestGraphQLMetrics(t *testing.T) *metrics.GraphQLMetrics { return metrics.NewGraphQLMetrics(reg) } -// withOperationContext returns a context with a gqlgen OperationContext set, -// simulating what gqlgen provides inside AroundOperations. +// withOperationContext returns a context with a gqlgen OperationContext set, simulating what +// gqlgen provides inside AroundOperations. name is used as both the (now-ignored) +// OperationContext.OperationName and the root field name, since GetOperationIdentifier derives its +// label from the root SelectionSet rather than the client-controlled OperationName. func withOperationContext(ctx context.Context, name string, op ast.Operation) context.Context { oc := &graphql.OperationContext{ OperationName: name, Operation: &ast.OperationDefinition{ - Operation: op, + Operation: op, + SelectionSet: ast.SelectionSet{&ast.Field{Name: name}}, }, } return graphql.WithOperationContext(ctx, oc) @@ -189,6 +192,11 @@ func TestClassifyGraphQLError(t *testing.T) { err: &gqlerror.Error{Extensions: map[string]interface{}{"code": "INTERNAL_SERVER_ERROR"}}, expected: "internal_error", }, + { + name: "balance internal error also maps to internal_error", + err: &gqlerror.Error{Extensions: map[string]interface{}{"code": "INTERNAL_ERROR"}}, + expected: "internal_error", + }, { name: "unknown code maps to unknown", err: &gqlerror.Error{Extensions: map[string]interface{}{"code": "CUSTOM_CODE"}}, diff --git a/internal/serve/middleware/graphql_utils.go b/internal/serve/middleware/graphql_utils.go index 9ebd54cac..546c07c08 100644 --- a/internal/serve/middleware/graphql_utils.go +++ b/internal/serve/middleware/graphql_utils.go @@ -9,20 +9,16 @@ import ( "github.com/vektah/gqlparser/v2/ast" ) -// GetOperationIdentifier extracts a meaningful operation identifier from a GraphQL operation context. -// It prefers the explicit OperationName if provided, otherwise falls back to the first root field name. -// This is useful for metrics and logging when clients send anonymous queries. +// GetOperationIdentifier extracts a metrics-safe operation identifier from a GraphQL operation +// context. It always derives the identifier from the root field name(s) in the selection set — a +// fixed, schema-bounded set — and never from the client-controlled OperationName: that value +// becomes a Prometheus label on several metric families, and using it directly would give clients +// control over label cardinality. func GetOperationIdentifier(oc *graphql.OperationContext) string { if oc == nil { return "" } - // Prefer explicit operation name if provided - if oc.OperationName != "" { - return oc.OperationName - } - - // Fall back to first root field name from the selection set if oc.Operation != nil && len(oc.Operation.SelectionSet) > 0 { for _, sel := range oc.Operation.SelectionSet { if field, ok := sel.(*ast.Field); ok { diff --git a/internal/serve/middleware/graphql_utils_test.go b/internal/serve/middleware/graphql_utils_test.go index 30f016e04..2f0f287a7 100644 --- a/internal/serve/middleware/graphql_utils_test.go +++ b/internal/serve/middleware/graphql_utils_test.go @@ -20,7 +20,7 @@ func TestGetOperationIdentifier(t *testing.T) { expected: "", }, { - name: "explicit operation name is preferred", + name: "explicit operation name is ignored in favor of root field name", oc: &graphql.OperationContext{ OperationName: "GetAccount", Operation: &ast.OperationDefinition{ @@ -29,7 +29,7 @@ func TestGetOperationIdentifier(t *testing.T) { }, }, }, - expected: "GetAccount", + expected: "accountByAddress", }, { name: "anonymous query returns first field name", diff --git a/internal/serve/middleware/request_timeout_middleware.go b/internal/serve/middleware/request_timeout_middleware.go new file mode 100644 index 000000000..f05a1e82f --- /dev/null +++ b/internal/serve/middleware/request_timeout_middleware.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "context" + "net/http" + "time" +) + +// RequestTimeoutMiddleware bounds each request's context to timeout, so that resolvers and DB +// calls further down the stack observe cancellation instead of running unbounded. Without this, a +// slow or stuck query can hold a pooled DB connection indefinitely (see also the WriteTimeout / +// IdleTimeout set on the HTTP server, which bound the connection itself rather than the request +// context). +func RequestTimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/serve/middleware/request_timeout_middleware_test.go b/internal/serve/middleware/request_timeout_middleware_test.go new file mode 100644 index 000000000..90b351b9b --- /dev/null +++ b/internal/serve/middleware/request_timeout_middleware_test.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequestTimeoutMiddleware(t *testing.T) { + t.Run("request context carries a deadline", func(t *testing.T) { + var sawDeadline bool + var sawDuration time.Duration + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deadline, ok := r.Context().Deadline() + sawDeadline = ok + if ok { + sawDuration = time.Until(deadline) + } + w.WriteHeader(http.StatusOK) + }) + + h := RequestTimeoutMiddleware(30 * time.Second)(next) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.True(t, sawDeadline, "request context must carry a deadline") + assert.InDelta(t, 30*time.Second, sawDuration, float64(2*time.Second)) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("context is canceled once the timeout elapses", func(t *testing.T) { + done := make(chan error, 1) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + done <- r.Context().Err() + w.WriteHeader(http.StatusOK) + }) + + h := RequestTimeoutMiddleware(10 * time.Millisecond)(next) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + select { + case err := <-done: + assert.Equal(t, context.DeadlineExceeded, err) + case <-time.After(time.Second): + t.Fatal("context was never canceled") + } + }) +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 87b5db6a9..648243b0c 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -38,6 +38,18 @@ import ( "github.com/vektah/gqlparser/v2/ast" ) +const ( + // graphqlWriteTimeout bounds how long the server may spend writing a response. Chosen above + // the slowest legitimate query (a full page of account history) so normal traffic never trips + // it, while still bounding how long a slow/stuck query can pin a DB connection from the pool. + graphqlWriteTimeout = 35 * time.Second + // graphqlIdleTimeout bounds how long a keep-alive connection may sit idle between requests. + graphqlIdleTimeout = 2 * time.Minute + // requestContextTimeout bounds how long a single request's context stays live so resolvers and + // DB calls downstream observe cancellation rather than running unbounded. + requestContextTimeout = 30 * time.Second +) + type Configs struct { Port int // AdminPort, when > 0, serves pprof endpoints at /debug/pprof on a separate @@ -56,7 +68,8 @@ type Configs struct { RPCURL string // GraphQL - GraphQLComplexityLimit int + GraphQLComplexityLimit int + GraphQLIntrospectionEnabled bool // Error Tracker AppTracker apptracker.AppTracker @@ -107,7 +120,8 @@ type handlerDeps struct { SEP41AllowanceModel sep41data.AllowanceModelInterface // GraphQL - GraphQLComplexityLimit int + GraphQLComplexityLimit int + GraphQLIntrospectionEnabled bool // Error Tracker AppTracker apptracker.AppTracker @@ -141,8 +155,10 @@ func Serve(cfg Configs) error { addr := fmt.Sprintf(":%d", cfg.Port) supporthttp.Run(supporthttp.Config{ - ListenAddr: addr, - Handler: handler(deps), + ListenAddr: addr, + Handler: handler(deps), + WriteTimeout: graphqlWriteTimeout, + IdleTimeout: graphqlIdleTimeout, OnStarting: func() { log.Infof("🌐 Starting Wallet Backend server on port %d", cfg.Port) }, @@ -189,20 +205,21 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { } return handlerDeps{ - Models: models, - RequestAuthVerifier: requestAuthVerifier, - SupportedAssets: cfg.SupportedAssets, - Metrics: m, - RPCService: rpcService, - TrustlineBalanceModel: models.TrustlineBalance, - NativeBalanceModel: models.NativeBalance, - SACBalanceModel: models.SACBalance, - LiquidityPoolBalanceModel: models.LiquidityPoolBalance, - SEP41BalanceModel: models.SEP41.Balances, - SEP41AllowanceModel: models.SEP41.Allowances, - AppTracker: cfg.AppTracker, - NetworkPassphrase: cfg.NetworkPassphrase, - GraphQLComplexityLimit: cfg.GraphQLComplexityLimit, + Models: models, + RequestAuthVerifier: requestAuthVerifier, + SupportedAssets: cfg.SupportedAssets, + Metrics: m, + RPCService: rpcService, + TrustlineBalanceModel: models.TrustlineBalance, + NativeBalanceModel: models.NativeBalance, + SACBalanceModel: models.SACBalance, + LiquidityPoolBalanceModel: models.LiquidityPoolBalance, + SEP41BalanceModel: models.SEP41.Balances, + SEP41AllowanceModel: models.SEP41.Allowances, + AppTracker: cfg.AppTracker, + NetworkPassphrase: cfg.NetworkPassphrase, + GraphQLComplexityLimit: cfg.GraphQLComplexityLimit, + GraphQLIntrospectionEnabled: cfg.GraphQLIntrospectionEnabled, }, nil } @@ -224,13 +241,15 @@ func handler(deps handlerDeps) http.Handler { // API routes (conditionally authenticated) mux.Group(func(r chi.Router) { + // Bound each request's context so a slow/stuck query can't pin a DB connection forever. + r.Use(middleware.RequestTimeoutMiddleware(requestContextTimeout)) // Apply authentication middleware only if auth verifier is configured if deps.RequestAuthVerifier != nil { r.Use(middleware.AuthenticationMiddleware(deps.RequestAuthVerifier, deps.AppTracker, deps.Metrics.Auth)) } r.Route("/graphql", func(r chi.Router) { - r.Use(middleware.DataloaderMiddleware(deps.Models)) + r.Use(middleware.DataloaderMiddleware(deps.Models, deps.Metrics.Dataloader)) resolver := resolvers.NewResolver( deps.Models, @@ -253,11 +272,17 @@ func handler(deps handlerDeps) http.Handler { srv.AddTransport(transport.GET{}) srv.AddTransport(transport.POST{}) srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) - srv.Use(extension.Introspection{}) + // Introspection is registered only when explicitly enabled (GQL-06): gqlgen disables + // introspection by default when this extension is absent, and leaving the full schema + // discoverable via __schema/__type is not appropriate for a production endpoint. + if deps.GraphQLIntrospectionEnabled { + srv.Use(extension.Introspection{}) + } srv.Use(extension.AutomaticPersistedQuery{ Cache: lru.New[string](100), }) srv.SetErrorPresenter(graphqlutils.CustomErrorPresenter) + srv.Use(graphqlutils.DepthLimit{MaxDepth: graphqlutils.MaxQueryDepth}) srv.Use(extension.FixedComplexityLimit(deps.GraphQLComplexityLimit)) // Add complexity logging - reports all queries with their complexity values @@ -339,16 +364,16 @@ func addComplexityCalculation(config *generated.Config) { paginatedQueryComplexityFunc := func(childComplexity int, first *int32, _ *string, last *int32, _ *string) int { return calculatePaginatedComplexity(childComplexity, first, last) } - config.Complexity.Query.Transactions = paginatedQueryComplexityFunc - config.Complexity.Query.Operations = paginatedQueryComplexityFunc - config.Complexity.Query.StateChanges = paginatedQueryComplexityFunc - config.Complexity.Account.Balances = paginatedQueryComplexityFunc - config.Complexity.Account.Transactions = func(childComplexity int, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int { - return calculatePaginatedComplexity(childComplexity, first, last) - } - config.Complexity.Account.Operations = func(childComplexity int, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int { + // timeBoundedPaginatedComplexityFunc is for the account-scoped connections that take + // since/until (Account.transactions/operations/stateChanges): since/until narrow the result + // set but don't change the page-size-based complexity accounting, so they're accepted and + // ignored here. + timeBoundedPaginatedComplexityFunc := func(childComplexity int, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int { return calculatePaginatedComplexity(childComplexity, first, last) } + config.Complexity.Account.Balances = paginatedQueryComplexityFunc + config.Complexity.Account.Transactions = timeBoundedPaginatedComplexityFunc + config.Complexity.Account.Operations = timeBoundedPaginatedComplexityFunc config.Complexity.Account.StateChanges = func(childComplexity int, filter *generated.AccountStateChangeFilterInput, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int { return calculatePaginatedComplexity(childComplexity, first, last) } diff --git a/pkg/wbclient/client.go b/pkg/wbclient/client.go index b2a5d5ee9..b1b4e1587 100644 --- a/pkg/wbclient/client.go +++ b/pkg/wbclient/client.go @@ -42,26 +42,14 @@ type TransactionByHashData struct { TransactionByHash *types.GraphQLTransaction `json:"transactionByHash"` } -type TransactionsData struct { - Transactions *types.TransactionConnection `json:"transactions"` -} - type AccountByAddressData struct { AccountByAddress *types.Account `json:"accountByAddress"` } -type OperationsData struct { - Operations *types.OperationConnection `json:"operations"` -} - type OperationByIDData struct { OperationByID *types.Operation `json:"operationById"` } -type StateChangesData struct { - StateChanges *types.StateChangeConnection `json:"stateChanges"` -} - type AccountTransactionsData struct { AccountByAddress *struct { Transactions *types.TransactionConnection `json:"transactions"` @@ -267,25 +255,6 @@ func (c *Client) GetTransactionByHash(ctx context.Context, hash string, opts ... return data.TransactionByHash, nil } -func (c *Client) GetTransactions(ctx context.Context, first, last *int32, after, before *string, opts ...*QueryOptions) (*types.TransactionConnection, error) { - var fields []string - if len(opts) > 0 && opts[0] != nil { - fields = opts[0].TransactionFields - } - - variables, err := buildPaginationVars(first, last, after, before) - if err != nil { - return nil, fmt.Errorf("building pagination variables: %w", err) - } - - data, err := executeGraphQL[TransactionsData](c, ctx, buildTransactionsQuery(fields), variables) - if err != nil { - return nil, err - } - - return data.Transactions, nil -} - func (c *Client) GetAccountByAddress(ctx context.Context, address string, opts ...*QueryOptions) (*types.Account, error) { var fields []string if len(opts) > 0 && opts[0] != nil { @@ -304,25 +273,6 @@ func (c *Client) GetAccountByAddress(ctx context.Context, address string, opts . return data.AccountByAddress, nil } -func (c *Client) GetOperations(ctx context.Context, first, last *int32, after, before *string, opts ...*QueryOptions) (*types.OperationConnection, error) { - var fields []string - if len(opts) > 0 && opts[0] != nil { - fields = opts[0].OperationFields - } - - variables, err := buildPaginationVars(first, last, after, before) - if err != nil { - return nil, fmt.Errorf("building pagination variables: %w", err) - } - - data, err := executeGraphQL[OperationsData](c, ctx, buildOperationsQuery(fields), variables) - if err != nil { - return nil, err - } - - return data.Operations, nil -} - func (c *Client) GetOperationByID(ctx context.Context, id int64, opts ...*QueryOptions) (*types.Operation, error) { var fields []string if len(opts) > 0 && opts[0] != nil { @@ -341,20 +291,6 @@ func (c *Client) GetOperationByID(ctx context.Context, id int64, opts ...*QueryO return data.OperationByID, nil } -func (c *Client) GetStateChanges(ctx context.Context, first, last *int32, after, before *string) (*types.StateChangeConnection, error) { - variables, err := buildPaginationVars(first, last, after, before) - if err != nil { - return nil, fmt.Errorf("building pagination variables: %w", err) - } - - data, err := executeGraphQL[StateChangesData](c, ctx, buildStateChangesQuery(), variables) - if err != nil { - return nil, err - } - - return data.StateChanges, nil -} - func (c *Client) GetAccountTransactions(ctx context.Context, address string, since, until *time.Time, first, last *int32, after, before *string, opts ...*QueryOptions) (*types.TransactionConnection, error) { var fields []string if len(opts) > 0 && opts[0] != nil { diff --git a/pkg/wbclient/queries.go b/pkg/wbclient/queries.go index c92f1254d..719b756b0 100644 --- a/pkg/wbclient/queries.go +++ b/pkg/wbclient/queries.go @@ -99,29 +99,6 @@ func buildTransactionByHashQuery(fields []string) string { `, fieldList) } -// buildTransactionsQuery builds the GraphQL query for fetching transactions with pagination -func buildTransactionsQuery(fields []string) string { - fieldList := buildFieldList(fields, defaultTransactionFields) - return fmt.Sprintf(` - query Transactions($first: Int, $after: String, $last: Int, $before: String) { - transactions(first: $first, after: $after, last: $last, before: $before) { - edges { - node { - %s - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - `, fieldList) -} - // buildAccountByAddressQuery builds the GraphQL query for fetching an account by address func buildAccountByAddressQuery(fields []string) string { fieldList := buildFieldList(fields, defaultAccountFields) @@ -134,29 +111,6 @@ func buildAccountByAddressQuery(fields []string) string { `, fieldList) } -// buildOperationsQuery builds the GraphQL query for fetching operations with pagination -func buildOperationsQuery(fields []string) string { - fieldList := buildFieldList(fields, defaultOperationFields) - return fmt.Sprintf(` - query Operations($first: Int, $after: String, $last: Int, $before: String) { - operations(first: $first, after: $after, last: $last, before: $before) { - edges { - node { - %s - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - `, fieldList) -} - // buildOperationByIDQuery builds the GraphQL query for fetching an operation by ID func buildOperationByIDQuery(fields []string) string { fieldList := buildFieldList(fields, defaultOperationFields) @@ -169,30 +123,6 @@ func buildOperationByIDQuery(fields []string) string { `, fieldList) } -// buildStateChangesQuery builds the GraphQL query for fetching state changes with pagination -func buildStateChangesQuery() string { - // For state changes, we always use fragments to handle polymorphic types - // Individual field selection is not supported for state changes due to their polymorphic nature - return fmt.Sprintf(` - query StateChanges($first: Int, $after: String, $last: Int, $before: String) { - stateChanges(first: $first, after: $after, last: $last, before: $before) { - edges { - node { - %s - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - `, stateChangeFragments) -} - // buildAccountTransactionsQuery builds the GraphQL query for fetching an account's transactions with pagination func buildAccountTransactionsQuery(fields []string) string { fieldList := buildFieldList(fields, defaultTransactionFields)