GraphQL API: encode state-change variants as 19 concrete types + schema conventions cleanup - #672
Conversation
The (category, reason) -> shape mapping was previously implicit in processor code: 9 concrete GraphQL types were picked by category alone, with per-variant nullability hidden behind uniformly nullable fields and JSON-in-String blobs. The schema now encodes the full variant structure: - 18 concrete BaseStateChange types, split wherever field nullability differs by reason (SignerAdded/Updated/Removed, TrustlineAdded/ Updated/Removed, AccountCreated/ContractDeployed/AccountMerged, HomeDomainChange/DataEntryChange/AllowanceChange), each docstringed with its exact (category, reason) pairs. - FeeChange split out of BALANCE rows (operation_id=0), letting every other type declare operation: Operation! (covariant narrowing). - JSON-blob fields replaced by typed fields: oldWeight/newWeight, oldThreshold/newThreshold, oldLimit/newLimit, name/oldValue/newValue, spender/amount/expirationLedger. SEP-41 approve rows now persist the spender in spender_account_id and slim key_value to live_until_ledger. - Interface field type renamed category (matches the enum and filter); Operation.operationType renamed type. - flags become typed enum lists (AccountFlag/TrustlineFlag) with deterministic decode order; BalanceAuthorizationChange.flags is null for SAC contract-holder rows instead of a lossy empty list. - Dispatch switches on (category, reason) plus discriminators and errors on unknown pairs; the discriminator columns are always selected regardless of the client projection. - Hygiene: connections/edges non-null everywhere, enum-typed filter input, TrustlineBalance.assetType enum replaces the free-string type, LiquidityPoolBalance.liquidityPoolId dropped (tokenId is the pool id), dead AUTHORIZATION category removed (Go enum + migration CHECK), sponsoredTrustline tokenId alias replaced by honest entity fields, SDL docstrings everywhere (introspectable). - pkg/wbclient mirrors the new schema (18 fragments, typed structs, schema-validation test); complexity default raised to 10000 since gqlgen sums all 18 mutually exclusive fragments (full-detail query measures 7601); AccountTransactionEdge keeps no multiplier. Deploy notes: GRAPHQL_COMPLEXITY_LIMIT env must be raised to 10000; pre-prod environments must re-ingest (edited migration CHECK, spender column, FeeChange rows).
Category->type table is now one-to-many, per-type field tables match the SDL, example queries validate against the schema, and stale references (envelopeXdr/metaXdr, operationType, keyValue on trustline/reserves/ balance-auth types) are gone.
buildAccountBalancesQuery still selected TrustlineBalance.type and LiquidityPoolBalance.liquidityPoolId, both removed from the schema; the client structs mirrored the stale fields. TrustlineBalance now carries assetType (AssetType enum), LiquidityPoolBalance drops the tokenId duplicate, and the offline schema-validation test covers every query builder so fragment/schema drift in any query fails unit tests.
There was a problem hiding this comment.
Pull request overview
Refactors the GraphQL state-change API into explicit variants and aligns schema, resolvers, persistence, documentation, and the Go client.
Changes:
- Introduces 18 concrete state-change types with typed fields.
- Standardizes GraphQL naming, enums, connections, and balance types.
- Persists SEP-41 spender data and raises the complexity limit.
Reviewed changes
Copilot reviewed 41 out of 48 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/wbclient/types/types.go |
Adds enums and updates balances. |
pkg/wbclient/types/types_test.go |
Updates client decoding tests. |
pkg/wbclient/types/statechange.go |
Models 18 state-change variants. |
pkg/wbclient/types/statechange_test.go |
Tests variant unmarshalling. |
pkg/wbclient/schema_validation_test.go |
Validates client queries against SDL. |
pkg/wbclient/queries.go |
Updates fragments and field aliases. |
pkg/wbclient/queries_test.go |
Verifies complete fragment selections. |
pkg/wbclient/client_test.go |
Updates client API expectations. |
internal/services/sep41/processor.go |
Persists allowance spender data. |
internal/services/sep41/processor_test.go |
Tests allowance persistence. |
internal/serve/serve.go |
Updates complexity documentation. |
internal/serve/graphql/schema/transaction.graphqls |
Documents and tightens transactions. |
internal/serve/graphql/schema/statechange.graphqls |
Defines concrete state-change schema. |
internal/serve/graphql/schema/scalars.graphqls |
Adds scalar documentation. |
internal/serve/graphql/schema/queries.graphqls |
Documents root queries. |
internal/serve/graphql/schema/pagination.graphqls |
Tightens connection nullability. |
internal/serve/graphql/schema/operation.graphqls |
Renames operation type field. |
internal/serve/graphql/schema/filters.graphqls |
Adds enum-typed filters. |
internal/serve/graphql/schema/enums.graphqls |
Adds documented enums. |
internal/serve/graphql/schema/balances.graphqls |
Cleans up balance contracts. |
internal/serve/graphql/schema/account.graphqls |
Tightens account connections. |
internal/serve/graphql/resolvers/utils.go |
Adds variant dispatch and projections. |
internal/serve/graphql/resolvers/utils_test.go |
Tests projection mappings. |
internal/serve/graphql/resolvers/transaction.resolvers.go |
Propagates conversion errors. |
internal/serve/graphql/resolvers/statechange_resolvers_test.go |
Tests dispatch and typed resolvers. |
internal/serve/graphql/resolvers/resolver.go |
Adds typed resolver helpers. |
internal/serve/graphql/resolvers/pagination.resolvers.go |
Handles conversion failures. |
internal/serve/graphql/resolvers/pagination_resolvers_test.go |
Updates pagination variant tests. |
internal/serve/graphql/resolvers/operation.resolvers.go |
Propagates conversion errors. |
internal/serve/graphql/resolvers/account.resolvers.go |
Supports enum filters and errors. |
internal/serve/graphql/resolvers/account_resolvers_test.go |
Updates account resolver tests. |
internal/serve/graphql/resolvers/account_liquidity_pool_balances_test.go |
Uses token ID for pools. |
internal/serve/graphql/resolvers/account_balances_utils.go |
Produces typed asset data. |
internal/serve/graphql/README.md |
Documents the breaking API. |
internal/serve/graphql/generated/models_gen.go |
Regenerates GraphQL models. |
internal/serve/graphql/dataloaders/statechange_loaders_test.go |
Tests mandatory projections. |
internal/serve/complexity_regression_test.go |
Updates worst-case complexity tests. |
internal/integrationtests/data_validation_test.go |
Uses typed state-change assertions. |
internal/integrationtests/account_balances_test.go |
Updates pool balance assertions. |
internal/indexer/types/types.go |
Adds variant models and flag enums. |
internal/indexer/types/types_test.go |
Tests deterministic flag decoding. |
internal/indexer/processors/state_change_builder.go |
Adds spender builder support. |
internal/db/migrations/2025-06-10.4-statechanges.sql |
Removes unused authorization category. |
internal/data/statechanges.go |
Forces dispatch discriminator columns. |
gqlgen.yml |
Maps new GraphQL models. |
cmd/utils/global_options.go |
Raises default complexity limit. |
Files not reviewed (5)
- internal/serve/graphql/resolvers/account.resolvers.go: Generated file
- internal/serve/graphql/resolvers/operation.resolvers.go: Generated file
- internal/serve/graphql/resolvers/pagination.resolvers.go: Generated file
- internal/serve/graphql/resolvers/statechange.resolvers.go: Generated file
- internal/serve/graphql/resolvers/transaction.resolvers.go: Generated file
Comments suppressed due to low confidence (2)
internal/serve/graphql/resolvers/utils.go:164
- These unconditional arms accept every reason, so rows such as
(SIGNATURE_THRESHOLD, CREATE)or(FLAGS, CREDIT)are exposed as concrete variants instead of triggering the documented data-integrity error. Validate LOW/MEDIUM/HIGH and SET/CLEAR respectively before returning.
case types.StateChangeCategorySignatureThreshold:
return &types.ThresholdChangeModel{StateChange: stateChange}, nil
case types.StateChangeCategoryFlags:
return &types.AccountFlagsChangeModel{StateChange: stateChange}, nil
internal/serve/graphql/resolvers/utils.go:188
- These categories also bypass reason validation. Because the database CHECK only validates category and reason independently, invalid pairs can exist and will currently be mislabeled rather than rejected. Restrict RESERVES to SPONSOR/UNSPONSOR and BALANCE_AUTHORIZATION to SET/CLEAR.
case types.StateChangeCategoryReserves:
return &types.SponsorshipChangeModel{StateChange: stateChange}, nil
case types.StateChangeCategoryBalanceAuthorization:
return &types.BalanceAuthorizationChangeModel{StateChange: stateChange}, nil
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
All 18 BaseStateChange implementers now end in Change (SignerAdded -> SignerAddedChange, TrustlineRemoved -> TrustlineRemovedChange, etc.), so every __typename is recognizable as a state-change variant and the concrete types group naturally in docs and autocomplete.
…rity Review fixes from PR #672: - convertStateChangeTypes restricts every arm to its valid reasons; corrupt pairs like (BALANCE, MERGE) now hit the integrity error instead of silently becoming BalanceChange/FeeChange, and fee rows are limited to DEBIT/CREDIT. - resolveRequiredString returns an error on SQL NULL (matching the address and int16 helpers) so missing amount/limit values surface as data-integrity errors instead of empty strings. - wbclient enforces the non-null connection contract: missing/null edges, null edge entries, and null nodes are unmarshal errors on every connection type (Transaction/Operation/StateChange/Balance/ SEP41Allowance/AccountTransaction), never nil values. - GetAccountStateChanges takes *types.StateChangeCategory / *types.StateChangeReason for compile-time filter safety.
- Pagination, time bounds, and state-change filters move from positional
parameters to Page / TimeRange / StateChangeFilter structs (nil =
defaults); GetAccountStateChanges drops from 12 positional params to 4.
- GraphQL response errors surface as a typed GraphQLErrors slice
(errors.As-able, per-error extensions such as code=BAD_USER_INPUT
preserved, all messages joined) instead of only the first message.
- Godocs on all exported symbols; interface{} -> any throughout.
…rustline fields - GetAccountTransactions/Operations/StateChanges (and the combined history call) reject a null connection on an existing account instead of passing nil through, matching GetAccountBalances and the schema's non-null connection fields. - TrustlineBalance.Code/Issuer become string (the schema declares code/issuer non-null); consumers no longer nil-check them.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 51 changed files in this pull request and generated 5 comments.
Files not reviewed (5)
- internal/serve/graphql/resolvers/account.resolvers.go: Generated file
- internal/serve/graphql/resolvers/operation.resolvers.go: Generated file
- internal/serve/graphql/resolvers/pagination.resolvers.go: Generated file
- internal/serve/graphql/resolvers/statechange.resolvers.go: Generated file
- internal/serve/graphql/resolvers/transaction.resolvers.go: Generated file
Comments suppressed due to low confidence (1)
internal/serve/graphql/schema/transaction.graphqls:25
- The SDK does not enforce this new non-null relationship:
GetTransactionStateChangesreturns the connection pointer directly, so an existing transaction withstateChanges: nullis accepted as(nil, nil). RepresenttransactionByHashas a nullable pointer to preserve not-found behavior, and reject a nilStateChangesfield when the transaction object exists.
…tion A state-change row whose operation or transaction is missing from the DB previously resolved to nil without an error; with operation/ transaction non-null on the concrete types, gqlgen would nullify the whole page with no diagnosable cause. resolveStateChangeOperation and resolveStateChangeTransaction now return an explicit data-integrity error on a loader miss (fee changes never call the operation resolver).
An updated or removed signer always had a prior weight, thresholds always had a prior value, and an account always has a home-domain field - so oldWeight, oldThreshold, oldHomeDomain, and newHomeDomain are now non-null. The effects processor guarantees them: a missing pre-image is a malformed effect rejected at parse time (dropped and logged, matching the existing policy for malformed effects) instead of degrading to a null field, and a master key absent from the pre-image signer summary decodes as its true prior weight, 0 (SignerSummary omits weight-0 master keys). Data-entry updates/removals likewise reject a missing pre-image, so a null DataEntryChange.oldValue now always means the entry was created - the remaining old/new nulls are semantic (creation/removal), never reconstruction misses. Client structs mirror the non-null fields.
851f65c to
c6f450d
Compare
getPrevLedgerEntryState returned the first pre-image of the requested ledger-entry type: the account-address check fell through to an unconditional return on mismatch, and trustline/data entries were not matched at all. Multi-entry operations (merges and sponsorship revokes carry two account pre-images; an operation can touch several of one account's trustlines) could therefore source old weights, thresholds, home domains, limits, and data values from the wrong entity. Pre-images now match the effect's entity: accounts by address, trustlines by trustor + asset (or liquidity pool), data entries by owner + entry name. A trustline update without a matching pre-image is rejected as malformed instead of panicking, and the data-entry name assertion is checked. Regression test pins entity matching for all three entry types.
| """ | ||
| A smart-contract deployment. `account` is the deployed contract address. | ||
| Pair: (ACCOUNT, CREATE). | ||
| """ | ||
| type ContractDeployedChange implements BaseStateChange { |
There was a problem hiding this comment.
It may be worth considering making contract deployment a variation of AccountCreatedChange, rather than having a distinct type. Again, the tradeoff is weak typing (deployerAddress and funderAddress have to be nullable) but the benefit is having one change type for creation of all account types.
I'm less opinionated about this one compared to FeeChange. I think it could also be confusing to encounter contract deployments when trying to monitor stellar account creation, so I'll defer to you.
There was a problem hiding this comment.
Another thought: right now we treat all contracts as potential smart accounts. Instead, we could only treat contracts as accounts if they include __check_auth() in their implementation.
Maybe this is out-of-scope of this PR, but worth discussing further.
There was a problem hiding this comment.
@JakeUrban I combined the two state changes into 1 by having a createrAccountID that is the funder address for classic and deployer account for contract deployments. I think that is cleaner here
…peration Transaction-fee rows (operation_id=0) are BalanceChange now; BalanceChange is the single type covering every balance movement. Its operation field is nullable for exactly the fee case (all other 16 concrete types keep a non-null operation), and the resolver short-circuits fee rows before the dataloader so a row that claims an operation id still errors on a loader miss. CREDIT fee rows were unreachable (the processor nets refunds into the charge), so fee rows are always (BALANCE, DEBIT); the enum docstrings drop the refund wording. Client fragments, dispatch matrix, complexity regression query, and README follow.
…ANCE category SEP-41 approve rows are (ALLOWANCE, UPDATE) now instead of (METADATA, UPDATE): allowances are not key-value data entries, so they no longer share a category with home domains and data entries. METADATA UPDATE is an invalid pair and dispatches to the integrity error. The migration CHECK gains the ALLOWANCE value in place (pre-prod re-ingest already required by this branch).
…e emission Sponsorship is an attribute of a reserve change, not a state change in its own right, so the RESERVES category goes away end to end: the effects processor no longer emits sponsor/unsponsor rows, the GraphQL type and its client mirror are deleted, and the migration drops the three sponsorship-only columns (sponsored_account_id, sponsor_account_id, sponsored_data) plus the RESERVES/SPONSOR/UNSPONSOR enum values in place. A reserves- or minimum-balance-tracking type can be added back once designed. Sponsorship operations themselves are still indexed; only their reserve state changes are gone, and integration suites keep their non-sponsorship assertions.
- wbclient: types.Operation mirrors the schema field name (Type, json:"type"); the default selection drops the operationType alias, so custom OperationFields selecting the real schema field unmarshal correctly. - wbclient: transaction/operation-scoped queries distinguish not-found from malformed responses — nullable roots with ErrTransactionNotFound/ ErrOperationNotFound sentinels, and a null non-null connection is a hard error (matching the account methods). GetTransactionByHash/ GetOperationByID return the sentinels instead of (nil, nil). - graphql: AccountFlagsChange.flags surfaces NULL backing data as a data-integrity error instead of serializing an empty list. - README: mixed-fragment example aliases tokenId on TrustlineAddedChange so it satisfies SameResponseShape; all 14 README queries now validate against the SDL.
The 16-fragment state-change selection measures 6701 at first:100 (balances unchanged at 3801), still above the previous 6000 limit, so the raise to 10000 stands.
Upstream (stellar/wallet-backend#672) merged FeeChange into BalanceChange, removed SponsorshipChange entirely, moved allowances to a new ALLOWANCE category, and renamed the SDK Operation field to Type (json "type"). The FeeChange and SponsorshipChange REST variants disappear from the wire: fees arrive as variant "BalanceChange" (BALANCE, DEBIT) with no to_muxed_id, and allowance entries' type field reads ALLOWANCE. SDK bumped to the upstream branch tip.
…hange One type covers all account creation: account is the created G-address or the deployed C-address, and a single non-null creatorAddress names the funding account or the deployer. The funder_account_id and deployer_account_id columns unify into creator_account_id (re-ingest already required on this branch), so the deployer dispatch discriminator disappears — type resolution is purely (category, reason) now — and deployer_account_id leaves the mandatory column set. Full-detail query complexity drops to 6601.
The payment transaction has 3 state changes (source debit, destination credit, transaction-fee row) and the claim-claimable-balance transaction has 2 (claimant credit, fee row) now that sponsorship reserve rows are no longer emitted — the two expectations had been transposed.
…e reason ThresholdChange follows the category-is-the-entity, reason-is-the-update convention now: the pair is (SIGNATURE_THRESHOLD, UPDATE) and a non-null threshold field (ThresholdLevel enum LOW/MEDIUM/HIGH, new threshold TEXT column) identifies which of the account's three thresholds changed. One row per changed threshold, low-medium-high emission order unchanged, so ordinals are unaffected. LOW/MEDIUM/HIGH leave the reason enum and CHECK.
…tegory
HomeDomainChange follows the convention now: the category names the
entity and the reason names the update — (HOME_DOMAIN, SET) when the
account had no domain, (HOME_DOMAIN, CLEAR) when it was removed,
(HOME_DOMAIN, UPDATE) when it changed value, derived at emission from
the empty-string transitions. The HOME_DOMAIN reason value is gone and
the row's key_value payload flattens to {old, new}. oldHomeDomain and
newHomeDomain stay non-null with "" meaning unset.
…split by reason
Data entries follow the convention now: category DATA_ENTRY with reasons
ADD/UPDATE/REMOVE (derived from the effect type), replacing (METADATA,
DATA_ENTRY) — and with home domains already moved out, the METADATA
category is gone entirely. The type splits by reason so nullability is
exact: DataEntryAddedChange{name!, value!}, DataEntryUpdatedChange{name!,
oldValue!, newValue!}, DataEntryRemovedChange{name!, oldValue!}. The
entry name moves from the key_value top-level key into a data_entry_name
column (projectable; no more first-map-key extraction) and key_value
flattens to {old, new}; a create/update effect without a new value is
rejected as malformed. 17 concrete types; full-detail query measures
7101, still under the 10000 limit.
…op rows
HomeDomainChange was the last type encoding meaning in a magic value:
oldHomeDomain "" meant "there wasn't one" and newHomeDomain "" meant
"it was removed". It splits into three exact types mirroring the data-entry
trio — HomeDomainSetChange{homeDomain!}, HomeDomainUpdatedChange{oldHomeDomain!,
newHomeDomain!}, HomeDomainClearedChange{oldHomeDomain!} — and the key_value
payload now carries only the meaningful side of the transition, so the empty
string reaches neither the API nor the database.
This also fixes a defect: the Horizon effect fires whenever SetOptions carries
a home domain, with no check that the value changed, so writing the domain an
account already had produced a row — and for an account with no domain, one
claiming reason SET with an empty newHomeDomain. Emission now skips unchanged
domains entirely, matching how flags already skip no-op rows.
parseKeyValue becomes parseDataEntryValues (home domain reads through the new
parseHomeDomain). 19 concrete types; full-detail query measures 7301.
protocol-migrate current-state consumed 112s of a 120s budget on the run that passed, so any slower runner killed it mid-stream: the two failures show it fetching ledgers 232 and 233 and polling for 234 when the deadline hit. Its wall-clock is dominated by waiting for the host-side exporter to publish ledgers rather than by our processing, so the window needed widening, not the work speeding up.
…ects GetAccountByAddress was the only one of twelve client query methods still returning (nil, nil) for a missing entity, so the sentinel guarantee the rest of the client advertises did not actually hold everywhere; it now returns ErrAccountNotFound like its siblings. The seven malformed-effect rejections in the effects processor logged at debug level, which is off in production — a dropped effect left no trace at all, making it operationally indistinguishable from the null-field degradation the rejection exists to prevent. They log at warn now.
An audit of every state_changes column found one orphan: claimable_balance_id was written as SQL NULL on every row, its builder setter was reachable only from its own unit test, and no resolver or GraphQL field ever read it — a leftover of the removed sponsorship changes. It goes end to end, column included (in place, as this branch already requires a re-ingest). Two non-column leftovers go with it. ContractType was computed per event by WithTokenType and then discarded on Build(): the field carried json:"-", no db tag, and no reader anywhere. Removing it left the asset argument unused in three helpers, which in turn left their receivers unused, so they become free functions. getContractType stays — it still gates non-SAC InvokeHostFunction events. The builder also assigned IngestedAt, but BatchCopy omits that column and Postgres DEFAULT NOW() supplies the persisted value, so the assignment was dead; the field and column remain, both live. Every other column is fully live: nothing is written without being exposed, and nothing is exposed without a writer.
… coverage The dispatch matrix covered 24 of the 26 (category, reason) pairs convertStateChangeTypes accepts; (BALANCE, BURN) and (BALANCE_AUTHORIZATION, CLEAR) were missing, and a section comment overclaimed that three categories accept any reason. Removing the sponsorship subtest also removed the only unit test driving a real CreateAccount transaction, whose first assertion covered the master-key signer synthesized when an account is created — live code that was left verified only by the Docker integration suite. A focused subtest asserts that SIGNER/ADD change again, on a minimal hand-built CreateAccount meta fixture rather than the opaque base64 group the removal took with it.
…efactor (#143) * feat: adapt to wallet-backend GraphQL schema refactor wallet-backend's state-change API now encodes (category, reason) variants as 18 concrete types with typed fields, and its Go SDK moved to Page/TimeRange/StateChangeFilter options and typed GraphQLErrors. - history: mapStateChange is an 18-case switch over the new SDK variants; REST state-change payloads carry typed snake_case fields (old_weight/new_weight, old_limit/new_limit, spender/expiration_ledger, ...) instead of JSON-blob strings; base type/reason fields unchanged. - balances: REST contract unchanged - the SDK's AssetType enum is lowered to the v1 Horizon spelling at the edge, and the LP pool id is read from TokenID. - classifyWBError matches the typed wbclient.GraphQLErrors via errors.As instead of message-prefix sniffing. - test fixtures updated to the new wire shape (category field, new __typename values, aliased response keys, non-null edges). * chore: bump wallet-backend SDK, absorb non-null contract tightening - TrustlineBalance.Code/Issuer are non-null strings in the SDK now; the mapper passes them through directly (v2 REST shape unchanged). - The SDK rejects a null transactions connection (schema declares it non-null), so the empty-page fallback for that case is gone; a null connection surfaces as an upstream error. * fix(history): add variant discriminator, keep flags array non-null - state_changes entries carry a variant field naming the concrete upstream shape; type/reason alone are ambiguous (BalanceChange vs FeeChange share BALANCE x DEBIT/CREDIT, account creation vs contract deployment share ACCOUNT x CREATE). - AccountFlagsChange.flags always encodes as an array; the upstream field is a non-null list, so an empty value is [] rather than null. - Wire-contract test covers both. * chore: bump wallet-backend SDK, mirror non-null old values Upstream made semantically guaranteed old values non-null (oldWeight on signer updates/removals, oldThreshold, old/new home domain) - the effects processor now rejects effects whose pre-image is missing rather than degrading to null, and a locked master key's prior weight decodes as 0. REST payloads mirror this: old_weight, old_threshold, and old/new_home_domain are always present. A null data-entry old_value now always means the entry was created. * refactor: absorb wallet-backend state-change consolidation Upstream (stellar/wallet-backend#672) merged FeeChange into BalanceChange, removed SponsorshipChange entirely, moved allowances to a new ALLOWANCE category, and renamed the SDK Operation field to Type (json "type"). The FeeChange and SponsorshipChange REST variants disappear from the wire: fees arrive as variant "BalanceChange" (BALANCE, DEBIT) with no to_muxed_id, and allowance entries' type field reads ALLOWANCE. SDK bumped to the upstream branch tip. * refactor: absorb upstream ContractDeployedChange merge into AccountCreatedChange One REST variant covers all account creation: creator_address replaces funder_address/deployer_address, and the ContractDeployedChange variant disappears from the wire. Every (type, reason) pair now maps to exactly one variant, so the variant field is a pure convenience discriminator. SDK bumped to the upstream tip. * refactor: absorb upstream (category, reason) convention alignment Upstream now keeps the category as the entity and the reason as the kind of update everywhere: thresholds are (SIGNATURE_THRESHOLD, UPDATE) with a new threshold field naming which one changed; home domains are (HOME_DOMAIN, SET/CLEAR/UPDATE); data entries are (DATA_ENTRY, ADD/UPDATE/REMOVE) split into three variants with non-null values, so the DataEntryChange variant leaves the wire and METADATA disappears as a type value. SDK bumped to the upstream tip; 17 variants. * refactor: absorb upstream home-domain split Upstream replaced HomeDomainChange with three exact variants, so the REST wire follows: HomeDomainSetChange carries home_domain, Updated carries old_home_domain plus new_home_domain, Cleared carries old_home_domain. The empty string no longer stands in for "no domain" anywhere, and a SetOptions op writing the domain an account already had emits nothing — so the mapping test's old shared-value fixture (that exact no-op) is replaced by three cases with distinct values. SDK bumped; 19 variants. * chore: repin wallet-backend SDK to merged main The previous pin (dedabe412f8f) was a commit on the graphql-schema-refactor PR branch. Upstream #672 has since merged to main as 5182ba49, so move the pin onto main. Inert for freighter: the GraphQL schema is byte-identical between the two commits, wallet-backend's own go.mod is unchanged (no transitive churn), and the only importable-surface change is pkg/wbclient returning ErrAccountNotFound from GetAccountByAddress -- a method this repo never calls. Everything else in the merge is under internal/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: merge flag mappers into one ~string generic mapAccountFlags and mapTrustlineFlags were the same []T -> []string loop, and both wbtypes.AccountFlag and wbtypes.TrustlineFlag are `type X string`, so one ~string-constrained generic covers both. Drops mapTrustlineFlags' `len(flags) == 0 -> return nil` guard, which had no observable effect: BalanceAuthorizationChange.Flags is tagged `json:"flags,omitempty"` and encoding/json omits "any array, slice, map, or string of length zero" -- nil or not -- so nil and []string{} already marshalled identically. The old doc comment credited the nil return for the omission, which was the wrong reason; the json tag is what decides it. The json tags are deliberately left alone: the 10 omitempty tags in internal/types/account_history.go map 1:1 onto the 10 nullable fields this repo surfaces from upstream's statechange.graphqls. flags is nullable on BalanceAuthorizationChange (L441, null for SAC contract-holder authorization, which has no trustline flags) but non-null on AccountFlagsChange (L206) -- so the differing tags mirror the schema rather than diverging from it. Adds the missing wire-contract counterpart: AccountFlagsChange's `"flags":[]` case was covered, BalanceAuthorizationChange's key-absent case was not. The new test asserts both nil and non-nil-empty flags drop the key entirely, pinning the encoding/json behaviour this refactor relies on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Breaking refactor of the GraphQL API (we're pre-release, so this is the last free window). The core problem: which
(category, reason)pairs exist and which fields are set for each was invisible in the schema — you had to read processor code to know that e.g.signerWeights.oldis null forADD. This PR encodes the full variant structure in the schema, plus a holistic conventions cleanup.State changes: 9 opaque types → 19 exact types
BaseStateChange.typeis renamedcategory(matches the enum + filter input). Types are split wherever nullability differs by reason, so every field's nullability is now exact and documented in SDL docstrings:BalanceChangetokenId!,amount!,toMuxedId— covers transaction fees: fee rows are (BALANCE, DEBIT) withoperationnullAccountCreatedChangecreatorAddress!—accountis the created G-address or deployed C-address;creatorAddressis the funder or deployerAccountMergedChangedestinationAddress!SignerAddedChangesignerAddress!,newWeight!SignerUpdatedChangesignerAddress!,oldWeight!,newWeight!SignerRemovedChangesignerAddress!,oldWeight!ThresholdChangethreshold: ThresholdLevel!(LOW/MEDIUM/HIGH — which one changed),oldThreshold!,newThreshold!; one row per changed thresholdAccountFlagsChangeflags: [AccountFlag!]!HomeDomainSetChangehomeDomain!HomeDomainUpdatedChangeoldHomeDomain!,newHomeDomain!HomeDomainClearedChangeoldHomeDomain!DataEntryAddedChangename!,value!(base64)DataEntryUpdatedChangename!,oldValue!,newValue!(base64)DataEntryRemovedChangename!,oldValue!(base64)AllowanceChangetokenId!,spender!,amount!,expirationLedger!TrustlineAddedChangetokenId⊕liquidityPoolId,limit!TrustlineUpdatedChangetokenId⊕liquidityPoolId,oldLimit!,newLimit!TrustlineRemovedChangetokenId⊕liquidityPoolIdBalanceAuthorizationChangetokenId⊕liquidityPoolId,flags: [TrustlineFlag!](null for SAC holders)(⊕ = exactly one set, documented in the docstrings.)
Highlights:
signerWeights/thresholds/limitblobs andMetadataChange.keyValueare typed fields now. SEP-41 approve persists the spender in the (previously unused)spender_account_idcolumn.BalanceChange. One type covers the complete set of balance movements; transaction-fee rows are(BALANCE, DEBIT)withoperationnull (fees are per-transaction) andtoMuxedIdnull. A CREDIT fee row cannot exist: the processor nets Soroban refunds into the charge, and a refund never exceeds the fee.operationis non-null on the other 14 types.thresholdfield, backed by a new column); home domains and data entries no longer encode the entity in the reason — they get their ownHOME_DOMAINandDATA_ENTRYcategories, each split by transition so nullability is exact, and theMETADATAcategory is gone. The data-entry name moves into adata_entry_namecolumn (projectable) andkey_valuepayloads flatten to only the meaningful side of the transition — no field in the API means anything by being empty any more, and the empty string never reaches the database.SetOptionscarries a domain, with no check that the value changed, so re-writing the same domain used to emit a row — and on an account with no domain it emitted one claiming reason SET with an emptynewHomeDomain. Emission now skips unchanged domains, matching how flags already skip no-op rows.ALLOWANCEcategory. They aren't key-value metadata, so they no longer share METADATA with home domains and data entries;(METADATA, UPDATE)is now an invalid pair.SponsorshipChangeand the RESERVES category are removed end to end — including the effects-processor emission and the three sponsorship-only DB columns. Sponsorship is an attribute of a reserve change, not a state change in its own right; aReservesChange/MinimumBalanceChangetype tracking required reserves can be added back once designed. Sponsorship operations are still indexed.AccountCreatedChange. One type for all account creation:accountdistinguishes classic (G-address) vs contract (C-address), and a single non-nullcreatorAddressreplacesfunderAddress/deployerAddress(the two DB columns unify intocreator_account_id).flagsare typed enums (AccountFlag/TrustlineFlag) with deterministic order (the old string decoder iterated a map — order was random per request). A NULLflagscolumn onAccountFlagsChangeis a data-integrity error, never an empty list.oldWeight,oldThreshold,oldHomeDomain): the effects processor rejects effects whose ledger-entry pre-image is missing (malformed meta, dropped + logged) instead of degrading to a null field, and a locked master key's prior weight decodes as its true value, 0. A nullDataEntryChange.oldValuenow always means "created", never "couldn't reconstruct".Other breaking changes
Operation.operationTypeOperation.typecategory/reason: StringStateChangeCategory/StateChangeReasonenumsedges: [X!]!,node: X!, connection fields non-null everywhereTrustlineBalance.type: String("credit_alphanum4")assetType: AssetType!enumLiquidityPoolBalance.liquidityPoolIdtokenIdis the pool id)ReservesChange/SponsorshipChange,RESERVES,SPONSOR/UNSPONSORContractDeployedChange,funderAddress/deployerAddressAccountCreatedChange.creatorAddress!AllowanceChangeunderMETADATAALLOWANCEcategory(SIGNATURE_THRESHOLD, LOW/MEDIUM/HIGH)(SIGNATURE_THRESHOLD, UPDATE)+threshold: ThresholdLevel!field(METADATA, HOME_DOMAIN/DATA_ENTRY),METADATAcategoryHOME_DOMAINandDATA_ENTRYcategories; home domains split into Set/Updated/Cleared and data entries into Added/Updated/RemovedAUTHORIZATIONcategory (never emitted)#comments in SDL"""docstrings"""(introspectable, visible in GraphiQL)Complexity limit: default 5000 → 10000
gqlgen sums mutually exclusive inline fragments, so the full-detail account-history query selecting all 19 fragments measures 7301 at
first:100(balances: 3801).AccountTransactionEdge.operations/stateChangesstill have no multiplier (guard test unchanged). Regression tests pin both queries under 10000.Client (
pkg/wbclient) — breaking API cleanup__typenameswitch, enum consts). An offline test validates every client query against the SDL, so client/schema drift fails unit tests.types.Operation.Typemirrors the schema field name (json:"type", no aliasing), so customQueryOptions.OperationFieldsselecting the real schema field unmarshal correctly.Page{First,Last,After,Before},TimeRange{Since,Until},StateChangeFilter{TransactionHash,OperationID,Category,Reason}— e.g.GetAccountStateChanges(ctx, addr, filter, timeRange, page)instead of 12 positional params. Filter category/reason are enum-typed.GraphQLErrorsslice (errors.As-able,extensions.codepreserved, all messages joined) instead of just the first message string.edgesornodeis an unmarshal error, and a null connection field on an existing entity is rejected — never a silent nil.ErrAccountNotFound,ErrTransactionNotFound,ErrOperationNotFound— transaction/operation-scoped queries distinguish "doesn't exist" from "malformed response" instead of returning(nil, nil).TrustlineBalance.Code/Issuerare non-nullstring(the SDL declarescode/issuernon-null).Tests
(BALANCE, MERGE),(METADATA, UPDATE)); required fields (amount, trustline limits,flags) error on NULL backing data instead of returning""/[].state_changescolumn is written without being exposed and none is exposed without a writer; the one orphan (claimable_balance_id, a sponsorship leftover) is dropped along with two pieces of dead builder plumbing.Deploy notes
GRAPHQL_COMPLEXITY_LIMITenv 6000 → 10000threshold,data_entry_name,creator_account_id) and dropped (sponsorship columns,funder_account_id/deployer_account_id,claimable_balance_id),spender_account_idnewly writtencategory,Operation.type, split types — stellar/freighter-backend-v2#143Verification
make check+make unit-test(race, 32 packages) green; complexity regression suite green; integration suite compile-verified (full Docker run pending).🤖 Generated with Claude Code