Skip to content

Commit a89672c

Browse files
committed
fix(complexity): price the SDK's real queries and size the default limit for them
The wbclient full-detail account-history query measures 10,101 at first:100 — over the previous 10,000 default — because the shared stateChangeFragments const gained 8 Blend fragments (+27 fields per state-change node, ×100 edges). The regression test could not catch this: it asserted a hand-copied mirror of the SDK query that had drifted (no Blend fragments, aliases the builder never emits), and the integration container overrode the limit to 30,000. pkg/wbclient now exports Queries(), the exact documents the client sends, and both server-side guards consume it: schema validation (which previously missed the three Blend queries) and the complexity regression test, which prices every SDK query at the largest accepted page size against the flag's own FlagDefault. Measured: BlendPools=26,150, full-detail history=10,101, state-change queries=8,401, blendPositions=7,583. The default limit rises to 30,000, documented as sized for the SDK's heaviest shipped queries with the DoS tradeoff stated (deployments not serving Blend should lower it). The integration container no longer overrides GRAPHQL_COMPLEXITY_LIMIT, so the suite proves the shipped default serves the SDK's full-selection queries end-to-end.
1 parent 97041f6 commit a89672c

6 files changed

Lines changed: 106 additions & 252 deletions

File tree

cmd/utils/global_options.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,10 @@ func BlendBackstopLPContractIDOption(configKey *string) *config.ConfigOption {
194194
func GraphQLComplexityLimitOption(configKey *int) *config.ConfigOption {
195195
return &config.ConfigOption{
196196
Name: "graphql-complexity-limit",
197-
Usage: "The maximum complexity limit for GraphQL queries. Complexity is calculated based on fields and pagination parameters. gqlgen sums mutually exclusive inline fragments, so the limit must accommodate a full-detail query selecting every BaseStateChange implementer (~7600 at first:100).",
197+
Usage: "The maximum complexity limit for GraphQL queries, computed from the selected fields and the pagination arguments. The default admits the heaviest queries pkg/wbclient ships: the full-detail account-history query costs ~10,100 at first:100 (gqlgen sums mutually exclusive inline fragments, so every BaseStateChange implementer is charged), and the blendPools catalog query ~26,150. This limit is the primary guard against resource exhaustion via expensive queries, so a deployment that does not serve Blend should lower it.",
198198
OptType: types.Int,
199199
ConfigKey: configKey,
200-
FlagDefault: 10_000,
200+
FlagDefault: 30_000,
201201
Required: false,
202202
}
203203
}

internal/integrationtests/infrastructure/containers.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -482,18 +482,13 @@ func createWalletBackendAPIContainer(ctx context.Context, name string, imageName
482482
"RPC_URL": "http://stellar-rpc:8000",
483483
"DATABASE_URL": "postgres://postgres@wallet-backend-db:5432/wallet-backend?sslmode=disable",
484484
"PORT": walletBackendContainerAPIPort,
485-
// High enough for the SDK's full-selection Blend catalog query —
486-
// blendPools costs 50 × (BlendPool scalars + 30 × BlendReserve
487-
// scalars) ≈ 26k under the server's complexity accounting (see
488-
// addComplexityCalculation in internal/serve/serve.go). The
489-
// integration suite deliberately requests every field, so it needs
490-
// more headroom than the production default.
491-
"GRAPHQL_COMPLEXITY_LIMIT": "30000",
492-
"LOG_LEVEL": "DEBUG",
493-
"NETWORK": "standalone",
494-
"NETWORK_PASSPHRASE": networkPassphrase,
495-
"CLIENT_AUTH_PUBLIC_KEYS": clientAuthKeyPair.Address(),
496-
"STELLAR_ENVIRONMENT": "integration-test",
485+
// GRAPHQL_COMPLEXITY_LIMIT is deliberately left unset: the suite issues the SDK's
486+
// full-selection queries, so it must prove they fit the shipped default.
487+
"LOG_LEVEL": "DEBUG",
488+
"NETWORK": "standalone",
489+
"NETWORK_PASSPHRASE": networkPassphrase,
490+
"CLIENT_AUTH_PUBLIC_KEYS": clientAuthKeyPair.Address(),
491+
"STELLAR_ENVIRONMENT": "integration-test",
497492
},
498493
Networks: []string{testNetwork.Name},
499494
WaitingFor: wait.ForHTTP("/health").WithPort(walletBackendContainerAPIPort + "/tcp"),

internal/serve/complexity_regression_test.go

Lines changed: 65 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -9,195 +9,34 @@ import (
99
"github.com/stretchr/testify/require"
1010
"github.com/vektah/gqlparser/v2"
1111

12+
"github.com/stellar/wallet-backend/cmd/utils"
1213
generated "github.com/stellar/wallet-backend/internal/serve/graphql/generated"
1314
resolvers "github.com/stellar/wallet-backend/internal/serve/graphql/resolvers"
15+
"github.com/stellar/wallet-backend/pkg/wbclient"
1416
)
1517

16-
// The queries below mirror the shapes pkg/wbclient/queries.go builds for freighter's
17-
// account-detail views (buildAccountBalancesQuery / buildAccountTransactionsWithOpsAndStateChangesQuery):
18-
// max page size (first: 100, the resolver-enforced cap, see graphqlutils.DefaultPageLimit and the
19-
// resolvers' page-size clamping) with every field of every concrete implementer selected. A single
20-
// query combining both at first:100 is not representative: freighter issues them as separate
21-
// requests, and combined they total ~11400, over budget on their own. Each is tested independently
22-
// as its own worst case.
23-
24-
const freighterAccountBalancesQuery = `
25-
query {
26-
accountByAddress(address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF") {
27-
balances(first: 100) {
28-
edges {
29-
node {
30-
balance
31-
tokenId
32-
tokenType
33-
... on NativeBalance {
34-
minimumBalance
35-
buyingLiabilities
36-
sellingLiabilities
37-
numSubentries
38-
lastModifiedLedger
39-
}
40-
... on TrustlineBalance {
41-
code
42-
issuer
43-
assetType
44-
limit
45-
buyingLiabilities
46-
sellingLiabilities
47-
lastModifiedLedger
48-
isAuthorized
49-
isAuthorizedToMaintainLiabilities
50-
}
51-
... on SACBalance {
52-
code
53-
issuer
54-
decimals
55-
isAuthorized
56-
isClawbackEnabled
57-
}
58-
... on SEP41Balance {
59-
name
60-
symbol
61-
decimals
62-
lastModifiedLedger
63-
}
64-
... on LiquidityPoolBalance {
65-
reserves { asset amount }
66-
lastModifiedLedger
67-
}
68-
}
69-
cursor
70-
}
71-
pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
72-
}
73-
}
74-
}
75-
`
76-
77-
// freighterAccountTransactionsQuery is the "full-detail account-history query" referenced in the
78-
// comments above addComplexityCalculation in serve.go: it selects a full set of Transaction scalar
79-
// fields, plus the AccountTransactionEdge-only operations/stateChanges fields (plain lists, no
80-
// first/last args) with a broad field selection across every BaseStateChange implementer. This is
81-
// the query the AccountTransactionEdge no-multiplier design in addComplexityCalculation exists to protect.
82-
const freighterAccountTransactionsQuery = `
83-
query {
84-
accountByAddress(address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF") {
85-
transactions(first: 100) {
86-
edges {
87-
node {
88-
hash
89-
feeCharged
90-
resultCode
91-
ledgerNumber
92-
ledgerCreatedAt
93-
isFeeBump
94-
ingestedAt
95-
}
96-
cursor
97-
operations {
98-
id
99-
type
100-
operationXdr
101-
resultCode
102-
successful
103-
ledgerNumber
104-
ledgerCreatedAt
105-
ingestedAt
106-
}
107-
stateChanges {
108-
category
109-
reason
110-
ingestedAt
111-
ledgerCreatedAt
112-
ledgerNumber
113-
... on BalanceChange {
114-
balanceTokenId: tokenId
115-
balanceAmount: amount
116-
toMuxedId
117-
}
118-
... on AccountCreatedChange {
119-
creatorAddress
120-
}
121-
... on AccountMergedChange {
122-
destinationAddress
123-
}
124-
... on SignerAddedChange {
125-
signerAddedAddress: signerAddress
126-
signerAddedNewWeight: newWeight
127-
}
128-
... on SignerUpdatedChange {
129-
signerUpdatedAddress: signerAddress
130-
signerUpdatedOldWeight: oldWeight
131-
signerUpdatedNewWeight: newWeight
132-
}
133-
... on SignerRemovedChange {
134-
signerRemovedAddress: signerAddress
135-
signerRemovedOldWeight: oldWeight
136-
}
137-
... on ThresholdChange {
138-
threshold
139-
oldThreshold
140-
newThreshold
141-
}
142-
... on AccountFlagsChange {
143-
accountFlags: flags
144-
}
145-
... on HomeDomainSetChange {
146-
homeDomainSetValue: homeDomain
147-
}
148-
... on HomeDomainUpdatedChange {
149-
homeDomainUpdatedOld: oldHomeDomain
150-
homeDomainUpdatedNew: newHomeDomain
151-
}
152-
... on HomeDomainClearedChange {
153-
homeDomainClearedOld: oldHomeDomain
154-
}
155-
... on DataEntryAddedChange {
156-
dataEntryAddedName: name
157-
dataEntryAddedValue: value
158-
}
159-
... on DataEntryUpdatedChange {
160-
dataEntryUpdatedName: name
161-
dataEntryUpdatedOldValue: oldValue
162-
dataEntryUpdatedNewValue: newValue
163-
}
164-
... on DataEntryRemovedChange {
165-
dataEntryRemovedName: name
166-
dataEntryRemovedOldValue: oldValue
167-
}
168-
... on AllowanceChange {
169-
allowanceTokenId: tokenId
170-
spender
171-
allowanceAmount: amount
172-
expirationLedger
173-
}
174-
... on TrustlineAddedChange {
175-
trustlineAddedTokenId: tokenId
176-
trustlineAddedLiquidityPoolId: liquidityPoolId
177-
trustlineAddedLimit: limit
178-
}
179-
... on TrustlineUpdatedChange {
180-
trustlineUpdatedTokenId: tokenId
181-
trustlineUpdatedLiquidityPoolId: liquidityPoolId
182-
oldLimit
183-
newLimit
184-
}
185-
... on TrustlineRemovedChange {
186-
trustlineRemovedTokenId: tokenId
187-
trustlineRemovedLiquidityPoolId: liquidityPoolId
188-
}
189-
... on BalanceAuthorizationChange {
190-
balanceAuthTokenId: tokenId
191-
balanceAuthLiquidityPoolId: liquidityPoolId
192-
trustlineFlags: flags
193-
}
194-
}
195-
}
196-
pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
197-
}
198-
}
199-
}
200-
`
18+
// The queries priced below are the documents pkg/wbclient itself builds (wbclient.Queries()),
19+
// not copies of them, so any fragment or field added to the SDK is priced here the moment it
20+
// lands: a change that pushes a shipped query past the deployment default fails this test
21+
// instead of failing a client at runtime.
22+
23+
// maxRequestedPageSize is the largest page the resolvers accept on any connection
24+
// (maxAccountPageLimit and maxBalancePageLimit in internal/serve/graphql/resolvers, both 100).
25+
// Every paginated query the SDK builds takes its page size as a $first variable, so binding it
26+
// here prices each query at the worst case a client can ask for.
27+
const maxRequestedPageSize = 100
28+
29+
// substantialQueryFloors pins a lower bound on the query shapes the complexity limit is sized
30+
// around. Without a floor the upper-bound assertion would also pass on a near-zero measurement,
31+
// which is what a silently dropped selection set looks like. Queries not listed here only have
32+
// to be non-zero.
33+
var substantialQueryFloors = map[string]int{
34+
"AccountBalances": 1000,
35+
"AccountStateChanges": 1000,
36+
"AccountTransactionsWithOpsAndStateChanges": 1000,
37+
"BlendPools": 1000,
38+
"AccountBlendPositions": 1000,
39+
}
20140

20241
// newComplexityCalculationSchema builds an ExecutableSchema wired with the production complexity
20342
// config, with no DB or server behind it: complexity.Calculate only walks the query AST against the
@@ -210,37 +49,45 @@ func newComplexityCalculationSchema(t *testing.T) graphql.ExecutableSchema {
21049
return generated.NewExecutableSchema(cfg)
21150
}
21251

213-
// TestFreighterFullDetailQueriesStayUnderComplexityLimit locks in that freighter's two heaviest
214-
// account-detail queries stay under the default GRAPHQL_COMPLEXITY_LIMIT=10000. This only holds because
215-
// AccountTransactionEdge.operations/stateChanges have no complexity multiplier registered (see
216-
// TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultiplier below); if that ever
217-
// changes, this test starts failing too.
218-
func TestFreighterFullDetailQueriesStayUnderComplexityLimit(t *testing.T) {
219-
es := newComplexityCalculationSchema(t)
52+
// defaultComplexityLimit reports the GRAPHQL_COMPLEXITY_LIMIT a deployment runs with when it does
53+
// not override the flag, read from the option itself so the budget below tracks the shipped
54+
// default rather than a second copy of the number.
55+
func defaultComplexityLimit(t *testing.T) int {
56+
t.Helper()
22057

221-
testCases := []struct {
222-
name string
223-
query string
224-
// computed complexity on record at time of writing, for both cases under the
225-
// GRAPHQL_COMPLEXITY_LIMIT=10000 default limit: balances=3801, transactions=7301.
226-
// gqlgen sums mutually exclusive inline fragments, so the exhaustive 19-fragment
227-
// state-change selection over-counts relative to what any one row resolves.
228-
floor int
229-
}{
230-
{name: "account balances, first:100, every Balance implementer field", query: freighterAccountBalancesQuery, floor: 1000},
231-
{name: "account transactions, first:100, embedded operations+stateChanges per edge", query: freighterAccountTransactionsQuery, floor: 1000},
232-
}
58+
var sink int
59+
limit, ok := utils.GraphQLComplexityLimitOption(&sink).FlagDefault.(int)
60+
require.True(t, ok, "graphql-complexity-limit FlagDefault should be an int")
61+
return limit
62+
}
23363

234-
for _, tc := range testCases {
235-
t.Run(tc.name, func(t *testing.T) {
236-
doc, gerr := gqlparser.LoadQueryWithRules(es.Schema(), tc.query, nil)
64+
// TestSDKQueriesFitDefaultComplexityLimit locks in that every query pkg/wbclient sends is
65+
// servable by a wallet-backend running the built-in GRAPHQL_COMPLEXITY_LIMIT, at the largest page
66+
// a resolver will accept.
67+
//
68+
// Measured complexities at first:100 for the queries that dominate the budget (gqlgen sums
69+
// mutually exclusive inline fragments, so the exhaustive state-change and balance selections
70+
// over-count relative to what any one row resolves): BlendPools=26,150,
71+
// AccountTransactionsWithOpsAndStateChanges=10,101, Account/Transaction/OperationStateChanges=8,401,
72+
// AccountBlendPositions=7,595, AccountBalances=3,901.
73+
func TestSDKQueriesFitDefaultComplexityLimit(t *testing.T) {
74+
es := newComplexityCalculationSchema(t)
75+
limit := defaultComplexityLimit(t)
76+
vars := map[string]any{"first": maxRequestedPageSize}
77+
78+
for name, query := range wbclient.Queries() {
79+
t.Run(name, func(t *testing.T) {
80+
doc, gerr := gqlparser.LoadQueryWithRules(es.Schema(), query, nil)
23781
require.Empty(t, gerr)
23882

239-
c := complexity.Calculate(context.Background(), es, doc.Operations[0], nil)
240-
t.Logf("%s: computed complexity = %d", tc.name, c)
83+
c := complexity.Calculate(context.Background(), es, doc.Operations[0], vars)
84+
t.Logf("%s: computed complexity at first:%d = %d", name, maxRequestedPageSize, c)
24185

242-
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")
243-
require.Less(t, c, 10_000, "freighter's full-detail query must stay under the default complexity limit (GRAPHQL_COMPLEXITY_LIMIT=10000)")
86+
require.Positive(t, c, "a zero complexity means LoadQueryWithRules silently dropped the selection set")
87+
if floor, ok := substantialQueryFloors[name]; ok {
88+
require.Greater(t, c, floor, "query should be substantial enough to be a meaningful worst case")
89+
}
90+
require.LessOrEqual(t, c, limit, "every query the SDK ships must be servable under the default complexity limit (%d)", limit)
24491
})
24592
}
24693
}
@@ -266,7 +113,7 @@ func TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultipli
266113
// Break-detection: prove the assertions above actually discriminate. Register the naive x50
267114
// multiplier a well-meaning future edit might add (matching the pattern every other paginated
268115
// field in addComplexityCalculation uses) and confirm `ok` flips to true, and that the resulting
269-
// schema pushes freighter's transactions query over the prod limit.
116+
// schema pushes the SDK's full-detail account-history query over the default limit.
270117
regressedCfg := generated.Config{Resolvers: &resolvers.Resolver{}}
271118
addComplexityCalculation(&regressedCfg)
272119
regressedCfg.Complexity.AccountTransactionEdge.Operations = func(childComplexity int) int { return childComplexity * 50 }
@@ -276,9 +123,11 @@ func TestAccountTransactionEdgeOperationsAndStateChangesHaveNoComplexityMultipli
276123
_, ok = regressedES.Complexity(ctx, "AccountTransactionEdge", "operations", 100, map[string]any{})
277124
require.True(t, ok, "sanity check on the break-detection config: the multiplier should be registered")
278125

279-
doc, gerr := gqlparser.LoadQueryWithRules(regressedES.Schema(), freighterAccountTransactionsQuery, nil)
126+
fullDetailQuery := wbclient.Queries()["AccountTransactionsWithOpsAndStateChanges"]
127+
require.NotEmpty(t, fullDetailQuery)
128+
doc, gerr := gqlparser.LoadQueryWithRules(regressedES.Schema(), fullDetailQuery, nil)
280129
require.Empty(t, gerr)
281-
regressed := complexity.Calculate(ctx, regressedES, doc.Operations[0], nil)
282-
t.Logf("freighter transactions query complexity with a hypothetical AccountTransactionEdge multiplier = %d", regressed)
283-
require.Greater(t, regressed, 10_000, "this confirms the guard above is load-bearing: without it, the freighter transactions query blows the complexity limit")
130+
regressed := complexity.Calculate(ctx, regressedES, doc.Operations[0], map[string]any{"first": maxRequestedPageSize})
131+
t.Logf("full-detail account-history query complexity with a hypothetical AccountTransactionEdge multiplier = %d", regressed)
132+
require.Greater(t, regressed, defaultComplexityLimit(t), "this confirms the guard above is load-bearing: without it, the full-detail account-history query blows the complexity limit")
284133
}

internal/serve/complexity_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,12 @@ func TestGraphQLComplexityAccountingUsesSharedDefaultsAndExplicitArgs(t *testing
307307
// were cheap. Each case selects a minimal field set so the expected complexity is easy to
308308
// verify by hand and stays stable across unrelated schema edits.
309309
//
310-
// Worst case (every field selected, derived in the comment above the multipliers in
311-
// addComplexityCalculation): blendPools = 26,150 and blendPositions = 7,584 — both above a
312-
// 6,000 complexity limit by design; admitting the full selections requires a deployment-side
313-
// limit raise. This does not touch AccountTransactionEdge.operations/stateChanges or any other
314-
// existing complexity entry, so the freighter full-detail account-history query budget is
315-
// unchanged.
310+
// The full-selection cost of these fields is what the default GRAPHQL_COMPLEXITY_LIMIT is
311+
// sized for; complexity_regression_test.go prices the SDK's own documents against that default.
312+
// The pricing here leaves AccountTransactionEdge.operations/stateChanges and every other
313+
// pre-existing complexity entry untouched, so the account-history query is priced exactly as
314+
// before — its cost rose only because the SDK's state-change selection gained Blend fragments,
315+
// not because this accounting changed.
316316
func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) {
317317
testCases := []struct {
318318
name string

0 commit comments

Comments
 (0)