Skip to content

indexer: identify DEPLOY state changes #252

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
18 changes: 12 additions & 6 deletions internal/data/statechanges.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (m *StateChangeModel) BatchInsert(
spenderAccountIDs := make([]*string, len(stateChanges))
sponsoredAccountIDs := make([]*string, len(stateChanges))
sponsorAccountIDs := make([]*string, len(stateChanges))
deployerAccountIDs := make([]*string, len(stateChanges))
signerWeights := make([]*types.NullableJSONB, len(stateChanges))
thresholds := make([]*types.NullableJSONB, len(stateChanges))
flags := make([]*types.NullableJSON, len(stateChanges))
Expand Down Expand Up @@ -90,6 +91,9 @@ func (m *StateChangeModel) BatchInsert(
if sc.SponsorAccountID.Valid {
sponsorAccountIDs[i] = &sc.SponsorAccountID.String
}
if sc.DeployerAccountID.Valid {
deployerAccountIDs[i] = &sc.DeployerAccountID.String
}
if sc.SignerWeights != nil {
signerWeights[i] = &sc.SignerWeights
}
Expand Down Expand Up @@ -130,10 +134,11 @@ func (m *StateChangeModel) BatchInsert(
UNNEST($15::text[]) AS spender_account_id,
UNNEST($16::text[]) AS sponsored_account_id,
UNNEST($17::text[]) AS sponsor_account_id,
UNNEST($18::jsonb[]) AS signer_weights,
UNNEST($19::jsonb[]) AS thresholds,
UNNEST($20::jsonb[]) AS flags,
UNNEST($21::jsonb[]) AS key_value
UNNEST($18::text[]) AS deployer_account_id,
UNNEST($19::jsonb[]) AS signer_weights,
UNNEST($20::jsonb[]) AS thresholds,
UNNEST($21::jsonb[]) AS flags,
UNNEST($22::jsonb[]) AS key_value
),

-- STEP 3: Get state changes that reference existing accounts
Expand All @@ -150,13 +155,13 @@ func (m *StateChangeModel) BatchInsert(
ledger_number, account_id, operation_id, tx_hash, token_id, amount,
claimable_balance_id, liquidity_pool_id, offer_id, signer_account_id,
spender_account_id, sponsored_account_id, sponsor_account_id,
signer_weights, thresholds, flags, key_value)
deployer_account_id, signer_weights, thresholds, flags, key_value)
SELECT
id, state_change_category, state_change_reason, ledger_created_at,
ledger_number, account_id, operation_id, tx_hash, token_id, amount,
claimable_balance_id, liquidity_pool_id, offer_id, signer_account_id,
spender_account_id, sponsored_account_id, sponsor_account_id,
signer_weights, thresholds, flags, key_value
deployer_account_id, signer_weights, thresholds, flags, key_value
FROM valid_state_changes
ON CONFLICT (id) DO NOTHING
RETURNING id
Expand Down Expand Up @@ -185,6 +190,7 @@ func (m *StateChangeModel) BatchInsert(
pq.Array(spenderAccountIDs),
pq.Array(sponsoredAccountIDs),
pq.Array(sponsorAccountIDs),
pq.Array(deployerAccountIDs),
pq.Array(signerWeights),
pq.Array(thresholds),
pq.Array(flags),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ CREATE TABLE state_changes (
spender_account_id TEXT,
sponsored_account_id TEXT,
sponsor_account_id TEXT,
deployer_account_id TEXT,
thresholds JSONB
);

Expand Down
33 changes: 24 additions & 9 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package indexer

import (
"context"
"errors"
"fmt"

set "github.com/deckarep/golang-set/v2"
"github.com/stellar/go/ingest"
operation_processor "github.com/stellar/go/processors/operation"

"github.com/stellar/wallet-backend/internal/indexer/processors"
"github.com/stellar/wallet-backend/internal/indexer/types"
Expand All @@ -32,19 +34,25 @@ type ParticipantsProcessorInterface interface {
GetOperationsParticipants(transaction ingest.LedgerTransaction) (map[int64]processors.OperationParticipants, error)
}

type OperationProcessorInterface interface {
ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error)
}

type Indexer struct {
Buffer IndexerBufferInterface
participantsProcessor ParticipantsProcessorInterface
tokenTransferProcessor TokenTransferProcessorInterface
effectsProcessor EffectsProcessorInterface
Buffer IndexerBufferInterface
participantsProcessor ParticipantsProcessorInterface
tokenTransferProcessor TokenTransferProcessorInterface
effectsProcessor OperationProcessorInterface
contractDeployProcessor OperationProcessorInterface
}

func NewIndexer(networkPassphrase string) *Indexer {
return &Indexer{
Buffer: NewIndexerBuffer(),
participantsProcessor: processors.NewParticipantsProcessor(networkPassphrase),
tokenTransferProcessor: processors.NewTokenTransferProcessor(networkPassphrase),
effectsProcessor: processors.NewEffectsProcessor(networkPassphrase),
Buffer: NewIndexerBuffer(),
participantsProcessor: processors.NewParticipantsProcessor(networkPassphrase),
tokenTransferProcessor: processors.NewTokenTransferProcessor(networkPassphrase),
effectsProcessor: processors.NewEffectsProcessor(networkPassphrase),
contractDeployProcessor: processors.NewContractDeployProcessor(networkPassphrase),
}
}

Expand All @@ -71,7 +79,7 @@ func (i *Indexer) ProcessTransaction(ctx context.Context, transaction ingest.Led
return fmt.Errorf("getting operations participants: %w", err)
}
var dataOp *types.Operation
var effectsStateChanges []types.StateChange
var effectsStateChanges, contractDeployStateChanges []types.StateChange
for opID, opParticipants := range opsParticipants {
dataOp, err = processors.ConvertOperation(&transaction, &opParticipants.OpWrapper.Operation, opID)
if err != nil {
Expand All @@ -88,6 +96,13 @@ func (i *Indexer) ProcessTransaction(ctx context.Context, transaction ingest.Led
return fmt.Errorf("processing effects state changes: %w", err)
}
i.Buffer.PushStateChanges(effectsStateChanges)

// 2.2. Index contract deploy state changes
contractDeployStateChanges, err = i.contractDeployProcessor.ProcessOperation(ctx, opParticipants.OpWrapper)
Copy link
Contributor

Choose a reason for hiding this comment

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

Small design idea: since we are using the same interface here, we can instead have a list with these two processors and use a for loop to push the state changes. So something like this:

operationProcessors []OperationProcessorInterface

for _, processor: range b.operationProcessors:
     stateChanges, err := processor.ProcesssOperation...
     ....
     i.Buffer.PushStateChanges(stateChanges)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I agree with that change but I think it's outside the scope of this PR. It should be done in a dedicated PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I opened a PR here, but in reality this won't scale for more custom contract processors.

This discussion points to the necessity of calling RPC to assert contract interfaces and likely adding a special cache for contracts. This cache can expand a lot and we'll probably need some space-efficient filter, like BloomFilter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm leaving that PR in draft. Feel free to either close it or build on top of it.

if err != nil && !errors.Is(err, processors.ErrInvalidOpType) {
return fmt.Errorf("processing contract deploy state changes: %w", err)
}
i.Buffer.PushStateChanges(contractDeployStateChanges)
}

// 3. Index token transfer state changes
Expand Down
39 changes: 24 additions & 15 deletions internal/indexer/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,14 @@ var (
func TestIndexer_ProcessTransaction(t *testing.T) {
tests := []struct {
name string
setupMocks func(*MockParticipantsProcessor, *MockTokenTransferProcessor, *MockEffectsProcessor, *MockIndexerBuffer)
setupMocks func(*MockParticipantsProcessor, *MockTokenTransferProcessor, *MockOperationProcessor, *MockOperationProcessor, *MockIndexerBuffer)
wantError string
txParticipants set.Set[string]
opsParticipants map[int64]processors.OperationParticipants
}{
{
name: "🟢 successful processing with participants",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
participants := set.NewSet("alice", "bob")
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil)

Expand All @@ -116,7 +116,10 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
mockTokenTransfer.On("ProcessTransaction", mock.Anything, mock.Anything).Return(tokenStateChanges, nil)

effectsStateChanges := []types.StateChange{{ID: "effects_sc1"}}
mockEffects.On("ProcessOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(effectsStateChanges, nil)
mockEffects.On("ProcessOperation", mock.Anything, mock.Anything).Return(effectsStateChanges, nil)

contractDeployStateChanges := []types.StateChange{{ID: "contract_deploy_sc1"}}
mockContractDeploy.On("ProcessOperation", mock.Anything, mock.Anything).Return(contractDeployStateChanges, nil)

// Verify transaction was pushed to buffer with correct participants
// PushParticipantTransaction is called once for each participant
Expand Down Expand Up @@ -149,6 +152,10 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
mock.MatchedBy(func(stateChanges []types.StateChange) bool {
return len(stateChanges) == 1 && stateChanges[0].ID == "token_sc1"
})).Return()
mockBuffer.On("PushStateChanges",
mock.MatchedBy(func(stateChanges []types.StateChange) bool {
return len(stateChanges) == 1 && stateChanges[0].ID == "contract_deploy_sc1"
})).Return()
},
txParticipants: set.NewSet("alice", "bob"),
opsParticipants: map[int64]processors.OperationParticipants{
Expand All @@ -165,7 +172,7 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
},
{
name: "🟢 successful processing without participants",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
participants := set.NewSet[string]()
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil)

Expand All @@ -187,14 +194,14 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
},
{
name: "🔴 error getting transaction participants",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(set.NewSet[string](), errors.New("participant error"))
},
wantError: "getting transaction participants: participant error",
},
{
name: "🔴 error getting operations participants",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
participants := set.NewSet[string]()
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil)
mockParticipants.On("GetOperationsParticipants", mock.Anything).Return(map[int64]processors.OperationParticipants{}, errors.New("operations error"))
Expand All @@ -203,7 +210,7 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
},
{
name: "🔴 error processing effects state changes",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
participants := set.NewSet[string]()
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil)

Expand All @@ -221,13 +228,13 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
mockParticipants.On("GetOperationsParticipants", mock.Anything).Return(opParticipants, nil)

mockBuffer.On("PushParticipantOperation", mock.Anything, mock.Anything, mock.Anything).Return()
mockEffects.On("ProcessOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]types.StateChange{}, errors.New("effects error"))
mockEffects.On("ProcessOperation", mock.Anything, mock.Anything).Return([]types.StateChange{}, errors.New("effects error"))
},
wantError: "processing effects state changes: effects error",
},
{
name: "🔴 error processing token transfer state changes",
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) {
setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockOperationProcessor, mockContractDeploy *MockOperationProcessor, mockBuffer *MockIndexerBuffer) {
participants := set.NewSet[string]()
mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil)

Expand All @@ -245,18 +252,20 @@ func TestIndexer_ProcessTransaction(t *testing.T) {
// Create mocks
mockParticipants := &MockParticipantsProcessor{}
mockTokenTransfer := &MockTokenTransferProcessor{}
mockEffects := &MockEffectsProcessor{}
mockEffects := &MockOperationProcessor{}
mockContractDeploy := &MockOperationProcessor{}
mockBuffer := &MockIndexerBuffer{}

// Setup mock expectations
tt.setupMocks(mockParticipants, mockTokenTransfer, mockEffects, mockBuffer)
tt.setupMocks(mockParticipants, mockTokenTransfer, mockEffects, mockContractDeploy, mockBuffer)

// Create testable indexer with mocked dependencies
indexer := &Indexer{
Buffer: mockBuffer,
participantsProcessor: mockParticipants,
tokenTransferProcessor: mockTokenTransfer,
effectsProcessor: mockEffects,
Buffer: mockBuffer,
participantsProcessor: mockParticipants,
tokenTransferProcessor: mockTokenTransfer,
effectsProcessor: mockEffects,
contractDeployProcessor: mockContractDeploy,
}

err := indexer.ProcessTransaction(context.Background(), testTx)
Expand Down
10 changes: 3 additions & 7 deletions internal/indexer/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import (
"github.com/stellar/wallet-backend/internal/indexer/types"
)

type EffectsProcessorInterface interface {
ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error)
}

// Mock implementations for testing
type MockParticipantsProcessor struct {
mock.Mock
Expand All @@ -40,11 +36,11 @@ func (m *MockTokenTransferProcessor) ProcessTransaction(ctx context.Context, tx
return args.Get(0).([]types.StateChange), args.Error(1)
}

type MockEffectsProcessor struct {
type MockOperationProcessor struct {
mock.Mock
}

func (m *MockEffectsProcessor) ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) {
func (m *MockOperationProcessor) ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) {
args := m.Called(ctx, opWrapper)
return args.Get(0).([]types.StateChange), args.Error(1)
}
Expand Down Expand Up @@ -99,5 +95,5 @@ var (
_ IndexerBufferInterface = &MockIndexerBuffer{}
_ ParticipantsProcessorInterface = &MockParticipantsProcessor{}
_ TokenTransferProcessorInterface = &MockTokenTransferProcessor{}
_ EffectsProcessorInterface = &MockEffectsProcessor{}
_ OperationProcessorInterface = &MockOperationProcessor{}
)
110 changes: 110 additions & 0 deletions internal/indexer/processors/contract_deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package processors

import (
"context"
"fmt"

operation_processor "github.com/stellar/go/processors/operation"
"github.com/stellar/go/xdr"

"github.com/stellar/wallet-backend/internal/indexer/types"
)

// ContractDeployProcessor emits state changes for contract deployments.
type ContractDeployProcessor struct {
networkPassphrase string
}

func NewContractDeployProcessor(networkPassphrase string) *ContractDeployProcessor {
return &ContractDeployProcessor{networkPassphrase: networkPassphrase}
}

// ProcessOperation emits a state change for each contract deployment (including subinvocations).
func (p *ContractDeployProcessor) ProcessOperation(_ context.Context, op *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) {
if op.OperationType() != xdr.OperationTypeInvokeHostFunction {
return nil, ErrInvalidOpType
}
invokeHostOp := op.Operation.Body.MustInvokeHostFunctionOp()

opID := op.ID()
builder := NewStateChangeBuilder(op.Transaction.Ledger.LedgerSequence(), op.LedgerClosed.Unix(), op.Transaction.Hash.HexString()).
WithOperationID(opID).
WithCategory(types.StateChangeCategoryContract).
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know if "contract" is the best category we can use. We're only indexing contract deployments for contracts that are registered as accounts, right? Meaning, the flow would be:

  • wallet generates the contract ID using the wasm/network/salt
  • wallet registers the contract ID with the wallet backend
  • wallet deploys the contract

This to me seems like the contract version of a CreateAccount operation. Which, speaking of which, I don't think we have a state change for an account being created, right? Should we rename this so that creation events for both stellar accounts and contracts result in the same state change? Of course, stellar accounts being created will also result in a credit XLM state change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Screenshot 2025-07-23 at 5 07 35 PM

(G) Account creation is generating these state changes, but for contracts there's no concept of DEBIT/CREDIT in play, it's just fees and deployer so I think the states being changed are different even though they can both be interpreted as account creation.

I'm still inclined to keep some differentiation in G-account and C-account creation because the nature of the accounts are different.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, well even if we keep a “Contract”/“Deploy” classification/reason for contract account creation, we don’t have a state change for stellar account creation. Meaning, a client querying for state changes wouldn’t be able to tell when the account was created. They’d have to inspect the operation or transaction. If we have a state change representing account creation for contracts, I think we should have one for classic accounts. Wdyt?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, that makes sense to me 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In which case, we probably can create a new ACCOUNT_CREATION category that applies for both, and then we use that instead of the DEPLOY

Copy link
Contributor

Choose a reason for hiding this comment

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

SGTM!

WithReason(types.StateChangeReasonDeploy)

deployedContractsMap := map[string]types.StateChange{}

processCreate := func(fromAddr xdr.ContractIdPreimageFromAddress) error {
contractID, err := calculateContractID(p.networkPassphrase, fromAddr)
if err != nil {
return fmt.Errorf("calculating contract ID: %w", err)
}
deployerAddr, err := fromAddr.Address.String()
if err != nil {
return fmt.Errorf("deployer address to string: %w", err)
}

deployedContractsMap[contractID] = builder.Clone().WithAccount(contractID).WithDeployer(deployerAddr).Build()
return nil
}

var walkInvocation func(inv xdr.SorobanAuthorizedInvocation) error
walkInvocation = func(inv xdr.SorobanAuthorizedInvocation) error {
switch inv.Function.Type {
case xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeCreateContractHostFn:
cc := inv.Function.MustCreateContractHostFn()
if cc.ContractIdPreimage.Type == xdr.ContractIdPreimageTypeContractIdPreimageFromAddress {
if err := processCreate(cc.ContractIdPreimage.MustFromAddress()); err != nil {
return err
}
}
case xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeCreateContractV2HostFn:
cc := inv.Function.MustCreateContractV2HostFn()
if cc.ContractIdPreimage.Type == xdr.ContractIdPreimageTypeContractIdPreimageFromAddress {
if err := processCreate(cc.ContractIdPreimage.MustFromAddress()); err != nil {
return err
}
}
case xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeContractFn:
// no-op
}
for _, sub := range inv.SubInvocations {
if err := walkInvocation(sub); err != nil {
return err
}
}
return nil
}

hf := invokeHostOp.HostFunction
switch hf.Type {
case xdr.HostFunctionTypeHostFunctionTypeCreateContract:
cc := hf.MustCreateContract()
if cc.ContractIdPreimage.Type == xdr.ContractIdPreimageTypeContractIdPreimageFromAddress {
if err := processCreate(cc.ContractIdPreimage.MustFromAddress()); err != nil {
return nil, err
}
}
case xdr.HostFunctionTypeHostFunctionTypeCreateContractV2:
cc := hf.MustCreateContractV2()
if cc.ContractIdPreimage.Type == xdr.ContractIdPreimageTypeContractIdPreimageFromAddress {
if err := processCreate(cc.ContractIdPreimage.MustFromAddress()); err != nil {
return nil, err
}
}
case xdr.HostFunctionTypeHostFunctionTypeUploadContractWasm, xdr.HostFunctionTypeHostFunctionTypeInvokeContract:
// no-op
}

for _, auth := range invokeHostOp.Auth {
if err := walkInvocation(auth.RootInvocation); err != nil {
return nil, err
}
}

stateChanges := make([]types.StateChange, 0, len(deployedContractsMap))
for _, sc := range deployedContractsMap {
stateChanges = append(stateChanges, sc)
}
return stateChanges, nil
}
Loading