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, 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{ 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, 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, } 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) } 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()) }) } 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) } } 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(), diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index ae6d2344..07ec54c6 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -563,9 +563,9 @@ 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"` - Amount sql.NullString `json:"amount,omitempty" db:"amount"` + // 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): SignerAccountID NullAddressBytea `json:"signerAccountId,omitempty" db:"signer_account_id"` 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. 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)