diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 9ff69ca3..1daa4c0b 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -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)) @@ -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 } @@ -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 @@ -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 @@ -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), diff --git a/internal/db/migrations/2025-06-10.4-create_indexer_table_state_changes.sql b/internal/db/migrations/2025-06-10.4-create_indexer_table_state_changes.sql index 916704eb..915b784d 100644 --- a/internal/db/migrations/2025-06-10.4-create_indexer_table_state_changes.sql +++ b/internal/db/migrations/2025-06-10.4-create_indexer_table_state_changes.sql @@ -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 ); diff --git a/internal/indexer/bulk_operation_processor.go b/internal/indexer/bulk_operation_processor.go new file mode 100644 index 00000000..dbee241d --- /dev/null +++ b/internal/indexer/bulk_operation_processor.go @@ -0,0 +1,49 @@ +package indexer + +import ( + "context" + "errors" + "fmt" + + operation_processor "github.com/stellar/go/processors/operation" + + "github.com/stellar/wallet-backend/internal/indexer/processors" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +// BulkOperationProcessor combines multiple OperationProcessorInterface instances +// and processes operations through all of them, collecting their results. +type BulkOperationProcessor struct { + processors []OperationStateChangeProcessorInterface +} + +// NewBulkOperationProcessor creates a new bulk processor with the given processors. +func NewBulkOperationProcessor(processors ...OperationStateChangeProcessorInterface) *BulkOperationProcessor { + return &BulkOperationProcessor{ + processors: processors, + } +} + +// ProcessOperation processes the operation through all child processors and combines their results. +func (b *BulkOperationProcessor) ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) { + stateChangesMap := map[string]types.StateChange{} + + for _, processor := range b.processors { + stateChanges, err := processor.ProcessOperation(ctx, opWrapper) + if err != nil && !errors.Is(err, processors.ErrInvalidOpType) { + return nil, fmt.Errorf("processor %T failed: %w", processor, err) + } else if err != nil { + continue + } + + for _, stateChange := range stateChanges { + stateChangesMap[stateChange.ID] = stateChange + } + } + + stateChanges := make([]types.StateChange, 0, len(stateChangesMap)) + for _, stateChange := range stateChangesMap { + stateChanges = append(stateChanges, stateChange) + } + return stateChanges, nil +} diff --git a/internal/indexer/bulk_operation_processor_test.go b/internal/indexer/bulk_operation_processor_test.go new file mode 100644 index 00000000..fd2b1327 --- /dev/null +++ b/internal/indexer/bulk_operation_processor_test.go @@ -0,0 +1,165 @@ +package indexer + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stellar/go/network" + operation_processor "github.com/stellar/go/processors/operation" + "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/indexer/processors" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +func Test_BulkOperationProcessor_ProcessOperation(t *testing.T) { + ctx := context.Background() + testOp := &operation_processor.TransactionOperationWrapper{ + Network: network.TestNetworkPassphrase, + LedgerClosed: time.Now(), + Operation: xdr.Operation{ + Body: xdr.OperationBody{Type: xdr.OperationTypePayment}, + }, + } + + type testCase struct { + name string + numProcessors int + prepareMocks func(t *testing.T, processors []OperationStateChangeProcessorInterface) + wantErrContains string + wantResult []types.StateChange + } + + testCases := []testCase{ + { + name: "🟢successful_processing_with_multiple_processors", + numProcessors: 2, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock1 := procs[0].(*MockOperationStateChangeProcessor) + mock2 := procs[1].(*MockOperationStateChangeProcessor) + + stateChanges1 := []types.StateChange{{ID: "sc1"}} + stateChanges2 := []types.StateChange{{ID: "sc2"}} + + mock1.On("ProcessOperation", ctx, testOp).Return(stateChanges1, nil) + mock2.On("ProcessOperation", ctx, testOp).Return(stateChanges2, nil) + }, + wantResult: []types.StateChange{{ID: "sc1"}, {ID: "sc2"}}, + }, + { + name: "🟢successful_processing_with_empty_results", + numProcessors: 2, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock1 := procs[0].(*MockOperationStateChangeProcessor) + mock2 := procs[1].(*MockOperationStateChangeProcessor) + + mock1.On("ProcessOperation", ctx, testOp).Return([]types.StateChange{}, nil) + mock2.On("ProcessOperation", ctx, testOp).Return([]types.StateChange{}, nil) + }, + wantResult: []types.StateChange{}, + }, + { + name: "🟢ignores_err_invalid_op_type_errors", + numProcessors: 2, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock1 := procs[0].(*MockOperationStateChangeProcessor) + mock2 := procs[1].(*MockOperationStateChangeProcessor) + + stateChanges1 := []types.StateChange{{ID: "sc1"}} + stateChanges2 := []types.StateChange{{ID: "sc2"}} + + mock1.On("ProcessOperation", ctx, testOp).Return(stateChanges1, processors.ErrInvalidOpType) + mock2.On("ProcessOperation", ctx, testOp).Return(stateChanges2, nil) + }, + wantResult: []types.StateChange{{ID: "sc2"}}, + }, + { + name: "🔴returns_first_error_from_processor_with_proper_wrapping", + numProcessors: 1, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock := procs[0].(*MockOperationStateChangeProcessor) + + stateChanges := []types.StateChange{{ID: "sc1"}} + expectedError := errors.New("processor error") + + mock.On("ProcessOperation", ctx, testOp).Return(stateChanges, expectedError) + }, + wantErrContains: "processor *indexer.MockOperationStateChangeProcessor failed:", + wantResult: nil, + }, + { + name: "🔴returns_first_error_when_multiple_processors_would_fail", + numProcessors: 1, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock := procs[0].(*MockOperationStateChangeProcessor) + + stateChanges := []types.StateChange{{ID: "sc1"}} + resErr := errors.New("first error") + + mock.On("ProcessOperation", ctx, testOp).Return(stateChanges, resErr) + }, + wantErrContains: "first error", + wantResult: nil, + }, + { + name: "🟢deduplicates_state_changes_by_id", + numProcessors: 2, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock1 := procs[0].(*MockOperationStateChangeProcessor) + mock2 := procs[1].(*MockOperationStateChangeProcessor) + + stateChanges1 := []types.StateChange{{ID: "sc1", StateChangeCategory: types.StateChangeCategoryDebit}} + stateChanges2 := []types.StateChange{{ID: "sc1", StateChangeCategory: types.StateChangeCategoryDebit}} + + mock1.On("ProcessOperation", ctx, testOp).Return(stateChanges1, nil) + mock2.On("ProcessOperation", ctx, testOp).Return(stateChanges2, nil) + }, + wantResult: []types.StateChange{{ID: "sc1", StateChangeCategory: types.StateChangeCategoryDebit}}, + }, + { + name: "🟢works_with_no_processors", + numProcessors: 0, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) {}, + wantResult: []types.StateChange{}, + }, + { + name: "🟢works_with_single_processor", + numProcessors: 1, + prepareMocks: func(t *testing.T, procs []OperationStateChangeProcessorInterface) { + mock := procs[0].(*MockOperationStateChangeProcessor) + stateChanges := []types.StateChange{{ID: "sc1"}} + + mock.On("ProcessOperation", ctx, testOp).Return(stateChanges, nil) + }, + wantResult: []types.StateChange{{ID: "sc1"}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + processors := []OperationStateChangeProcessorInterface{} + for range tc.numProcessors { + newProcessor := &MockOperationStateChangeProcessor{} + defer newProcessor.AssertExpectations(t) + processors = append(processors, newProcessor) + } + tc.prepareMocks(t, processors) + + bulkProcessor := NewBulkOperationProcessor(processors...) + result, err := bulkProcessor.ProcessOperation(ctx, testOp) + + if tc.wantErrContains != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tc.wantErrContains) + assert.Empty(t, result) + } else { + require.NoError(t, err) + assert.ElementsMatch(t, tc.wantResult, result) + } + }) + } +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index f5d44caf..7a5c1fe4 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -6,6 +6,7 @@ import ( 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" @@ -32,11 +33,15 @@ type ParticipantsProcessorInterface interface { GetOperationsParticipants(transaction ingest.LedgerTransaction) (map[int64]processors.OperationParticipants, error) } +type OperationStateChangeProcessorInterface 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 + opStateChangeProcessors OperationStateChangeProcessorInterface } func NewIndexer(networkPassphrase string) *Indexer { @@ -44,7 +49,10 @@ func NewIndexer(networkPassphrase string) *Indexer { Buffer: NewIndexerBuffer(), participantsProcessor: processors.NewParticipantsProcessor(networkPassphrase), tokenTransferProcessor: processors.NewTokenTransferProcessor(networkPassphrase), - effectsProcessor: processors.NewEffectsProcessor(networkPassphrase), + opStateChangeProcessors: NewBulkOperationProcessor( + processors.NewEffectsProcessor(networkPassphrase), + processors.NewContractDeployProcessor(networkPassphrase), + ), } } @@ -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 opStateChanges []types.StateChange for opID, opParticipants := range opsParticipants { dataOp, err = processors.ConvertOperation(&transaction, &opParticipants.OpWrapper.Operation, opID) if err != nil { @@ -82,15 +90,15 @@ func (i *Indexer) ProcessTransaction(ctx context.Context, transaction ingest.Led i.Buffer.PushParticipantOperation(participant, *dataOp, *dataTx) } - // 2.1. Index effects state changes - effectsStateChanges, err = i.effectsProcessor.ProcessOperation(ctx, opParticipants.OpWrapper) + // 3. Index operation state changes from all inner processors + opStateChanges, err = i.opStateChangeProcessors.ProcessOperation(ctx, opParticipants.OpWrapper) if err != nil { - return fmt.Errorf("processing effects state changes: %w", err) + return fmt.Errorf("processing operation state changes: %w", err) } - i.Buffer.PushStateChanges(effectsStateChanges) + i.Buffer.PushStateChanges(opStateChanges) } - // 3. Index token transfer state changes + // 4. Index token transfer state changes tokenTransferStateChanges, err := i.tokenTransferProcessor.ProcessTransaction(ctx, transaction) if err != nil { return fmt.Errorf("processing token transfer state changes: %w", err) diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go index c45055fb..d3c83a49 100644 --- a/internal/indexer/indexer_test.go +++ b/internal/indexer/indexer_test.go @@ -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, *MockOperationStateChangeProcessor, *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, mockOpStateChange *MockOperationStateChangeProcessor, mockBuffer *MockIndexerBuffer) { participants := set.NewSet("alice", "bob") mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil) @@ -115,8 +115,9 @@ func TestIndexer_ProcessTransaction(t *testing.T) { tokenStateChanges := []types.StateChange{{ID: "token_sc1"}} 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) + // Combined state changes from both effects and contract deploy processors + operationStateChanges := []types.StateChange{{ID: "effects_sc1"}, {ID: "contract_deploy_sc1"}} + mockOpStateChange.On("ProcessOperation", mock.Anything, mock.Anything).Return(operationStateChanges, nil) // Verify transaction was pushed to buffer with correct participants // PushParticipantTransaction is called once for each participant @@ -140,10 +141,10 @@ func TestIndexer_ProcessTransaction(t *testing.T) { })).Return() // Verify state changes were pushed to buffer - // PushStateChanges is called separately for effects and token transfer state changes + // PushStateChanges is called for operation state changes and token transfer state changes mockBuffer.On("PushStateChanges", mock.MatchedBy(func(stateChanges []types.StateChange) bool { - return len(stateChanges) == 1 && stateChanges[0].ID == "effects_sc1" + return len(stateChanges) == 2 && stateChanges[0].ID == "effects_sc1" && stateChanges[1].ID == "contract_deploy_sc1" })).Return() mockBuffer.On("PushStateChanges", mock.MatchedBy(func(stateChanges []types.StateChange) bool { @@ -165,7 +166,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, _ *MockOperationStateChangeProcessor, mockBuffer *MockIndexerBuffer) { participants := set.NewSet[string]() mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil) @@ -187,14 +188,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, _ *MockTokenTransferProcessor, _ *MockOperationStateChangeProcessor, _ *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, _ *MockTokenTransferProcessor, _ *MockOperationStateChangeProcessor, _ *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")) @@ -202,8 +203,8 @@ func TestIndexer_ProcessTransaction(t *testing.T) { wantError: "getting operations participants: operations error", }, { - name: "🔴 error processing effects state changes", - setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) { + name: "🔴 error processing operation state changes", + setupMocks: func(mockParticipants *MockParticipantsProcessor, _ *MockTokenTransferProcessor, mockOpStateChange *MockOperationStateChangeProcessor, mockBuffer *MockIndexerBuffer) { participants := set.NewSet[string]() mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil) @@ -221,13 +222,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")) + mockOpStateChange.On("ProcessOperation", mock.Anything, mock.Anything).Return([]types.StateChange{}, errors.New("operation error")) }, - wantError: "processing effects state changes: effects error", + wantError: "processing operation state changes: operation error", }, { name: "🔴 error processing token transfer state changes", - setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, mockEffects *MockEffectsProcessor, mockBuffer *MockIndexerBuffer) { + setupMocks: func(mockParticipants *MockParticipantsProcessor, mockTokenTransfer *MockTokenTransferProcessor, _ *MockOperationStateChangeProcessor, _ *MockIndexerBuffer) { participants := set.NewSet[string]() mockParticipants.On("GetTransactionParticipants", mock.Anything).Return(participants, nil) @@ -245,18 +246,18 @@ func TestIndexer_ProcessTransaction(t *testing.T) { // Create mocks mockParticipants := &MockParticipantsProcessor{} mockTokenTransfer := &MockTokenTransferProcessor{} - mockEffects := &MockEffectsProcessor{} + mockOpStateChange := &MockOperationStateChangeProcessor{} mockBuffer := &MockIndexerBuffer{} // Setup mock expectations - tt.setupMocks(mockParticipants, mockTokenTransfer, mockEffects, mockBuffer) + tt.setupMocks(mockParticipants, mockTokenTransfer, mockOpStateChange, mockBuffer) // Create testable indexer with mocked dependencies indexer := &Indexer{ - Buffer: mockBuffer, - participantsProcessor: mockParticipants, - tokenTransferProcessor: mockTokenTransfer, - effectsProcessor: mockEffects, + Buffer: mockBuffer, + participantsProcessor: mockParticipants, + tokenTransferProcessor: mockTokenTransfer, + opStateChangeProcessors: mockOpStateChange, } err := indexer.ProcessTransaction(context.Background(), testTx) @@ -292,7 +293,7 @@ func TestIndexer_ProcessTransaction(t *testing.T) { // Verify all mock expectations were met mockParticipants.AssertExpectations(t) mockTokenTransfer.AssertExpectations(t) - mockEffects.AssertExpectations(t) + mockOpStateChange.AssertExpectations(t) mockBuffer.AssertExpectations(t) }) } diff --git a/internal/indexer/mocks.go b/internal/indexer/mocks.go index 1da1ba0d..79e84629 100644 --- a/internal/indexer/mocks.go +++ b/internal/indexer/mocks.go @@ -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 @@ -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 MockOperationStateChangeProcessor struct { mock.Mock } -func (m *MockEffectsProcessor) ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) { +func (m *MockOperationStateChangeProcessor) 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) } @@ -96,8 +92,8 @@ func (m *MockIndexerBuffer) GetAllStateChanges() []types.StateChange { } var ( - _ IndexerBufferInterface = &MockIndexerBuffer{} - _ ParticipantsProcessorInterface = &MockParticipantsProcessor{} - _ TokenTransferProcessorInterface = &MockTokenTransferProcessor{} - _ EffectsProcessorInterface = &MockEffectsProcessor{} + _ IndexerBufferInterface = &MockIndexerBuffer{} + _ ParticipantsProcessorInterface = &MockParticipantsProcessor{} + _ TokenTransferProcessorInterface = &MockTokenTransferProcessor{} + _ OperationStateChangeProcessorInterface = &MockOperationStateChangeProcessor{} ) diff --git a/internal/indexer/processors/contract_deploy.go b/internal/indexer/processors/contract_deploy.go new file mode 100644 index 00000000..6bf68e25 --- /dev/null +++ b/internal/indexer/processors/contract_deploy.go @@ -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). + 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 +} diff --git a/internal/indexer/processors/contract_deploy_test.go b/internal/indexer/processors/contract_deploy_test.go new file mode 100644 index 00000000..f75567bc --- /dev/null +++ b/internal/indexer/processors/contract_deploy_test.go @@ -0,0 +1,269 @@ +package processors + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stellar/go/network" + operation_processor "github.com/stellar/go/processors/operation" + "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/utils" +) + +func Test_ContractDeployProcessor_Process_invalidOpType(t *testing.T) { + ctx := context.Background() + proc := NewContractDeployProcessor(network.TestNetworkPassphrase) + + op := &operation_processor.TransactionOperationWrapper{ + Operation: xdr.Operation{Body: xdr.OperationBody{Type: xdr.OperationTypePayment}}, + } + changes, err := proc.ProcessOperation(ctx, op) + assert.ErrorIs(t, err, ErrInvalidOpType) + assert.Nil(t, changes) +} + +func Test_ContractDeployProcessor_Process_createContract(t *testing.T) { + const ( + opSourceAccount = "GBZURSTQQRSU3XB66CHJ3SH2ZWLG663V5SWM6HF3FL72BOMYHDT4QTUF" + fromSourceAccount = "GCQIH6MRLCJREVE76LVTKKEZXRIT6KSX7KU65HPDDBYFKFYHIYSJE57R" + authSignerAccount = "GDG2KKXC62BINMUZNBTLG235323N6BOIR33JBF4ELTOUKUG5BDE6HJZT" + ) + + ctx := context.Background() + + builder := NewStateChangeBuilder(12345, closeTime.Unix(), "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"). + WithOperationID(53021371269121). + WithReason(types.StateChangeReasonDeploy). + WithCategory(types.StateChangeCategoryContract) + + type TestCase struct { + name string + op *operation_processor.TransactionOperationWrapper + wantStateChanges []types.StateChange + } + testCases := []TestCase{} + + // We test with/without subinvocations, which can contain nested invocations to this or another contract. + for _, withSubinvocations := range []bool{false, true} { + // We test with/without fee bump, to ensure the processor handles both cases. + for _, feeBump := range []bool{false, true} { + for _, hostFnType := range []xdr.HostFunctionType{xdr.HostFunctionTypeHostFunctionTypeCreateContract, xdr.HostFunctionTypeHostFunctionTypeCreateContractV2} { + prefix := strings.ReplaceAll(hostFnType.String(), "HostFunctionTypeHostFunctionType", "") + + subInvocationsStateChanges := []types.StateChange{} + if withSubinvocations { + prefix = fmt.Sprintf("%s,withSubinvocations🔄", prefix) + subInvocationsStateChanges = []types.StateChange{ + builder.Clone(). + WithDeployer(deployerAccountID). + WithAccount(deployedContractID). + Build(), + } + } + + if feeBump { + prefix = fmt.Sprintf("feeBump(%s)", prefix) + } + + testCases = append(testCases, + TestCase{ + name: fmt.Sprintf("🟢%s/FromAddress/tx.SourceAccount", prefix), + op: func() *operation_processor.TransactionOperationWrapper { + op := makeBasicSorobanOp() + setFromAddress(op, hostFnType, fromSourceAccount) + if withSubinvocations { + op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) + includeSubInvocations(op) + } + if feeBump { + op = makeFeeBumpOp(txSourceAccount, op) + } + return op + }(), + wantStateChanges: append(subInvocationsStateChanges, + builder.Clone(). + WithDeployer(fromSourceAccount). + WithAccount("CA7UGIYR2H63C2ETN2VE4WDQ6YX5XNEWNWC2DP7A64B2ZR7VJJWF3SBF"). + Build(), + ), + }, + TestCase{ + name: fmt.Sprintf("🟢%s/FromAddress/op.SourceAccount", prefix), + op: func() *operation_processor.TransactionOperationWrapper { + op := makeBasicSorobanOp() + op.Operation.SourceAccount = utils.PointOf(xdr.MustMuxedAddress(opSourceAccount)) + setFromAddress(op, hostFnType, fromSourceAccount) + if withSubinvocations { + op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) + includeSubInvocations(op) + } + if feeBump { + op = makeFeeBumpOp(txSourceAccount, op) + } + return op + }(), + wantStateChanges: append(subInvocationsStateChanges, + builder.Clone(). + WithDeployer(fromSourceAccount). + WithAccount("CA7UGIYR2H63C2ETN2VE4WDQ6YX5XNEWNWC2DP7A64B2ZR7VJJWF3SBF"). + Build(), + ), + }, + TestCase{ + name: fmt.Sprintf("🟢%s/FromAsset/tx.SourceAccount", prefix), + op: func() *operation_processor.TransactionOperationWrapper { + op := makeBasicSorobanOp() + setFromAsset(op, hostFnType, usdcAssetTestnet) + if withSubinvocations { + op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) + includeSubInvocations(op) + } + if feeBump { + op = makeFeeBumpOp(txSourceAccount, op) + } + return op + }(), + wantStateChanges: subInvocationsStateChanges, + }, + ) + } + } + } + + proc := NewContractDeployProcessor(network.TestNetworkPassphrase) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + stateChanges, err := proc.ProcessOperation(ctx, tc.op) + + require.NoError(t, err) + assertStateChangesElementsMatch(t, tc.wantStateChanges, stateChanges) + }) + } +} + +func Test_ContractDeployProcessor_Process_invokeContract(t *testing.T) { + const ( + opSourceAccount = "GBZURSTQQRSU3XB66CHJ3SH2ZWLG663V5SWM6HF3FL72BOMYHDT4QTUF" + argAccountID1 = "GCQIH6MRLCJREVE76LVTKKEZXRIT6KSX7KU65HPDDBYFKFYHIYSJE57R" + argAccountID2 = "GDG2KKXC62BINMUZNBTLG235323N6BOIR33JBF4ELTOUKUG5BDE6HJZT" + argContractID1 = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" + argContractID2 = "CDNVQW44C3HALYNVQ4SOBXY5EWYTGVYXX6JPESOLQDABJI5FC5LTRRUE" + authSignerAccount = "GDG2KKXC62BINMUZNBTLG235323N6BOIR33JBF4ELTOUKUG5BDE6HJZT" + invokedContractID = "CBL6KD2LFMLAUKFFWNNXWOXFN73GAXLEA4WMJRLQ5L76DMYTM3KWQVJN" + ) + + ctx := context.Background() + + makeInvokeContractOp := func(argAddresses ...xdr.ScAddress) *operation_processor.TransactionOperationWrapper { + op := makeBasicSorobanOp() + op.Operation = xdr.Operation{ + Body: xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ + HostFunction: xdr.HostFunction{ + Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract, + InvokeContract: &xdr.InvokeContractArgs{ + ContractAddress: makeScContract(invokedContractID), + FunctionName: xdr.ScSymbol("authorized_fn"), + Args: func() []xdr.ScVal { + args := make([]xdr.ScVal, len(argAddresses)) + for i, argAddress := range argAddresses { + args[i] = xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: utils.PointOf(argAddress)} + } + return args + }(), + }, + }, + Auth: []xdr.SorobanAuthorizationEntry{}, + }, + }, + } + + return op + } + + builder := NewStateChangeBuilder(12345, closeTime.Unix(), "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"). + WithOperationID(53021371269121). + WithReason(types.StateChangeReasonDeploy). + WithCategory(types.StateChangeCategoryContract) + + type TestCase struct { + name string + op *operation_processor.TransactionOperationWrapper + wantStateChanges []types.StateChange + } + testCases := []TestCase{} + + // We test with/without subinvocations, which can contain nested invocations to this or another contract. + for _, withSubinvocations := range []bool{false, true} { + // We test with/without fee bump, to ensure the processor handles both cases. + for _, feeBump := range []bool{false, true} { + prefix := "" + + subInvocationsStateChanges := []types.StateChange{} + if withSubinvocations { + prefix = "🔄WithSubinvocations🔄" + subInvocationsStateChanges = []types.StateChange{ + builder.Clone(). + WithDeployer(deployerAccountID). + WithAccount(deployedContractID). + Build(), + } + } + + if feeBump { + prefix = fmt.Sprintf("feeBump(%s)", prefix) + } + + testCases = append(testCases, + TestCase{ + name: fmt.Sprintf("🟢%s/tx.SourceAccount", prefix), + op: func() *operation_processor.TransactionOperationWrapper { + op := makeInvokeContractOp(makeScAddress(argAccountID1), makeScAddress(argAccountID2)) + if withSubinvocations { + op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) + includeSubInvocations(op) + } + if feeBump { + op = makeFeeBumpOp(txSourceAccount, op) + } + return op + }(), + wantStateChanges: subInvocationsStateChanges, + }, + TestCase{ + name: fmt.Sprintf("🟢%s/op.SourceAccount", prefix), + op: func() *operation_processor.TransactionOperationWrapper { + op := makeInvokeContractOp(makeScContract(argContractID1), makeScContract(argContractID2)) + op.Operation.SourceAccount = utils.PointOf(xdr.MustMuxedAddress(opSourceAccount)) + if withSubinvocations { + op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) + includeSubInvocations(op) + } + if feeBump { + op = makeFeeBumpOp(txSourceAccount, op) + } + return op + }(), + wantStateChanges: subInvocationsStateChanges, + }, + ) + } + } + + proc := NewContractDeployProcessor(network.TestNetworkPassphrase) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + stateChanges, err := proc.ProcessOperation(ctx, tc.op) + + require.NoError(t, err) + assertStateChangesElementsMatch(t, tc.wantStateChanges, stateChanges) + }) + } +} diff --git a/internal/indexer/processors/contract_operations_test.go b/internal/indexer/processors/contract_operations_test.go index f03209e8..80bed1df 100644 --- a/internal/indexer/processors/contract_operations_test.go +++ b/internal/indexer/processors/contract_operations_test.go @@ -2,6 +2,7 @@ package processors import ( "fmt" + "strings" "testing" "time" @@ -9,7 +10,6 @@ import ( "github.com/stellar/go/ingest" "github.com/stellar/go/network" operation_processor "github.com/stellar/go/processors/operation" - "github.com/stellar/go/strkey" "github.com/stellar/go/xdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,39 +28,6 @@ func Test_calculateContractID(t *testing.T) { require.Equal(t, "CANZKJUEZM22DO2XLJP4ARZAJFG7GJVBIEXJ7T4F2GAIAV4D4RMXMDVD", contractID) } -func makeScAddress(accountID string) xdr.ScAddress { - return xdr.ScAddress{ - Type: xdr.ScAddressTypeScAddressTypeAccount, - AccountId: utils.PointOf(xdr.MustAddress(accountID)), - } -} - -func makeScContract(contractID string) xdr.ScAddress { - decoded := strkey.MustDecode(strkey.VersionByteContract, contractID) - return xdr.ScAddress{ - Type: xdr.ScAddressTypeScAddressTypeContract, - ContractId: utils.PointOf(xdr.ContractId(decoded)), - } -} - -// makeFeeBumpOp updates the envelope type to a fee bump envelope and sets the fee source account. -func makeFeeBumpOp(feeBumpSourceAccount string, baseOp *operation_processor.TransactionOperationWrapper) *operation_processor.TransactionOperationWrapper { - op := *baseOp - op.Transaction.Envelope.V0 = nil - op.Transaction.Envelope.V1 = nil - op.Transaction.Envelope.Type = xdr.EnvelopeTypeEnvelopeTypeTxFeeBump - op.Transaction.Envelope.FeeBump = &xdr.FeeBumpTransactionEnvelope{ - Tx: xdr.FeeBumpTransaction{ - FeeSource: xdr.MustMuxedAddress(feeBumpSourceAccount), - InnerTx: xdr.FeeBumpTransactionInnerTx{ - Type: baseOp.Transaction.Envelope.Type, - V1: baseOp.Transaction.Envelope.V1, - }, - }, - } - return &op -} - func Test_participantsForSorobanOp_nonSorobanOp(t *testing.T) { const txSourceAccount = "GAGWN4445WLODCXT7RUZXJLQK5XWX4GICXDOAAZZGK2N3BR67RIIVWJ7" @@ -245,41 +212,22 @@ func Test_participantsForSorobanOp_footprintOps(t *testing.T) { } func Test_participantsForSorobanOp_invokeHostFunction_uploadWasm(t *testing.T) { - const ( - txSourceAccount = "GAGWN4445WLODCXT7RUZXJLQK5XWX4GICXDOAAZZGK2N3BR67RIIVWJ7" - opSourceAccount = "GBKV7KN5K2CJA7TC5AUQNI76JBXHLMQSHT426JEAR3TPVKNSMKMG4RZN" - ) + const opSourceAccount = "GBKV7KN5K2CJA7TC5AUQNI76JBXHLMQSHT426JEAR3TPVKNSMKMG4RZN" uploadWasmOp := func() *operation_processor.TransactionOperationWrapper { - return &operation_processor.TransactionOperationWrapper{ - Network: network.TestNetworkPassphrase, - LedgerClosed: time.Now(), - Operation: xdr.Operation{ - Body: xdr.OperationBody{ - Type: xdr.OperationTypeInvokeHostFunction, - InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ - HostFunction: xdr.HostFunction{ - Type: xdr.HostFunctionTypeHostFunctionTypeUploadContractWasm, - Wasm: &[]byte{1, 2, 3, 4, 5}, - }, - }, - }, - }, - Transaction: ingest.LedgerTransaction{ - Envelope: xdr.TransactionEnvelope{ - Type: xdr.EnvelopeTypeEnvelopeTypeTx, - V1: &xdr.TransactionV1Envelope{ - Tx: xdr.Transaction{ - SourceAccount: xdr.MustMuxedAddress(txSourceAccount), - Ext: xdr.TransactionExt{ - V: 1, - SorobanData: &xdr.SorobanTransactionData{}, - }, - }, + op := makeBasicSorobanOp() + op.Operation = xdr.Operation{ + Body: xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ + HostFunction: xdr.HostFunction{ + Type: xdr.HostFunctionTypeHostFunctionTypeUploadContractWasm, + Wasm: &[]byte{1, 2, 3, 4, 5}, }, }, }, } + return op } testCases := []struct { @@ -433,7 +381,6 @@ func includeSubInvocations(op *operation_processor.TransactionOperationWrapper) func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing.T) { const ( - txSourceAccount = "GAUE24B36YYY3CXTXNFE3IFXU6EE4NUOS5L744IWGTNXVXZAXFGMP6CC" opSourceAccount = "GBZURSTQQRSU3XB66CHJ3SH2ZWLG663V5SWM6HF3FL72BOMYHDT4QTUF" fromSourceAccount = "GCQIH6MRLCJREVE76LVTKKEZXRIT6KSX7KU65HPDDBYFKFYHIYSJE57R" authSignerAccount = "GDG2KKXC62BINMUZNBTLG235323N6BOIR33JBF4ELTOUKUG5BDE6HJZT" @@ -441,89 +388,6 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. constructorAccountID = "GAHPYWLK6YRN7CVYZOO4H3VDRZ7PVF5UJGLZCSPAEIKJE2XSWF5LAGER" constructorContractID = "CDNVQW44C3HALYNVQ4SOBXY5EWYTGVYXX6JPESOLQDABJI5FC5LTRRUE" ) - usdcXdrAsset := xdr.Asset{ - Type: xdr.AssetTypeAssetTypeCreditAlphanum4, - AlphaNum4: &xdr.AlphaNum4{ - AssetCode: [4]byte{'U', 'S', 'D', 'C'}, - Issuer: xdr.MustAddress("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"), - }, - } - salt := xdr.Uint256{195, 179, 60, 131, 211, 25, 160, 131, 45, 151, 203, 11, 11, 116, 166, 232, 51, 92, 179, 76, 220, 111, 96, 246, 72, 68, 195, 127, 194, 19, 147, 252} - - basicSorobanOp := func() *operation_processor.TransactionOperationWrapper { - return &operation_processor.TransactionOperationWrapper{ - Network: network.TestNetworkPassphrase, - LedgerClosed: time.Now(), - Operation: xdr.Operation{ - Body: xdr.OperationBody{ - Type: xdr.OperationTypeInvokeHostFunction, - InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ - HostFunction: xdr.HostFunction{}, - Auth: []xdr.SorobanAuthorizationEntry{}, - }, - }, - }, - Transaction: ingest.LedgerTransaction{ - Envelope: xdr.TransactionEnvelope{ - Type: xdr.EnvelopeTypeEnvelopeTypeTx, - V1: &xdr.TransactionV1Envelope{ - Tx: xdr.Transaction{ - SourceAccount: xdr.MustMuxedAddress(txSourceAccount), - Ext: xdr.TransactionExt{ - V: 1, - SorobanData: &xdr.SorobanTransactionData{}, - }, - }, - }, - }, - }, - } - } - - setFromAddress := func(op *operation_processor.TransactionOperationWrapper, hostFnType xdr.HostFunctionType, fromSourceAccount string) { - op.Operation.Body.InvokeHostFunctionOp.HostFunction.Type = hostFnType - preimage := xdr.ContractIdPreimage{ - Type: xdr.ContractIdPreimageTypeContractIdPreimageFromAddress, - FromAddress: &xdr.ContractIdPreimageFromAddress{ - Address: makeScAddress(fromSourceAccount), - Salt: salt, - }, - } - - switch hostFnType { - case xdr.HostFunctionTypeHostFunctionTypeCreateContract: - op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContract = &xdr.CreateContractArgs{ - ContractIdPreimage: preimage, - } - case xdr.HostFunctionTypeHostFunctionTypeCreateContractV2: - op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContractV2 = &xdr.CreateContractArgsV2{ - ContractIdPreimage: preimage, - } - default: - require.Fail(t, "unsupported host function type", "host function type: %s", hostFnType) - } - } - - setFromAsset := func(op *operation_processor.TransactionOperationWrapper, hostFnType xdr.HostFunctionType, asset xdr.Asset) { - op.Operation.Body.InvokeHostFunctionOp.HostFunction.Type = hostFnType - preimage := xdr.ContractIdPreimage{ - Type: xdr.ContractIdPreimageTypeContractIdPreimageFromAsset, - FromAsset: &asset, - } - - switch hostFnType { - case xdr.HostFunctionTypeHostFunctionTypeCreateContract: - op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContract = &xdr.CreateContractArgs{ - ContractIdPreimage: preimage, - } - case xdr.HostFunctionTypeHostFunctionTypeCreateContractV2: - op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContractV2 = &xdr.CreateContractArgsV2{ - ContractIdPreimage: preimage, - } - default: - require.Fail(t, "unsupported host function type", "host function type: %s", hostFnType) - } - } type TestCase struct { name string @@ -535,7 +399,7 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. for _, withSubinvocations := range []bool{false, true} { for _, feeBump := range []bool{false, true} { for _, hostFnType := range []xdr.HostFunctionType{xdr.HostFunctionTypeHostFunctionTypeCreateContract, xdr.HostFunctionTypeHostFunctionTypeCreateContractV2} { - prefix := hostFnType.String() + prefix := strings.ReplaceAll(hostFnType.String(), "HostFunctionTypeHostFunctionType", "") subInvocationsParticipants := set.NewSet[string]() if withSubinvocations { prefix = fmt.Sprintf("%s,withSubinvocations🔄", prefix) @@ -548,7 +412,7 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. TestCase{ name: fmt.Sprintf("🟢%s/FromAddress/tx.SourceAccount", prefix), op: func() *operation_processor.TransactionOperationWrapper { - op := basicSorobanOp() + op := makeBasicSorobanOp() setFromAddress(op, hostFnType, fromSourceAccount) if withSubinvocations { op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) @@ -564,7 +428,7 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. TestCase{ name: fmt.Sprintf("🟢%s/FromAddress/op.SourceAccount", prefix), op: func() *operation_processor.TransactionOperationWrapper { - op := basicSorobanOp() + op := makeBasicSorobanOp() op.Operation.SourceAccount = utils.PointOf(xdr.MustMuxedAddress(opSourceAccount)) setFromAddress(op, hostFnType, fromSourceAccount) if withSubinvocations { @@ -581,8 +445,8 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. TestCase{ name: fmt.Sprintf("🟢%s/FromAsset/tx.SourceAccount", prefix), op: func() *operation_processor.TransactionOperationWrapper { - op := basicSorobanOp() - setFromAsset(op, hostFnType, usdcXdrAsset) + op := makeBasicSorobanOp() + setFromAsset(op, hostFnType, usdcAssetTestnet) if withSubinvocations { op.Operation.Body.InvokeHostFunctionOp.Auth = makeAuthEntries(t, op, makeScAddress(authSignerAccount)) includeSubInvocations(op) @@ -601,7 +465,7 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. testCases = append(testCases, TestCase{ name: fmt.Sprintf("🟢%s.ConstructorArgs/FromAccount/op.SourceAccount", xdr.HostFunctionTypeHostFunctionTypeCreateContractV2), op: func() *operation_processor.TransactionOperationWrapper { - op := basicSorobanOp() + op := makeBasicSorobanOp() setFromAddress(op, xdr.HostFunctionTypeHostFunctionTypeCreateContractV2, fromSourceAccount) op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContractV2.ConstructorArgs = []xdr.ScVal{ // <--- args addresses are not returned {Type: xdr.ScValTypeScvAddress, Address: utils.PointOf(makeScAddress(constructorAccountID))}, @@ -626,7 +490,6 @@ func Test_participantsForSorobanOp_invokeHostFunction_createContract(t *testing. func Test_participantsForSorobanOp_invokeHostFunction_invokeContract(t *testing.T) { const ( - txSourceAccount = "GAUE24B36YYY3CXTXNFE3IFXU6EE4NUOS5L744IWGTNXVXZAXFGMP6CC" opSourceAccount = "GBZURSTQQRSU3XB66CHJ3SH2ZWLG663V5SWM6HF3FL72BOMYHDT4QTUF" argAccountID1 = "GCQIH6MRLCJREVE76LVTKKEZXRIT6KSX7KU65HPDDBYFKFYHIYSJE57R" argAccountID2 = "GDG2KKXC62BINMUZNBTLG235323N6BOIR33JBF4ELTOUKUG5BDE6HJZT" @@ -637,47 +500,31 @@ func Test_participantsForSorobanOp_invokeHostFunction_invokeContract(t *testing. ) makeInvokeContractOp := func(argAddresses ...xdr.ScAddress) *operation_processor.TransactionOperationWrapper { - return &operation_processor.TransactionOperationWrapper{ - Network: network.TestNetworkPassphrase, - LedgerClosed: time.Now(), - Operation: xdr.Operation{ - Body: xdr.OperationBody{ - Type: xdr.OperationTypeInvokeHostFunction, - InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ - HostFunction: xdr.HostFunction{ - Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract, - - InvokeContract: &xdr.InvokeContractArgs{ - ContractAddress: makeScContract(invokedContractID), - FunctionName: xdr.ScSymbol("authorized_fn"), - Args: func() []xdr.ScVal { - args := make([]xdr.ScVal, len(argAddresses)) - for i, argAddress := range argAddresses { - args[i] = xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: utils.PointOf(argAddress)} - } - return args - }(), - }, - }, - Auth: []xdr.SorobanAuthorizationEntry{}, - }, - }, - }, - Transaction: ingest.LedgerTransaction{ - Envelope: xdr.TransactionEnvelope{ - Type: xdr.EnvelopeTypeEnvelopeTypeTx, - V1: &xdr.TransactionV1Envelope{ - Tx: xdr.Transaction{ - SourceAccount: xdr.MustMuxedAddress(txSourceAccount), - Ext: xdr.TransactionExt{ - V: 1, - SorobanData: &xdr.SorobanTransactionData{}, - }, + op := makeBasicSorobanOp() + op.Operation = xdr.Operation{ + Body: xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ + HostFunction: xdr.HostFunction{ + Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract, + InvokeContract: &xdr.InvokeContractArgs{ + ContractAddress: makeScContract(invokedContractID), + FunctionName: xdr.ScSymbol("authorized_fn"), + Args: func() []xdr.ScVal { + args := make([]xdr.ScVal, len(argAddresses)) + for i, argAddress := range argAddresses { + args[i] = xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: utils.PointOf(argAddress)} + } + return args + }(), }, }, + Auth: []xdr.SorobanAuthorizationEntry{}, }, }, } + + return op } type TestCase struct { diff --git a/internal/indexer/processors/contracts_test_utils.go b/internal/indexer/processors/contracts_test_utils.go new file mode 100644 index 00000000..738ace46 --- /dev/null +++ b/internal/indexer/processors/contracts_test_utils.go @@ -0,0 +1,183 @@ +package processors + +import ( + "testing" + "time" + + "github.com/stellar/go/ingest" + "github.com/stellar/go/network" + operation_processor "github.com/stellar/go/processors/operation" + "github.com/stellar/go/strkey" + "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/utils" +) + +// txSourceAccount is a source account commonly used in this package's tests. +const txSourceAccount = "GAUE24B36YYY3CXTXNFE3IFXU6EE4NUOS5L744IWGTNXVXZAXFGMP6CC" + +// TestSalt is a common salt used in this package's tests. +var TestSalt = xdr.Uint256{195, 179, 60, 131, 211, 25, 160, 131, 45, 151, 203, 11, 11, 116, 166, 232, 51, 92, 179, 76, 220, 111, 96, 246, 72, 68, 195, 127, 194, 19, 147, 252} + +// usdcAssetTestnet is the widely known Circle USDC asset, and we use it in this package's tests. +var usdcAssetTestnet = xdr.Asset{ + Type: xdr.AssetTypeAssetTypeCreditAlphanum4, + AlphaNum4: &xdr.AlphaNum4{ + AssetCode: [4]byte{'U', 'S', 'D', 'C'}, + Issuer: xdr.MustAddress("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"), + }, +} + +var closeTime = time.Date(2025, 2, 21, 12, 0, 0, 0, time.UTC) + +// makeScAddress creates an xdr.ScAddress from an account ID string. +func makeScAddress(accountID string) xdr.ScAddress { + return xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeAccount, + AccountId: utils.PointOf(xdr.MustAddress(accountID)), + } +} + +// makeScContract creates an xdr.ScAddress from a contract ID string. +func makeScContract(contractID string) xdr.ScAddress { + decoded := strkey.MustDecode(strkey.VersionByteContract, contractID) + return xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeContract, + ContractId: utils.PointOf(xdr.ContractId(decoded)), + } +} + +// makeBasicSorobanOp creates a basic Soroban operation wrapper for testing. +func makeBasicSorobanOp() *operation_processor.TransactionOperationWrapper { + return &operation_processor.TransactionOperationWrapper{ + Network: network.TestNetworkPassphrase, + LedgerClosed: closeTime, + LedgerSequence: 12345, + Operation: xdr.Operation{}, + Transaction: ingest.LedgerTransaction{ + Envelope: xdr.TransactionEnvelope{ + Type: xdr.EnvelopeTypeEnvelopeTypeTx, + V1: &xdr.TransactionV1Envelope{ + Tx: xdr.Transaction{ + SourceAccount: xdr.MustMuxedAddress(txSourceAccount), // <--- tx.SourceAccount + Ext: xdr.TransactionExt{ + V: 1, + SorobanData: &xdr.SorobanTransactionData{}, + }, + }, + }, + }, + Hash: xdr.Hash{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20}, + Ledger: xdr.LedgerCloseMeta{ + V: 1, + V1: &xdr.LedgerCloseMetaV1{ + LedgerHeader: xdr.LedgerHeaderHistoryEntry{ + Header: xdr.LedgerHeader{ + LedgerSeq: 12345, + ScpValue: xdr.StellarValue{CloseTime: xdr.TimePoint(closeTime.Unix())}, + }, + }, + }, + }, + }, + } +} + +// setFromAddress configures a Soroban operation with FromAddress contract creation. +// +//nolint:unparam +func setFromAddress(op *operation_processor.TransactionOperationWrapper, hostFnType xdr.HostFunctionType, fromSourceAccount string) { + setFrom(op, hostFnType, xdr.ContractIdPreimage{ + Type: xdr.ContractIdPreimageTypeContractIdPreimageFromAddress, + FromAddress: &xdr.ContractIdPreimageFromAddress{ + Address: makeScAddress(fromSourceAccount), + Salt: TestSalt, + }, + }) +} + +// setFromAsset configures a Soroban operation with FromAsset contract creation. +func setFromAsset(op *operation_processor.TransactionOperationWrapper, hostFnType xdr.HostFunctionType, asset xdr.Asset) { + setFrom(op, hostFnType, xdr.ContractIdPreimage{ + Type: xdr.ContractIdPreimageTypeContractIdPreimageFromAsset, + FromAsset: &asset, + }) +} + +// setFrom configures a Soroban operation with From(Asset|Address) contract creation. +func setFrom(op *operation_processor.TransactionOperationWrapper, hostFnType xdr.HostFunctionType, preimage xdr.ContractIdPreimage) { + op.Operation.Body = xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{ + HostFunction: xdr.HostFunction{ + Type: hostFnType, + }, + Auth: []xdr.SorobanAuthorizationEntry{}, + }, + } + + switch hostFnType { + case xdr.HostFunctionTypeHostFunctionTypeCreateContract: + op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContract = &xdr.CreateContractArgs{ + ContractIdPreimage: preimage, + } + case xdr.HostFunctionTypeHostFunctionTypeCreateContractV2: + op.Operation.Body.InvokeHostFunctionOp.HostFunction.CreateContractV2 = &xdr.CreateContractArgsV2{ + ContractIdPreimage: preimage, + } + default: + require.Fail(nil, "unsupported host function type", "host function type: %s", hostFnType) + } +} + +// makeFeeBumpOp updates the envelope type to a fee bump envelope and sets the fee source account. +func makeFeeBumpOp(feeBumpSourceAccount string, baseOp *operation_processor.TransactionOperationWrapper) *operation_processor.TransactionOperationWrapper { + op := *baseOp + op.Transaction.Envelope.V0 = nil + op.Transaction.Envelope.V1 = nil + op.Transaction.Envelope.Type = xdr.EnvelopeTypeEnvelopeTypeTxFeeBump + op.Transaction.Envelope.FeeBump = &xdr.FeeBumpTransactionEnvelope{ + Tx: xdr.FeeBumpTransaction{ + FeeSource: xdr.MustMuxedAddress(feeBumpSourceAccount), + InnerTx: xdr.FeeBumpTransactionInnerTx{ + Type: baseOp.Transaction.Envelope.Type, + V1: baseOp.Transaction.Envelope.V1, + }, + }, + } + return &op +} + +// assertStateChangeEqual compares two state changes and fails if they are not equal. +func assertStateChangeEqual(t *testing.T, want types.StateChange, got types.StateChange) { + t.Helper() + + gotV2 := got + gotV2.IngestedAt = want.IngestedAt + assert.Equal(t, want, gotV2) +} + +// assertStateChangesElementsMatch compares two slices of state changes and fails if they are not equal. +func assertStateChangesElementsMatch(t *testing.T, want []types.StateChange, got []types.StateChange) { + t.Helper() + + if len(want) != len(got) { + assert.Fail(t, "state changes length mismatch", "want %d, got %d", len(want), len(got)) + } + + wantMap := make(map[string]types.StateChange) + for _, w := range want { + wantMap[w.ID] = w + } + + for _, g := range got { + if _, ok := wantMap[g.ID]; !ok { + assert.Fail(t, "state change not found", "state change id: %s", g.ID) + } + + assertStateChangeEqual(t, wantMap[g.ID], g) + } +} diff --git a/internal/indexer/processors/effects.go b/internal/indexer/processors/effects.go index 90418dec..6b3c44b7 100644 --- a/internal/indexer/processors/effects.go +++ b/internal/indexer/processors/effects.go @@ -72,7 +72,7 @@ func NewEffectsProcessor(networkPassphrase string) *EffectsProcessor { // It processes account state changes like signer modifications, threshold updates, flag changes, // home domain updates, data entry changes, and sponsorship relationship modifications. // Returns a slice of state changes representing various account state changes. -func (p *EffectsProcessor) ProcessOperation(ctx context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) { +func (p *EffectsProcessor) ProcessOperation(_ context.Context, opWrapper *operation_processor.TransactionOperationWrapper) ([]types.StateChange, error) { ledgerCloseTime := opWrapper.Transaction.Ledger.LedgerCloseTime() ledgerNumber := opWrapper.Transaction.Ledger.LedgerSequence() txHash := opWrapper.Transaction.Result.TransactionHash.HexString() diff --git a/internal/indexer/processors/state_change_builder.go b/internal/indexer/processors/state_change_builder.go index 9f462cf4..32832c1c 100644 --- a/internal/indexer/processors/state_change_builder.go +++ b/internal/indexer/processors/state_change_builder.go @@ -64,6 +64,12 @@ func (b *StateChangeBuilder) WithSigner(signer string, weights map[string]any) * return b } +// WithDeployer sets the deployer account ID, usually associated with a contract deployment. +func (b *StateChangeBuilder) WithDeployer(deployer string) *StateChangeBuilder { + b.base.DeployerAccountID = utils.SQLNullString(deployer) + return b +} + // WithSponsor sets the sponsor func (b *StateChangeBuilder) WithSponsor(sponsor string) *StateChangeBuilder { b.base.SponsorAccountID = utils.SQLNullString(sponsor) diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index 191a6f4a..d6d3948b 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -170,6 +170,7 @@ type StateChange struct { SpenderAccountID sql.NullString `json:"spenderAccountId,omitempty" db:"spender_account_id"` SponsoredAccountID sql.NullString `json:"sponsoredAccountId,omitempty" db:"sponsored_account_id"` SponsorAccountID sql.NullString `json:"sponsorAccountId,omitempty" db:"sponsor_account_id"` + DeployerAccountID sql.NullString `json:"deployerAccountId,omitempty" db:"deployer_account_id"` // Nullable JSONB fields: // TODO: update from `NullableJSONB` to custom objects, except for KeyValue. SignerWeights NullableJSONB `json:"signerWeights,omitempty" db:"signer_weights"` Thresholds NullableJSONB `json:"thresholds,omitempty" db:"thresholds"`