From 39b68c502cf90f14d030e151d958709f162bb3f3 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:40:12 -0500 Subject: [PATCH 01/12] Change token_id column from TEXT to BYTEA in state_changes migration Brings token_id storage in line with all other address columns that already use BYTEA. Not in production so the migration is modified directly. --- internal/db/migrations/2025-06-10.4-statechanges.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/db/migrations/2025-06-10.4-statechanges.sql b/internal/db/migrations/2025-06-10.4-statechanges.sql index eb41b37a..22d85998 100644 --- a/internal/db/migrations/2025-06-10.4-statechanges.sql +++ b/internal/db/migrations/2025-06-10.4-statechanges.sql @@ -23,7 +23,7 @@ CREATE TABLE state_changes ( ledger_number INTEGER NOT NULL, account_id BYTEA NOT NULL, operation_id BIGINT NOT NULL, - token_id TEXT, + token_id BYTEA, amount TEXT, signer_account_id BYTEA, spender_account_id BYTEA, From 3121832492b5f739b53e2c339bd4422c062b5939 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:40:33 -0500 Subject: [PATCH 02/12] Change StateChange.TokenID type from sql.NullString to NullAddressBytea Matches the BYTEA column type in the migration and is consistent with all other address fields in the struct. --- internal/indexer/types/types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index ae6d2344..a78e6b13 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -563,8 +563,8 @@ type StateChange struct { LedgerCreatedAt time.Time `json:"ledgerCreatedAt,omitempty" db:"ledger_created_at"` LedgerNumber uint32 `json:"ledgerNumber,omitempty" db:"ledger_number"` - // Nullable string fields: - TokenID sql.NullString `json:"tokenId,omitempty" db:"token_id"` + // Nullable address fields (stored as BYTEA in database): + TokenID NullAddressBytea `json:"tokenId,omitempty" db:"token_id"` Amount sql.NullString `json:"amount,omitempty" db:"amount"` // Nullable address fields (stored as BYTEA in database): From 52ee484e6038e40ddc19611d8b228adcc9c46e37 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:40:44 -0500 Subject: [PATCH 03/12] Update StateChangeBuilder for NullAddressBytea TokenID WithToken now uses utils.NullAddressBytea and generateSortKey uses the String() method instead of the .String field. --- internal/indexer/processors/state_change_builder.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/indexer/processors/state_change_builder.go b/internal/indexer/processors/state_change_builder.go index a6b6e26d..8fc77cc7 100644 --- a/internal/indexer/processors/state_change_builder.go +++ b/internal/indexer/processors/state_change_builder.go @@ -124,7 +124,7 @@ func (b *StateChangeBuilder) WithAmount(amount string) *StateChangeBuilder { // WithToken sets the token ID using the contract address func (b *StateChangeBuilder) WithToken(contractAddress string) *StateChangeBuilder { - b.base.TokenID = utils.SQLNullString(contractAddress) + b.base.TokenID = utils.NullAddressBytea(contractAddress) return b } @@ -190,7 +190,7 @@ func (b *StateChangeBuilder) generateSortKey() string { b.base.StateChangeCategory, reason, b.base.AccountID, - b.base.TokenID.String, + b.base.TokenID.String(), b.base.Amount.String, b.base.SignerAccountID.String(), b.base.SpenderAccountID.String(), From b2d0a819eab4d93fc124c8a241002795bacbc4b3 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:40:54 -0500 Subject: [PATCH 04/12] Update indexer to use TokenID.String() method Matches the NullAddressBytea type change for TokenID. --- internal/indexer/indexer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 4adad122..fe21ed04 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -253,7 +253,7 @@ func (i *Indexer) processTransaction(ctx context.Context, tx ingest.LedgerTransa contractChange := types.ContractChange{ AccountID: string(stateChange.AccountID), OperationID: stateChange.OperationID, - ContractID: stateChange.TokenID.String, + ContractID: stateChange.TokenID.String(), LedgerNumber: tx.Ledger.LedgerSequence(), ContractType: stateChange.ContractType, } From 15b03dfb9394966ecc93d96caa9d9823eb089a57 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:41:25 -0500 Subject: [PATCH 05/12] Update statechanges data layer for BYTEA token_id BatchInsert: use []byte slice and bytea[] SQL cast instead of text[]. BatchCopy: convert TokenID via pgtypeBytesFromNullAddressBytea instead of pgtypeTextFromNullString. --- internal/data/statechanges.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index ae0aee54..89cc9036 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -186,7 +186,7 @@ func (m *StateChangeModel) BatchInsert( ledgerNumbers := make([]int, len(stateChanges)) accountIDBytes := make([][]byte, len(stateChanges)) operationIDs := make([]int64, len(stateChanges)) - tokenIDs := make([]*string, len(stateChanges)) + tokenIDBytes := make([][]byte, len(stateChanges)) amounts := make([]*string, len(stateChanges)) signerAccountIDBytes := make([][]byte, len(stateChanges)) spenderAccountIDBytes := make([][]byte, len(stateChanges)) @@ -226,8 +226,9 @@ func (m *StateChangeModel) BatchInsert( reason := string(*sc.StateChangeReason) reasons[i] = &reason } - if sc.TokenID.Valid { - tokenIDs[i] = &sc.TokenID.String + tokenIDBytes[i], err = pgtypeBytesFromNullAddressBytea(sc.TokenID) + if err != nil { + return nil, fmt.Errorf("converting token_id: %w", err) } if sc.Amount.Valid { amounts[i] = &sc.Amount.String @@ -305,7 +306,7 @@ func (m *StateChangeModel) BatchInsert( UNNEST($6::integer[]) AS ledger_number, UNNEST($7::bytea[]) AS account_id, UNNEST($8::bigint[]) AS operation_id, - UNNEST($9::text[]) AS token_id, + UNNEST($9::bytea[]) AS token_id, UNNEST($10::text[]) AS amount, UNNEST($11::bytea[]) AS signer_account_id, UNNEST($12::bytea[]) AS spender_account_id, @@ -358,7 +359,7 @@ func (m *StateChangeModel) BatchInsert( pq.Array(ledgerNumbers), pq.Array(accountIDBytes), pq.Array(operationIDs), - pq.Array(tokenIDs), + pq.Array(tokenIDBytes), pq.Array(amounts), pq.Array(signerAccountIDBytes), pq.Array(spenderAccountIDBytes), @@ -456,6 +457,10 @@ func (m *StateChangeModel) BatchCopy( if err != nil { return nil, fmt.Errorf("converting funder_account_id: %w", err) } + tokenBytes, err := pgtypeBytesFromNullAddressBytea(sc.TokenID) + if err != nil { + return nil, fmt.Errorf("converting token_id: %w", err) + } return []any{ pgtype.Int8{Int64: sc.ToID, Valid: true}, @@ -466,7 +471,7 @@ func (m *StateChangeModel) BatchCopy( pgtype.Int4{Int32: int32(sc.LedgerNumber), Valid: true}, accountBytes, pgtype.Int8{Int64: sc.OperationID, Valid: true}, - pgtypeTextFromNullString(sc.TokenID), + tokenBytes, pgtypeTextFromNullString(sc.Amount), signerBytes, spenderBytes, From 979d422bcbba1c59672e1ed4be2fb2403a47f8c7 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:41:45 -0500 Subject: [PATCH 06/12] Update GraphQL resolvers for NullAddressBytea TokenID Use resolveNullableAddress for nullable TokenID fields and inline String() for the required StandardBalanceChange.tokenId field. --- internal/serve/graphql/resolvers/statechange.resolvers.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go index 9583c2b9..c32d8125 100644 --- a/internal/serve/graphql/resolvers/statechange.resolvers.go +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -69,7 +69,7 @@ func (r *balanceAuthorizationChangeResolver) Transaction(ctx context.Context, ob // TokenID is the resolver for the tokenId field. func (r *balanceAuthorizationChangeResolver) TokenID(ctx context.Context, obj *types.BalanceAuthorizationStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // LiquidityPoolID is the resolver for the liquidityPoolId field. @@ -197,7 +197,7 @@ func (r *reservesChangeResolver) ClaimableBalanceID(ctx context.Context, obj *ty // SponsoredTrustline is the resolver for the sponsoredTrustline field. func (r *reservesChangeResolver) SponsoredTrustline(ctx context.Context, obj *types.ReservesStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // SponsoredData is the resolver for the sponsoredData field. @@ -306,7 +306,7 @@ func (r *standardBalanceChangeResolver) Transaction(ctx context.Context, obj *ty // TokenID is the resolver for the tokenId field. func (r *standardBalanceChangeResolver) TokenID(ctx context.Context, obj *types.StandardBalanceStateChangeModel) (string, error) { - return r.resolveRequiredString(obj.TokenID), nil + return obj.TokenID.String(), nil } // Amount is the resolver for the amount field. @@ -341,7 +341,7 @@ func (r *trustlineChangeResolver) Transaction(ctx context.Context, obj *types.Tr // TokenID is the resolver for the tokenId field. func (r *trustlineChangeResolver) TokenID(ctx context.Context, obj *types.TrustlineStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // Limit is the resolver for the limit field. From 349143cc25642f0ed7bd59cbe07e1284618b9683 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:42:04 -0500 Subject: [PATCH 07/12] Update resolver tests for NullAddressBytea TokenID Use a valid Stellar contract address instead of a plain string. --- .../serve/graphql/resolvers/statechange_resolvers_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 8076e5ac..54ac4509 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -25,14 +25,14 @@ func TestStateChangeResolver_NullableStringFields(t *testing.T) { t.Run("all valid", func(t *testing.T) { obj := &types.StandardBalanceStateChangeModel{ StateChange: types.StateChange{ - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(MainnetNativeContractAddress), Valid: true}, Amount: sql.NullString{String: "100.5", Valid: true}, }, } tokenID, err := resolver.TokenID(ctx, obj) require.NoError(t, err) - assert.Equal(t, "token1", tokenID) + assert.Equal(t, MainnetNativeContractAddress, tokenID) amount, err := resolver.Amount(ctx, obj) require.NoError(t, err) From d20dcdc80d2fcc3d3d0bff3c52deef6813bdc084 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:42:46 -0500 Subject: [PATCH 08/12] Update statechanges tests for NullAddressBytea TokenID Replace sql.NullString with NullAddressBytea using valid Stellar addresses in generateTestStateChanges, BatchInsert, and BatchCopy tests. --- internal/data/statechanges_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 8267b5fa..b0bc2020 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -40,8 +40,8 @@ func generateTestStateChanges(n int, accountID string, startToID int64, auxAddre LedgerNumber: uint32(i + 1), AccountID: types.AddressBytea(accountID), OperationID: int64(i + 1), - // sql.NullString fields - TokenID: sql.NullString{String: fmt.Sprintf("token_%d", i), Valid: true}, + // NullAddressBytea token field + 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}, @@ -126,7 +126,7 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { LedgerNumber: 1, AccountID: types.AddressBytea(kp1.Address()), OperationID: 123, - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(kp1.Address()), Valid: true}, Amount: sql.NullString{String: "100", Valid: true}, } sc2 := types.StateChange{ @@ -282,7 +282,7 @@ func TestStateChangeModel_BatchCopy(t *testing.T) { LedgerNumber: 1, AccountID: types.AddressBytea(kp1.Address()), OperationID: 123, - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(kp1.Address()), Valid: true}, Amount: sql.NullString{String: "100", Valid: true}, } sc2 := types.StateChange{ From 3051d591e01b8bccfe3ee6eac9683b61e264cc2a Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:42:57 -0500 Subject: [PATCH 09/12] Update effects tests for NullAddressBytea TokenID Use .String() method instead of .String field access. --- internal/indexer/processors/effects_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/indexer/processors/effects_test.go b/internal/indexer/processors/effects_test.go index 335b7903..1749a8d0 100644 --- a/internal/indexer/processors/effects_test.go +++ b/internal/indexer/processors/effects_test.go @@ -376,7 +376,7 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("TEST", "GBNOOJYISY7Y5IKJFDOGDTVQMPO6DZ46SCS64O2IB4NSCAMXGCKOLORN") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) }) t.Run("ChangeTrust - trustline updated", func(t *testing.T) { envelopeXDR := "AAAAAHHbEhVipyZ2k4byyCZkS1Bdvpj7faBChuYo8S/Rt89UAAAAZAAQuJIAAAAHAAAAAQAAAAAAAAAAAAAAAF4XVskAAAAAAAAAAQAAAAAAAAAGAAAAAlRFU1RBU1NFVAAAAAAAAAA7JUkkD+tgCi2xTVyEcs4WZXOA0l7w2orZg/bghXOgkAAAAAA7msoAAAAAAAAAAAHRt89UAAAAQOCi2ylqRvvRzZaCFjGkLYFk7DCjJA5uZ1nXo8FaPCRl2LZczoMbc46sZIlHh0ENzk7fKjFnRPMo8XAirrrf2go=" @@ -413,7 +413,7 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("TESTASSET", "GA5SKSJEB7VWACRNWFGVZBDSZYLGK44A2JPPBWUK3GB7NYEFOOQJAC2B") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) assert.Equal(t, "1000000000", changes[0].TrustlineLimitOld.String) assert.Equal(t, "100.0000000", changes[0].TrustlineLimitNew.String) }) @@ -452,6 +452,6 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("OCIToken", "GBE4L76HUCHCQ2B7IIWBXRAJDBDPIY6MGWX7VZHUZD2N5RO7XI4J6GTJ") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) }) } From 5a0f13401da4281bfc4058f29ffefff9f427e403 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:43:05 -0500 Subject: [PATCH 10/12] Update processors test utils for NullAddressBytea TokenID Use utils.NullAddressBytea instead of utils.SQLNullString for TokenID assertion. --- internal/indexer/processors/processors_test_utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/indexer/processors/processors_test_utils.go b/internal/indexer/processors/processors_test_utils.go index a7e7c014..d2ebbd32 100644 --- a/internal/indexer/processors/processors_test_utils.go +++ b/internal/indexer/processors/processors_test_utils.go @@ -811,7 +811,7 @@ func assertStateChangeBase(t *testing.T, change types.StateChange, category type require.Equal(t, utils.SQLNullString(expectedAmount), change.Amount) } if expectedToken != "" { - require.Equal(t, utils.SQLNullString(expectedToken), change.TokenID) + require.Equal(t, utils.NullAddressBytea(expectedToken), change.TokenID) } } From 67232ae5b8ab9f1924b9667e5b34ab4440bad87b Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:43:15 -0500 Subject: [PATCH 11/12] Update contracts test utils for NullAddressBytea TokenID Use .String() method instead of .String field access. --- internal/indexer/processors/contracts/test_utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/indexer/processors/contracts/test_utils.go b/internal/indexer/processors/contracts/test_utils.go index d6ca9c03..3b26284f 100644 --- a/internal/indexer/processors/contracts/test_utils.go +++ b/internal/indexer/processors/contracts/test_utils.go @@ -628,7 +628,7 @@ func assertContractEvent(t *testing.T, change types.StateChange, reason types.St require.Equal(t, expectedAccount, change.AccountID.String()) if expectedContractID != "" { require.NotNil(t, change.TokenID) - require.Equal(t, expectedContractID, change.TokenID.String) + require.Equal(t, expectedContractID, change.TokenID.String()) } require.Equal(t, reason, *change.StateChangeReason) } From 6c80306e5644e3739079c5bbd9c35efd7a6c15dd Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Sat, 21 Feb 2026 15:46:26 -0500 Subject: [PATCH 12/12] make check --- internal/indexer/types/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index a78e6b13..07ec54c6 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -565,7 +565,7 @@ type StateChange struct { // Nullable address fields (stored as BYTEA in database): TokenID NullAddressBytea `json:"tokenId,omitempty" db:"token_id"` - Amount sql.NullString `json:"amount,omitempty" db:"amount"` + Amount sql.NullString `json:"amount,omitempty" db:"amount"` // Nullable address fields (stored as BYTEA in database): SignerAccountID NullAddressBytea `json:"signerAccountId,omitempty" db:"signer_account_id"`