Skip to content

GraphQL API: encode state-change variants as 19 concrete types + schema conventions cleanup - #672

Merged
aditya1702 merged 25 commits into
mainfrom
graphql-schema-refactor
Jul 29, 2026
Merged

GraphQL API: encode state-change variants as 19 concrete types + schema conventions cleanup#672
aditya1702 merged 25 commits into
mainfrom
graphql-schema-refactor

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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.old is null for ADD. This PR encodes the full variant structure in the schema, plus a holistic conventions cleanup.

State changes: 9 opaque types → 19 exact types

BaseStateChange.type is renamed category (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:

Type (category, reason) Own fields
BalanceChange BALANCE × DEBIT/CREDIT/MINT/BURN tokenId!, amount!, toMuxedId — covers transaction fees: fee rows are (BALANCE, DEBIT) with operation null
AccountCreatedChange ACCOUNT × CREATE creatorAddress!account is the created G-address or deployed C-address; creatorAddress is the funder or deployer
AccountMergedChange ACCOUNT × MERGE destinationAddress!
SignerAddedChange SIGNER × ADD signerAddress!, newWeight!
SignerUpdatedChange SIGNER × UPDATE signerAddress!, oldWeight!, newWeight!
SignerRemovedChange SIGNER × REMOVE signerAddress!, oldWeight!
ThresholdChange SIGNATURE_THRESHOLD × UPDATE threshold: ThresholdLevel! (LOW/MEDIUM/HIGH — which one changed), oldThreshold!, newThreshold!; one row per changed threshold
AccountFlagsChange FLAGS × SET/CLEAR flags: [AccountFlag!]!
HomeDomainSetChange HOME_DOMAIN × SET homeDomain!
HomeDomainUpdatedChange HOME_DOMAIN × UPDATE oldHomeDomain!, newHomeDomain!
HomeDomainClearedChange HOME_DOMAIN × CLEAR oldHomeDomain!
DataEntryAddedChange DATA_ENTRY × ADD name!, value! (base64)
DataEntryUpdatedChange DATA_ENTRY × UPDATE name!, oldValue!, newValue! (base64)
DataEntryRemovedChange DATA_ENTRY × REMOVE name!, oldValue! (base64)
AllowanceChange ALLOWANCE × UPDATE (SEP-41 approve) tokenId!, spender!, amount!, expirationLedger!
TrustlineAddedChange TRUSTLINE × ADD tokenIdliquidityPoolId, limit!
TrustlineUpdatedChange TRUSTLINE × UPDATE tokenIdliquidityPoolId, oldLimit!, newLimit!
TrustlineRemovedChange TRUSTLINE × REMOVE tokenIdliquidityPoolId
BalanceAuthorizationChange BALANCE_AUTHORIZATION × SET/CLEAR tokenIdliquidityPoolId, flags: [TrustlineFlag!] (null for SAC holders)

(⊕ = exactly one set, documented in the docstrings.)

Highlights:

  • JSON-in-String is gone. signerWeights/thresholds/limit blobs and MetadataChange.keyValue are typed fields now. SEP-41 approve persists the spender in the (previously unused) spender_account_id column.
  • Fees are BalanceChange. One type covers the complete set of balance movements; transaction-fee rows are (BALANCE, DEBIT) with operation null (fees are per-transaction) and toMuxedId null. A CREDIT fee row cannot exist: the processor nets Soroban refunds into the charge, and a refund never exceeds the fee. operation is non-null on the other 14 types.
  • The (category, reason) convention is uniform: category = the entity, reason = the kind of update. Thresholds no longer encode which threshold in the reason (that's the typed threshold field, backed by a new column); home domains and data entries no longer encode the entity in the reason — they get their own HOME_DOMAIN and DATA_ENTRY categories, each split by transition so nullability is exact, and the METADATA category is gone. The data-entry name moves into a data_entry_name column (projectable) and key_value payloads 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.
  • No-op writes no longer produce rows. The Horizon home-domain effect fires whenever SetOptions carries 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 empty newHomeDomain. Emission now skips unchanged domains, matching how flags already skip no-op rows.
  • Allowances get their own ALLOWANCE category. 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.
  • SponsorshipChange and 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; a ReservesChange/MinimumBalanceChange type tracking required reserves can be added back once designed. Sponsorship operations are still indexed.
  • Contract deployments are AccountCreatedChange. One type for all account creation: account distinguishes classic (G-address) vs contract (C-address), and a single non-null creatorAddress replaces funderAddress/deployerAddress (the two DB columns unify into creator_account_id).
  • Type resolution dispatches purely on (category, reason) — no column discriminators — and errors on unknown pairs instead of returning null nodes. The discriminator columns (category, reason) are always selected regardless of client field selection.
  • flags are typed enums (AccountFlag/TrustlineFlag) with deterministic order (the old string decoder iterated a map — order was random per request). A NULL flags column on AccountFlagsChange is a data-integrity error, never an empty list.
  • Semantically guaranteed old values are non-null (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 null DataEntryChange.oldValue now always means "created", never "couldn't reconstruct".
  • Pre-image lookups are entity-matched (fixes a latent bug in the old-value extraction): the lookup previously returned the first pre-image of the requested entry type — multi-entry operations (merges carry two account pre-images) could source old values from the wrong entity. Accounts now match by address, trustlines by trustor + asset/pool, data entries by owner + name; regression tests pin all three.

Other breaking changes

Before After
Operation.operationType Operation.type
Filter category/reason: String StateChangeCategory / StateChangeReason enums
Connections/edges nullable inconsistently edges: [X!]!, node: X!, connection fields non-null everywhere
TrustlineBalance.type: String ("credit_alphanum4") assetType: AssetType! enum
LiquidityPoolBalance.liquidityPoolId removed (tokenId is the pool id)
ReservesChange/SponsorshipChange, RESERVES, SPONSOR/UNSPONSOR removed (see above)
ContractDeployedChange, funderAddress/deployerAddress merged into AccountCreatedChange.creatorAddress!
AllowanceChange under METADATA own ALLOWANCE category
(SIGNATURE_THRESHOLD, LOW/MEDIUM/HIGH) (SIGNATURE_THRESHOLD, UPDATE) + threshold: ThresholdLevel! field
(METADATA, HOME_DOMAIN/DATA_ENTRY), METADATA category HOME_DOMAIN and DATA_ENTRY categories; home domains split into Set/Updated/Cleared and data entries into Added/Updated/Removed
AUTHORIZATION category (never emitted) removed from Go enum + migration CHECK
# 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/stateChanges still have no multiplier (guard test unchanged). Regression tests pin both queries under 10000.

Client (pkg/wbclient) — breaking API cleanup

  • Mirrors the schema 1:1 (19 fragments/structs, __typename switch, enum consts). An offline test validates every client query against the SDL, so client/schema drift fails unit tests.
  • types.Operation.Type mirrors the schema field name (json:"type", no aliasing), so custom QueryOptions.OperationFields selecting the real schema field unmarshal correctly.
  • Pagination/time/filter params move from positional args to structs (nil = defaults): 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.
  • GraphQL response errors are a typed GraphQLErrors slice (errors.As-able, extensions.code preserved, all messages joined) instead of just the first message string.
  • Connections enforce the non-null contract end to end: missing/null edges or node is an unmarshal error, and a null connection field on an existing entity is rejected — never a silent nil.
  • Not-found is always a sentinel: ErrAccountNotFound, ErrTransactionNotFound, ErrOperationNotFound — transaction/operation-scoped queries distinguish "doesn't exist" from "malformed response" instead of returning (nil, nil).
  • TrustlineBalance.Code/Issuer are non-null string (the SDL declares code/issuer non-null).

Tests

  • Dispatch-matrix unit tests cover every (category, reason, discriminator) → type, including invalid-pair error cases (dispatch rejects e.g. (BALANCE, MERGE), (METADATA, UPDATE)); required fields (amount, trustline limits, flags) error on NULL backing data instead of returning ""/[].
  • A column audit confirms no state_changes column 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.
  • Integration validators rewritten to typed assertions (enum flags fail at compile time now, not string-compare); sponsorship ops keep their operation-level and non-sponsorship state-change coverage.
  • All 14 GraphQL examples in the README validate against the SDL (SameResponseShape included).

Deploy notes

Action Why
Raise GRAPHQL_COMPLEXITY_LIMIT env 6000 → 10000 freighter full-detail query measures 7301
Re-ingest dev/staging migration edited in place (sql-migrate tracks by filename): CHECK values changed (−AUTHORIZATION, −RESERVES/SPONSOR/UNSPONSOR, +ALLOWANCE/HOME_DOMAIN/DATA_ENTRY, −METADATA), columns added (threshold, data_entry_name, creator_account_id) and dropped (sponsorship columns, funder_account_id/deployer_account_id, claimable_balance_id), spender_account_id newly written
freighter-backend-v2 adapts renamed fields when it adopts this API category, Operation.type, split types — stellar/freighter-backend-v2#143

Verification

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

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.
Copilot AI review requested due to automatic review settings July 24, 2026 17:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/serve/graphql/resolvers/utils.go Outdated
Comment thread internal/serve/graphql/schema/pagination.graphqls
Comment thread internal/serve/graphql/schema/pagination.graphqls
Comment thread internal/serve/graphql/schema/filters.graphqls
Comment thread internal/serve/graphql/resolvers/resolver.go
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: GetTransactionStateChanges returns the connection pointer directly, so an existing transaction with stateChanges: null is accepted as (nil, nil). Represent transactionByHash as a nullable pointer to preserve not-found behavior, and reject a nil StateChanges field when the transaction object exists.

Comment thread pkg/wbclient/queries.go Outdated
Comment thread internal/serve/graphql/schema/transaction.graphqls
Comment thread internal/serve/graphql/schema/operation.graphqls
Comment thread internal/serve/graphql/resolvers/statechange.resolvers.go
Comment thread internal/serve/graphql/README.md Outdated
…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.
@aditya1702
aditya1702 force-pushed the graphql-schema-refactor branch from 851f65c to c6f450d Compare July 24, 2026 20:28
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.
Comment thread internal/data/statechanges.go Outdated
Comment thread internal/serve/graphql/schema/statechange.graphqls Outdated
Comment on lines +101 to +105
"""
A smart-contract deployment. `account` is the deployed contract address.
Pair: (ACCOUNT, CREATE).
"""
type ContractDeployedChange implements BaseStateChange {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Comment thread internal/serve/graphql/schema/statechange.graphqls
Comment thread internal/serve/graphql/schema/statechange.graphqls Outdated
…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.
@aditya1702 aditya1702 changed the title GraphQL API: encode state-change variants as 18 concrete types + schema conventions cleanup GraphQL API: encode state-change variants as 16 concrete types + schema conventions cleanup Jul 28, 2026
aditya1702 added a commit to stellar/freighter-backend-v2 that referenced this pull request Jul 28, 2026
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.
@aditya1702 aditya1702 changed the title GraphQL API: encode state-change variants as 16 concrete types + schema conventions cleanup GraphQL API: encode state-change variants as 15 concrete types + schema conventions cleanup Jul 28, 2026
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.
@aditya1702 aditya1702 changed the title GraphQL API: encode state-change variants as 15 concrete types + schema conventions cleanup GraphQL API: encode state-change variants as 17 concrete types + schema conventions cleanup Jul 28, 2026
…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.
@aditya1702 aditya1702 changed the title GraphQL API: encode state-change variants as 17 concrete types + schema conventions cleanup GraphQL API: encode state-change variants as 19 concrete types + schema conventions cleanup Jul 28, 2026
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.
Comment thread internal/db/migrations/2025-06-10.4-statechanges.sql
Comment thread pkg/wbclient/client.go Outdated
Comment thread internal/serve/graphql/resolvers/statechange_resolvers_test.go
Comment thread internal/indexer/processors/state_change_builder.go Outdated
Comment thread internal/indexer/processors/effects_test.go
Comment thread internal/indexer/processors/effects.go
…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.
Comment thread pkg/wbclient/client.go
@aditya1702
aditya1702 merged commit 5182ba4 into main Jul 29, 2026
9 checks passed
@aditya1702
aditya1702 deleted the graphql-schema-refactor branch July 29, 2026 20:32
aditya1702 added a commit to stellar/freighter-backend-v2 that referenced this pull request Jul 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants