Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d49eeb1
refactor(graphql): encode state-change variants as 18 concrete types
aditya1702 Jul 24, 2026
521d2dc
docs(graphql): rewrite README for the 18-type state-change API
aditya1702 Jul 24, 2026
e54fc4f
fix(wbclient): port balance query to the assetType schema
aditya1702 Jul 24, 2026
20dc21a
refactor(graphql): uniform Change suffix on all state-change types
aditya1702 Jul 24, 2026
8fc5889
fix(graphql): address PR review - strict dispatch, client contract pa…
aditya1702 Jul 24, 2026
6933505
refactor(wbclient): production-grade client API
aditya1702 Jul 24, 2026
a149f64
fix(wbclient): enforce non-null contract on account connections and t…
aditya1702 Jul 24, 2026
28437bd
fix(graphql): surface dataloader misses on non-null operation/transac…
aditya1702 Jul 24, 2026
c6f450d
refactor(graphql): make semantically guaranteed old values non-null
aditya1702 Jul 24, 2026
388b779
fix(processors): match pre-image lookups to the effect's entity
aditya1702 Jul 24, 2026
244a457
refactor(graphql)!: fold FeeChange into BalanceChange with nullable o…
aditya1702 Jul 28, 2026
0d44177
refactor(graphql)!: move allowance state changes to a dedicated ALLOW…
aditya1702 Jul 28, 2026
fa5c52f
refactor(graphql)!: remove SponsorshipChange and reserves state-chang…
aditya1702 Jul 28, 2026
74d182b
fix(wbclient,graphql): address Copilot review round 2
aditya1702 Jul 28, 2026
2f41451
chore(test): record re-measured full-detail query complexity
aditya1702 Jul 28, 2026
247a36c
refactor(graphql)!: merge ContractDeployedChange into AccountCreatedC…
aditya1702 Jul 28, 2026
917541f
fix(integrationtests): correct swapped state-change count expectations
aditya1702 Jul 28, 2026
fe45725
refactor(graphql)!: encode which threshold changed as a field, not th…
aditya1702 Jul 28, 2026
2db8ad5
refactor(graphql)!: give home-domain changes their own HOME_DOMAIN ca…
aditya1702 Jul 28, 2026
8477ee2
refactor(graphql)!: give data entries their own DATA_ENTRY category, …
aditya1702 Jul 28, 2026
dedabe4
refactor(graphql)!: split home-domain changes by transition, drop no-…
aditya1702 Jul 28, 2026
07b2c08
test(integrationtests): give one-shot command containers room to finish
aditya1702 Jul 28, 2026
f53a702
fix: complete the not-found sentinel contract and surface dropped eff…
aditya1702 Jul 29, 2026
b3fadff
refactor: drop state-change plumbing that nothing consumes
aditya1702 Jul 29, 2026
61662b8
test: close two dispatch-matrix gaps and restore CreateAccount signer…
aditya1702 Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,7 @@ Each test suite focuses on a specific aspect of the wallet-backend functionality
- **Tests**:
- Transaction metadata (hash, ledger number, envelope XDR, result XDR, meta XDR)
- Operations (type, source account, operation-specific fields)
- State changes (account creation, balance changes, trustlines, offers, liquidity pools, sponsorship, signers, metadata)
- State changes (account creation, balance changes, trustlines, offers, liquidity pools, signers, metadata)
- Relationships (transaction ↔ operations ↔ state changes)
- Pagination (forward and backward)
- **Coverage**: GraphQL API, ingestion accuracy, ledger state parsing, XDR decoding
Expand Down
4 changes: 2 additions & 2 deletions cmd/utils/global_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ func NetworkOption(configKey *string) *config.ConfigOption {
func GraphQLComplexityLimitOption(configKey *int) *config.ConfigOption {
return &config.ConfigOption{
Name: "graphql-complexity-limit",
Usage: "The maximum complexity limit for GraphQL queries. Complexity is calculated based on fields and pagination parameters.",
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).",
OptType: types.Int,
ConfigKey: configKey,
FlagDefault: 5000,
FlagDefault: 10_000,
Required: false,
}
}
Expand Down
73 changes: 56 additions & 17 deletions gqlgen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,33 +173,72 @@ models:
StateChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.StateChange
StandardBalanceChange:
BalanceChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.StandardBalanceStateChangeModel
AccountChange:
- github.com/stellar/wallet-backend/internal/indexer/types.BalanceChangeModel
AccountCreatedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.AccountStateChangeModel
SignerChange:
- github.com/stellar/wallet-backend/internal/indexer/types.AccountCreatedChangeModel
AccountMergedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.SignerStateChangeModel
SignerThresholdsChange:
- github.com/stellar/wallet-backend/internal/indexer/types.AccountMergedChangeModel
SignerAddedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.SignerThresholdsStateChangeModel
MetadataChange:
- github.com/stellar/wallet-backend/internal/indexer/types.SignerAddedChangeModel
SignerUpdatedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.MetadataStateChangeModel
FlagsChange:
- github.com/stellar/wallet-backend/internal/indexer/types.SignerUpdatedChangeModel
SignerRemovedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.FlagsStateChangeModel
TrustlineChange:
- github.com/stellar/wallet-backend/internal/indexer/types.SignerRemovedChangeModel
ThresholdChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.TrustlineStateChangeModel
- github.com/stellar/wallet-backend/internal/indexer/types.ThresholdChangeModel
AccountFlagsChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.AccountFlagsChangeModel
HomeDomainSetChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.HomeDomainSetChangeModel
HomeDomainUpdatedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.HomeDomainUpdatedChangeModel
HomeDomainClearedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.HomeDomainClearedChangeModel
DataEntryAddedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.DataEntryAddedChangeModel
DataEntryUpdatedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.DataEntryUpdatedChangeModel
DataEntryRemovedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.DataEntryRemovedChangeModel
AllowanceChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.AllowanceChangeModel
TrustlineAddedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.TrustlineAddedChangeModel
TrustlineUpdatedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.TrustlineUpdatedChangeModel
TrustlineRemovedChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.TrustlineRemovedChangeModel
BalanceAuthorizationChange:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.BalanceAuthorizationStateChangeModel
ReservesChange:
- github.com/stellar/wallet-backend/internal/indexer/types.BalanceAuthorizationChangeModel
AccountFlag:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.AccountFlag
TrustlineFlag:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.TrustlineFlag
ThresholdLevel:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.ReservesStateChangeModel
- github.com/stellar/wallet-backend/internal/indexer/types.ThresholdLevel
OperationType:
model:
- github.com/stellar/wallet-backend/internal/indexer/types.OperationType
Expand Down
53 changes: 23 additions & 30 deletions internal/data/statechanges.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ type StateChangeWriter interface {

var _ StateChangeWriter = (*StateChangeModel)(nil)

// stateChangeMandatoryColumns is always selected regardless of the GraphQL field
// projection: the cursor/key columns, plus the discriminators (state_change_category,
// state_change_reason) that convertStateChangeTypes in the resolvers package needs
// to pick the concrete GraphQL type for each row.
var stateChangeMandatoryColumns = []string{"to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at", "state_change_category", "state_change_reason"}

// BatchGetByAccountAddress gets the state changes that are associated with the given account address.
// Optional filters: txHash, operationID, category, and reason can be used to further filter results.
func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, txHash *string, operationID *int64, category *string, reason *string, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder, timeRange *TimeRange) ([]*types.StateChangeWithCursor, error) {
columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "", stateChangeMandatoryColumns...)
var queryBuilder strings.Builder
args := []interface{}{types.AddressBytea(accountAddress)}
argIndex := 2
Expand Down Expand Up @@ -156,10 +162,12 @@ func (m *StateChangeModel) BatchCopy(
"to_id", "state_change_id", "state_change_category", "state_change_reason",
"ledger_created_at", "ledger_number", "account_id", "operation_id",
"token_id", "amount", "signer_account_id", "spender_account_id",
"sponsored_account_id", "sponsor_account_id", "deployer_account_id", "funder_account_id",
"destination_account_id", "claimable_balance_id", "liquidity_pool_id", "sponsored_data",
"signer_weight_old", "signer_weight_new", "threshold_old", "threshold_new",
"trustline_limit_old", "trustline_limit_new", "flags", "key_value",
"creator_account_id", "destination_account_id",
"liquidity_pool_id",
"signer_weight_old", "signer_weight_new",
"threshold", "threshold_old", "threshold_new",
"trustline_limit_old", "trustline_limit_new", "flags",
"data_entry_name", "key_value",
"to_muxed_id",
},
pgx.CopyFromSlice(len(stateChanges), func(i int) ([]any, error) {
Expand All @@ -180,21 +188,9 @@ func (m *StateChangeModel) BatchCopy(
if err != nil {
return nil, fmt.Errorf("converting spender_account_id: %w", err)
}
sponsoredBytes, err := pgtypeBytesFromNullAddressBytea(sc.SponsoredAccountID)
if err != nil {
return nil, fmt.Errorf("converting sponsored_account_id: %w", err)
}
sponsorBytes, err := pgtypeBytesFromNullAddressBytea(sc.SponsorAccountID)
if err != nil {
return nil, fmt.Errorf("converting sponsor_account_id: %w", err)
}
deployerBytes, err := pgtypeBytesFromNullAddressBytea(sc.DeployerAccountID)
if err != nil {
return nil, fmt.Errorf("converting deployer_account_id: %w", err)
}
funderBytes, err := pgtypeBytesFromNullAddressBytea(sc.FunderAccountID)
creatorBytes, err := pgtypeBytesFromNullAddressBytea(sc.CreatorAccountID)
if err != nil {
return nil, fmt.Errorf("converting funder_account_id: %w", err)
return nil, fmt.Errorf("converting creator_account_id: %w", err)
}
destinationBytes, err := pgtypeBytesFromNullAddressBytea(sc.DestinationAccountID)
if err != nil {
Expand All @@ -218,21 +214,18 @@ func (m *StateChangeModel) BatchCopy(
pgtypeTextFromNullString(sc.Amount),
signerBytes,
spenderBytes,
sponsoredBytes,
sponsorBytes,
deployerBytes,
funderBytes,
creatorBytes,
destinationBytes,
pgtypeTextFromNullString(sc.ClaimableBalanceID),
pgtypeTextFromNullString(sc.LiquidityPoolID),
pgtypeTextFromNullString(sc.SponsoredData),
pgtypeInt2FromNullInt16(sc.SignerWeightOld),
pgtypeInt2FromNullInt16(sc.SignerWeightNew),
pgtypeTextFromNullString(sc.Threshold),
pgtypeInt2FromNullInt16(sc.ThresholdOld),
pgtypeInt2FromNullInt16(sc.ThresholdNew),
pgtypeTextFromNullString(sc.TrustlineLimitOld),
pgtypeTextFromNullString(sc.TrustlineLimitNew),
pgtypeInt2FromNullInt16(sc.Flags),
pgtypeTextFromNullString(sc.DataEntryName),
jsonbFromMap(sc.KeyValue),
pgtypeTextFromNullString(sc.ToMuxedID),
}, nil
Expand Down Expand Up @@ -266,7 +259,7 @@ func (m *StateChangeModel) BatchCopy(
// BatchGetByToID gets state changes for a single transaction with pagination support, pinned to
// the parent transaction's ledger_created_at for partition-column chunk exclusion.
func (m *StateChangeModel) BatchGetByToID(ctx context.Context, toID int64, ledgerCreatedAt time.Time, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) {
columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "", stateChangeMandatoryColumns...)
var queryBuilder strings.Builder
fmt.Fprintf(&queryBuilder, `
SELECT %s, ledger_created_at as cursor_ledger_created_at, to_id as cursor_to_id, operation_id as cursor_operation_id, state_change_id as cursor_state_change_id
Expand Down Expand Up @@ -330,7 +323,7 @@ func (m *StateChangeModel) BatchGetByToIDs(ctx context.Context, toIDs []int64, l
if len(toIDs) != len(ledgerCreatedAts) {
return nil, fmt.Errorf("toIDs and ledgerCreatedAts must be parallel arrays of equal length, got %d and %d", len(toIDs), len(ledgerCreatedAts))
}
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", stateChangeMandatoryColumns...)

// The ORDER BY + LIMIT live inside the LATERAL (a per-transaction top-N), replacing the old
// ROW_NUMBER()-over-everything CTE with an equivalent per-to_id cap; a subquery containing
Expand Down Expand Up @@ -397,7 +390,7 @@ func (m *StateChangeModel) BatchGetByToIDs(ctx context.Context, toIDs []int64, l
// BatchGetByOperationID gets state changes for a single operation with pagination support,
// pinned to the parent operation's ledger_created_at for partition-column chunk exclusion.
func (m *StateChangeModel) BatchGetByOperationID(ctx context.Context, operationID int64, ledgerCreatedAt time.Time, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) {
columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "", stateChangeMandatoryColumns...)
var queryBuilder strings.Builder
fmt.Fprintf(&queryBuilder, `
SELECT %s, ledger_created_at as cursor_ledger_created_at, to_id as cursor_to_id, operation_id as cursor_operation_id, state_change_id as cursor_state_change_id
Expand Down Expand Up @@ -484,7 +477,7 @@ func (m *StateChangeModel) BatchGetAccountStateChangesByToIDs(ctx context.Contex
hi = t
}
}
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", stateChangeMandatoryColumns...)
query := fmt.Sprintf(`
SELECT %s
FROM state_changes sc
Expand Down Expand Up @@ -516,7 +509,7 @@ func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operation
if len(operationIDs) != len(ledgerCreatedAts) {
return nil, fmt.Errorf("operationIDs and ledgerCreatedAts must be parallel arrays of equal length, got %d and %d", len(operationIDs), len(ledgerCreatedAts))
}
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", stateChangeMandatoryColumns...)

// The ORDER BY + LIMIT live inside the LATERAL (a per-operation top-N), replacing the old
// ROW_NUMBER()-over-everything CTE with an equivalent per-operation cap; a subquery
Expand Down
13 changes: 6 additions & 7 deletions internal/data/statechanges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,9 @@ func generateTestStateChanges(n int, accountID string, startToID int64, auxAddre
TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+6)%len(auxAddresses)]), Valid: true},
Amount: sql.NullString{String: fmt.Sprintf("%d", (i+1)*100), Valid: true},
// NullAddressBytea fields
SignerAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[auxIdx]), Valid: true},
SpenderAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+1)%len(auxAddresses)]), Valid: true},
SponsoredAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+2)%len(auxAddresses)]), Valid: true},
SponsorAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+3)%len(auxAddresses)]), Valid: true},
DeployerAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+4)%len(auxAddresses)]), Valid: true},
FunderAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+5)%len(auxAddresses)]), Valid: true},
SignerAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[auxIdx]), Valid: true},
SpenderAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+1)%len(auxAddresses)]), Valid: true},
CreatorAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+4)%len(auxAddresses)]), Valid: true},
// Typed fields (previously JSONB)
SignerWeightOld: sql.NullInt16{Int16: int16(i % 256), Valid: true},
SignerWeightNew: sql.NullInt16{Int16: int16((i + 1) % 256), Valid: true},
Expand All @@ -57,7 +54,8 @@ func generateTestStateChanges(n int, accountID string, startToID int64, auxAddre
TrustlineLimitOld: sql.NullString{String: fmt.Sprintf("%d", i*1000), Valid: true},
TrustlineLimitNew: sql.NullString{String: fmt.Sprintf("%d", (i+1)*1000), Valid: true},
Flags: sql.NullInt16{Int16: 6, Valid: true}, // Bitmask for auth_required (2) | auth_revocable (4)
KeyValue: types.NullableJSONB{"key": fmt.Sprintf("data_key_%d", i), "value": fmt.Sprintf("data_value_%d", i)},
DataEntryName: sql.NullString{String: fmt.Sprintf("data_key_%d", i), Valid: true},
KeyValue: types.NullableJSONB{"old": fmt.Sprintf("old_value_%d", i), "new": fmt.Sprintf("new_value_%d", i)},
}
}

Expand Down Expand Up @@ -139,6 +137,7 @@ func TestStateChangeModel_BatchCopy(t *testing.T) {
OperationID: 789,
SignerWeightOld: sql.NullInt16{Int16: 0, Valid: true},
SignerWeightNew: sql.NullInt16{Int16: 10, Valid: true},
Threshold: sql.NullString{String: string(types.ThresholdLevelMedium), Valid: true},
ThresholdOld: sql.NullInt16{Int16: 1, Valid: true},
ThresholdNew: sql.NullInt16{Int16: 3, Valid: true},
}
Expand Down
16 changes: 6 additions & 10 deletions internal/db/migrations/2025-06-10.4-statechanges.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ CREATE TABLE state_changes (
state_change_category TEXT NOT NULL CHECK (
state_change_category IN (
'BALANCE', 'ACCOUNT', 'SIGNER', 'SIGNATURE_THRESHOLD',
'METADATA', 'FLAGS', 'TRUSTLINE', 'RESERVES',
'BALANCE_AUTHORIZATION', 'AUTHORIZATION'
'DATA_ENTRY', 'HOME_DOMAIN', 'ALLOWANCE', 'FLAGS', 'TRUSTLINE',
Comment thread
aditya1702 marked this conversation as resolved.
'BALANCE_AUTHORIZATION'
)
),
state_change_reason TEXT NOT NULL CHECK (
state_change_reason IN (
'CREATE', 'MERGE', 'DEBIT', 'CREDIT', 'MINT', 'BURN',
'ADD', 'REMOVE', 'UPDATE', 'LOW', 'MEDIUM', 'HIGH',
'HOME_DOMAIN', 'SET', 'CLEAR', 'DATA_ENTRY', 'SPONSOR', 'UNSPONSOR'
'ADD', 'REMOVE', 'UPDATE', 'SET', 'CLEAR'
)
),
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
Expand All @@ -27,21 +26,18 @@ CREATE TABLE state_changes (
amount TEXT,
signer_account_id BYTEA,
spender_account_id BYTEA,
sponsored_account_id BYTEA,
sponsor_account_id BYTEA,
deployer_account_id BYTEA,
funder_account_id BYTEA,
creator_account_id BYTEA,
destination_account_id BYTEA,
claimable_balance_id TEXT,
liquidity_pool_id TEXT,
sponsored_data TEXT,
signer_weight_old SMALLINT,
signer_weight_new SMALLINT,
threshold TEXT,
threshold_old SMALLINT,
threshold_new SMALLINT,
trustline_limit_old TEXT,
trustline_limit_new TEXT,
flags SMALLINT,
data_entry_name TEXT,
key_value JSONB,
to_muxed_id TEXT,
ledger_created_at TIMESTAMPTZ NOT NULL,
Expand Down
2 changes: 1 addition & 1 deletion internal/indexer/processors/contract_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func (p *ContractDeployProcessor) ProcessOperation(_ context.Context, op *Transa
}

seen[contractID] = struct{}{}
stateChanges = append(stateChanges, builder.Clone().WithAccount(contractID).WithDeployer(deployerAddr).Build())
stateChanges = append(stateChanges, builder.Clone().WithAccount(contractID).WithCreator(deployerAddr).Build())
return nil
}

Expand Down
Loading
Loading