diff --git a/universalClient/core/client.go b/universalClient/core/client.go index 965e7c20..cf1cdb93 100644 --- a/universalClient/core/client.go +++ b/universalClient/core/client.go @@ -31,7 +31,7 @@ type UniversalClient struct { pushCore *pushcore.Client pushSigner *pushsigner.Signer chains *externalchains.Chains - pushChain *pushwatcher.Client + pushWatcher *pushwatcher.Client tssNode *tss.Node } @@ -70,21 +70,23 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie chainsManager := externalchains.NewChains(pushCore, pushSigner, cfg, log) - // Push chain DB is shared by the push chain client and the TSS node. + // Push chain DB is shared by the push watcher and the TSS node. pushDB, err := openPushDB(cfg) if err != nil { return nil, err } - pushChain, err := pushwatcher.NewClient( + pushWatcher, err := pushwatcher.NewClient( pushDB, cfg.GetChainConfig(cfg.PushChainID), pushCore, cfg.PushChainID, log, + pushSigner, + chainsManager, ) if err != nil { - return nil, fmt.Errorf("failed to create push chain client: %w", err) + return nil, fmt.Errorf("failed to create push watcher: %w", err) } tssNode, err := initTSS(ctx, cfg, pushCore, chainsManager, pushSigner, pushDB, log) @@ -102,7 +104,7 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie pushCore: pushCore, pushSigner: pushSigner, chains: chainsManager, - pushChain: pushChain, + pushWatcher: pushWatcher, tssNode: tssNode, }, nil } @@ -130,8 +132,8 @@ func (uc *UniversalClient) Start() error { return fmt.Errorf("failed to start chains manager: %w", err) } - if err := uc.pushChain.Start(uc.ctx); err != nil { - return fmt.Errorf("failed to start push chain client: %w", err) + if err := uc.pushWatcher.Start(uc.ctx); err != nil { + return fmt.Errorf("failed to start push watcher: %w", err) } if uc.tssNode != nil { @@ -166,9 +168,9 @@ func (uc *UniversalClient) shutdown() { } } - if uc.pushChain != nil { - if err := uc.pushChain.Stop(); err != nil { - uc.log.Error().Err(err).Str("subsystem", "push_chain").Msg("subsystem failed to stop") + if uc.pushWatcher != nil { + if err := uc.pushWatcher.Stop(); err != nil { + uc.log.Error().Err(err).Str("subsystem", "push_watcher").Msg("subsystem failed to stop") } } diff --git a/universalClient/externalchains/chains_test.go b/universalClient/externalchains/chains_test.go index 20ed9d84..9426e0a8 100644 --- a/universalClient/externalchains/chains_test.go +++ b/universalClient/externalchains/chains_test.go @@ -413,6 +413,9 @@ type mockChainClient struct { func (m *mockChainClient) Start(ctx context.Context) error { m.startCalled = true; return nil } func (m *mockChainClient) Stop() error { m.stopCalled = true; return m.stopErr } func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return nil, nil } diff --git a/universalClient/externalchains/common/chain_store.go b/universalClient/externalchains/common/chain_store.go index b67bd1f8..b711087c 100644 --- a/universalClient/externalchains/common/chain_store.go +++ b/universalClient/externalchains/common/chain_store.go @@ -154,24 +154,6 @@ func (cs *ChainStore) UpdateStatusAndEventData(eventID, oldStatus, newStatus str return res.RowsAffected, nil } -// UpdateVoteTxHash updates the vote_tx_hash field for an event -func (cs *ChainStore) UpdateVoteTxHash(eventID string, voteTxHash string) error { - if cs.database == nil { - return fmt.Errorf("database is nil") - } - - result := cs.database.Client(). - Model(&store.Event{}). - Where("event_id = ?", eventID). - Update("vote_tx_hash", voteTxHash) - - if result.Error != nil { - return fmt.Errorf("failed to update vote_tx_hash: %w", result.Error) - } - - return nil -} - // DeleteTerminalEvents deletes events in terminal states (COMPLETED, REVERTED, EXPIRED) // that were updated before the given time func (cs *ChainStore) DeleteTerminalEvents(updatedBefore any) (int64, error) { diff --git a/universalClient/externalchains/common/chain_store_test.go b/universalClient/externalchains/common/chain_store_test.go index a3b80989..bc413cac 100644 --- a/universalClient/externalchains/common/chain_store_test.go +++ b/universalClient/externalchains/common/chain_store_test.go @@ -56,12 +56,6 @@ func TestChainStoreNilDatabase(t *testing.T) { assert.Contains(t, err.Error(), "database is nil") }) - t.Run("UpdateVoteTxHash returns error for nil database", func(t *testing.T) { - err := store.UpdateVoteTxHash("event-1", "0x123") - require.Error(t, err) - assert.Contains(t, err.Error(), "database is nil") - }) - t.Run("InsertEventIfNotExists returns error for nil database", func(t *testing.T) { inserted, err := store.InsertEventIfNotExists(nil) require.Error(t, err) @@ -222,23 +216,6 @@ func TestChainStore_UpdateStatusAndEventData(t *testing.T) { assert.Equal(t, int64(1), rows) } -func TestChainStore_UpdateVoteTxHash(t *testing.T) { - cs := newTestChainStore(t) - - event := &storemodels.Event{ - EventID: "evt-5", - BlockHeight: 50, - Type: storemodels.EventTypeOutbound, - ConfirmationType: storemodels.ConfirmationStandard, - Status: storemodels.StatusConfirmed, - } - _, err := cs.InsertEventIfNotExists(event) - require.NoError(t, err) - - err = cs.UpdateVoteTxHash("evt-5", "0xvotehash") - require.NoError(t, err) -} - func TestChainStore_GetPendingEventsLimit(t *testing.T) { cs := newTestChainStore(t) diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index 3625d306..280ccf40 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -3,54 +3,67 @@ package common import ( "context" "encoding/hex" - "encoding/json" "fmt" - "strconv" "strings" "sync" "time" "github.com/mr-tron/base58" "github.com/pushchain/push-chain-node/universalClient/db" - "github.com/pushchain/push-chain-node/universalClient/pushsigner" "github.com/pushchain/push-chain-node/universalClient/store" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" "github.com/rs/zerolog" ) -// EventProcessor processes events from the chain's database and votes on them +const eventProcessBatchSize = 1000 + +// VoteSigner is the subset of pushsigner.Signer used by the event processors. +// Defined here (consumer-side) so tests can provide mock implementations. +type VoteSigner interface { + VoteInbound(ctx context.Context, inbound *uexecutortypes.Inbound) (string, error) + VoteOutbound(ctx context.Context, txID string, utxID string, observation *uexecutortypes.OutboundObservation) (string, error) +} + +// EventHandler processes one CONFIRMED event of a registered type. +// Handlers own the event's status transitions; a returned error is logged and +// the event is retried next tick. +type EventHandler interface { + HandleEvent(ctx context.Context, event *store.Event) error +} + +// EventProcessor drains CONFIRMED events from the chain's database and +// dispatches them to the handler registered for their type. Event types +// without a handler are ignored. type EventProcessor struct { - signer *pushsigner.Signer - chainStore *ChainStore - logger zerolog.Logger - chainID string - inboundEnabled bool - outboundEnabled bool - running bool - stopCh chan struct{} - wg sync.WaitGroup + chainStore *ChainStore + handlers map[string]EventHandler + chainID string + logger zerolog.Logger + running bool + stopCh chan struct{} + wg sync.WaitGroup } -// NewEventProcessor creates a new event processor +// NewEventProcessor creates a new event processor. Register handlers before Start. func NewEventProcessor( - signer *pushsigner.Signer, database *db.DB, chainID string, - inboundEnabled bool, - outboundEnabled bool, logger zerolog.Logger, ) *EventProcessor { return &EventProcessor{ - signer: signer, - chainStore: NewChainStore(database), - chainID: chainID, - inboundEnabled: inboundEnabled, - outboundEnabled: outboundEnabled, - logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), - stopCh: make(chan struct{}), + chainStore: NewChainStore(database), + handlers: make(map[string]EventHandler), + chainID: chainID, + logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), + stopCh: make(chan struct{}), } } +// RegisterHandler registers a handler for an event type. Must be called before Start. +func (ep *EventProcessor) RegisterHandler(eventType string, handler EventHandler) { + ep.handlers[eventType] = handler +} + // Start begins processing events func (ep *EventProcessor) Start(ctx context.Context) error { if ep.running { @@ -103,7 +116,6 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { ep.logger.Debug().Msg("stop signal received, stopping event processor") return case <-ticker.C: - // Fetch 1000 CONFIRMED events and process them if err := ep.processConfirmedEvents(ctx); err != nil { ep.logger.Error().Err(err).Msg("failed to process confirmed events") } @@ -111,123 +123,43 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { } } -// processConfirmedEvents processes confirmed events (both inbound and outbound) +// processConfirmedEvents dispatches CONFIRMED events to their registered handlers. func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { - events, err := ep.chainStore.GetConfirmedEvents(1000) + events, err := ep.chainStore.GetConfirmedEvents(eventProcessBatchSize) if err != nil { return fmt.Errorf("failed to get confirmed events: %w", err) } for _, event := range events { - if event.Type == store.EventTypeInbound { - if !ep.inboundEnabled { - ep.logger.Warn().Str("event_id", event.EventID).Msg("inbound disabled, skipping inbound event processing") - continue - } - if err := ep.processInboundEvent(ctx, &event); err != nil { - ep.logger.Error(). - Err(err). - Str("event_id", event.EventID). - Msg("failed to vote on inbound event") - continue - } - } else if event.Type == store.EventTypeOutbound { - if !ep.outboundEnabled { - ep.logger.Warn().Str("event_id", event.EventID).Msg("outbound disabled, skipping outbound event processing") - continue - } - if err := ep.processOutboundEvent(ctx, &event); err != nil { - ep.logger.Error(). - Err(err). - Str("event_id", event.EventID). - Msg("failed to vote on outbound event") - continue - } + handler, ok := ep.handlers[event.Type] + if !ok { + continue } - } - - return nil -} - -// processOutboundEvent processes an outbound event by voting on it -func (ep *EventProcessor) processOutboundEvent(ctx context.Context, event *store.Event) error { - ep.logger.Debug(). - Str("event_id", event.EventID). - Msg("processing outbound event") - - // Parse outbound event data once - outboundData, err := ep.parseOutboundEventData(event) - if err != nil { - return fmt.Errorf("failed to parse outbound event data: %w", err) - } - - txID := outboundData.TxID - utxID := outboundData.UniversalTxID - - // Build observation from parsed data - observation, err := ep.buildOutboundObservation(event, outboundData) - if err != nil { - return fmt.Errorf("failed to build outbound observation: %w", err) - } - - // Vote on outbound - voteTxHash, err := ep.signer.VoteOutbound(ctx, txID, utxID, observation) - if err != nil { - return fmt.Errorf("failed to vote on outbound: %w", err) - } - // Atomically record vote hash and flip status in one DB write - rowsAffected, err := ep.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) - if err != nil { - return fmt.Errorf("failed to update event status and vote_tx_hash: %w", err) - } - - if rowsAffected == 0 { - return nil // already completed by another validator + if err := handler.HandleEvent(ctx, &event); err != nil { + ep.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Str("type", event.Type). + Msg("failed to process event") + } } - ep.logger.Info(). - Str("event_id", event.EventID). - Str("type", event.Type). - Str("vote_tx_hash", voteTxHash). - Msg("event marked as COMPLETED") - return nil } -// processInboundEvent processes an inbound event by voting on it and confirming it -func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store.Event) error { - ep.logger.Debug(). - Str("event_id", event.EventID). - Msg("processing inbound event") - - // Extract inbound data from event - inbound, err := ep.constructInbound(event) - if err != nil { - return fmt.Errorf("failed to construct inbound: %w", err) - } - - // Execute vote on blockchain - voteTxHash, err := ep.signer.VoteInbound(ctx, inbound) - if err != nil { - ep.logger.Error(). - Str("event_id", event.EventID). - Err(err). - Msg("failed to vote on event - keeping status for retry") - return err - } - - // Atomically record vote hash and flip status in one DB write - rowsAffected, err := ep.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) +// markEventCompleted atomically records the vote hash and flips CONFIRMED -> COMPLETED. +func markEventCompleted(chainStore *ChainStore, logger zerolog.Logger, event *store.Event, voteTxHash string) error { + rowsAffected, err := chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) if err != nil { return fmt.Errorf("failed to update event status after successful vote: %w", err) } if rowsAffected == 0 { - return nil // already completed by another validator + return nil // already completed } - ep.logger.Info(). + logger.Info(). Str("event_id", event.EventID). Str("type", event.Type). Str("vote_tx_hash", voteTxHash). @@ -236,86 +168,25 @@ func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store. return nil } -// constructInbound creates an Inbound message from event data -func (ep *EventProcessor) constructInbound(event *store.Event) (*uexecutortypes.Inbound, error) { - var eventData UniversalTx - - if event == nil { - return nil, fmt.Errorf("event is nil") - } - - if event.EventData == nil { - return nil, fmt.Errorf("event data is missing for event_id: %s", event.EventID) - } - - if err := json.Unmarshal(event.EventData, &eventData); err != nil { - return nil, fmt.Errorf("failed to unmarshal event data: %w", err) - } - - // Map txType from eventData to proper enum value - txType := uexecutortypes.TxType_UNSPECIFIED_TX - switch eventData.TxType { - case 0: - txType = uexecutortypes.TxType_GAS - case 1: - txType = uexecutortypes.TxType_GAS_AND_PAYLOAD - case 2: - txType = uexecutortypes.TxType_FUNDS - case 3: - txType = uexecutortypes.TxType_FUNDS_AND_PAYLOAD - default: - txType = uexecutortypes.TxType_UNSPECIFIED_TX - } - - // Extract txHash from EventID (format: "txHash:logIndex") +// eventTxHash extracts the tx hash from an EventID (format: "txHash:logIndex" +// or "signature:logIndex"), converting base58 signatures to 0x-prefixed hex. +// Falls back to the raw value if conversion fails. +func eventTxHash(eventID string) string { txHash := "" - parts := strings.Split(event.EventID, ":") + parts := strings.Split(eventID, ":") if len(parts) > 0 { txHash = parts[0] } - // Convert txHash to hex format if it's in base58 - txHashHex, err := ep.base58ToHex(txHash) + txHashHex, err := base58ToHex(txHash) if err != nil { - ep.logger.Warn(). - Str("tx_hash", txHash). - Err(err). - Msg("failed to convert txHash to hex, using original value") - txHashHex = txHash - } - - inboundMsg := &uexecutortypes.Inbound{ - SourceChain: eventData.SourceChain, - TxHash: txHashHex, - Sender: eventData.Sender, - Recipient: eventData.Recipient, - Amount: eventData.Amount, - AssetAddr: eventData.Token, - LogIndex: strconv.FormatUint(uint64(eventData.LogIndex), 10), - TxType: txType, - IsCEA: eventData.FromCEA, - RawPayload: eventData.RawPayload, - } - - // Set revert instructions if revert fund recipient is present - if eventData.RevertFundRecipient != "" { - inboundMsg.RevertInstructions = &uexecutortypes.RevertInstructions{ - FundRecipient: eventData.RevertFundRecipient, - } - } - - // Use event's VerificationData if present, otherwise fall back to txHash - if eventData.VerificationData == "" || eventData.VerificationData == "0x" { - inboundMsg.VerificationData = txHashHex - } else { - inboundMsg.VerificationData = eventData.VerificationData + return txHash } - - return inboundMsg, nil + return txHashHex } // base58ToHex converts a base58 encoded string to hex format (0x...) -func (ep *EventProcessor) base58ToHex(base58Str string) (string, error) { +func base58ToHex(base58Str string) (string, error) { if base58Str == "" { return "0x", nil } @@ -334,65 +205,3 @@ func (ep *EventProcessor) base58ToHex(base58Str string) (string, error) { // Convert to hex with 0x prefix return "0x" + hex.EncodeToString(decoded), nil } - -// parseOutboundEventData unmarshals event data into an OutboundEvent struct -func (ep *EventProcessor) parseOutboundEventData(event *store.Event) (*OutboundEvent, error) { - if event == nil { - return nil, fmt.Errorf("event is nil") - } - - if len(event.EventData) == 0 { - return nil, fmt.Errorf("event data is empty") - } - - var eventData OutboundEvent - if err := json.Unmarshal(event.EventData, &eventData); err != nil { - return nil, fmt.Errorf("failed to unmarshal event data: %w", err) - } - - if eventData.TxID == "" { - return nil, fmt.Errorf("tx_id not found in event data") - } - - if eventData.UniversalTxID == "" { - return nil, fmt.Errorf("universal_tx_id not found in event data") - } - - return &eventData, nil -} - -// buildOutboundObservation builds an OutboundObservation from event metadata and parsed outbound data -func (ep *EventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundEvent) (*uexecutortypes.OutboundObservation, error) { - // Extract txHash from EventID (format: "txHash:logIndex" or "signature:logIndex") - txHash := "" - parts := strings.Split(event.EventID, ":") - if len(parts) > 0 { - txHash = parts[0] - } - - // Convert txHash to hex format if it's in base58 - txHashHex, err := ep.base58ToHex(txHash) - if err != nil { - ep.logger.Warn(). - Str("tx_hash", txHash). - Err(err). - Msg("failed to convert txHash to hex, using original value") - txHashHex = txHash - } - - gasFeeUsed := "0" - if outboundData.GasFeeUsed != "" { - gasFeeUsed = outboundData.GasFeeUsed - } - - observation := &uexecutortypes.OutboundObservation{ - Success: true, - BlockHeight: event.BlockHeight, - TxHash: txHashHex, - ErrorMsg: "", - GasFeeUsed: gasFeeUsed, - Pc20WrapperAddress: outboundData.Pc20WrapperAddress, - } - - return observation, nil -} diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index 4a08c305..df8a1f12 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -2,7 +2,8 @@ package common import ( "context" - "encoding/json" + "fmt" + "math/big" "testing" "time" @@ -15,1042 +16,256 @@ import ( uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -func TestNewEventProcessor(t *testing.T) { - t.Run("creates event processor with valid params", func(t *testing.T) { - logger := zerolog.Nop() - chainID := "eip155:1" - - processor := NewEventProcessor(nil, nil, chainID, true, true, logger) - - require.NotNil(t, processor) - assert.Equal(t, chainID, processor.chainID) - assert.False(t, processor.running) - assert.NotNil(t, processor.stopCh) - assert.NotNil(t, processor.chainStore) - }) -} - -func TestEventProcessorIsRunning(t *testing.T) { - t.Run("returns false when not running", func(t *testing.T) { - processor := &EventProcessor{running: false} - assert.False(t, processor.IsRunning()) - }) - - t.Run("returns true when running", func(t *testing.T) { - processor := &EventProcessor{running: true} - assert.True(t, processor.IsRunning()) - }) +type fakeVoteSigner struct { + inboundVotes int + outboundVotes int + txHash string + err error } -func TestEventProcessorStop(t *testing.T) { - t.Run("stop when not running returns nil", func(t *testing.T) { - processor := &EventProcessor{running: false} - err := processor.Stop() - assert.NoError(t, err) - }) +func (f *fakeVoteSigner) VoteInbound(ctx context.Context, inbound *uexecutortypes.Inbound) (string, error) { + if f.err != nil { + return "", f.err + } + f.inboundVotes++ + return f.txHash, nil } -func TestEventProcessorBase58ToHex(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "test-chain", true, true, logger) - - t.Run("empty string returns 0x", func(t *testing.T) { - result, err := processor.base58ToHex("") - require.NoError(t, err) - assert.Equal(t, "0x", result) - }) - - t.Run("already hex returns as is", func(t *testing.T) { - input := "0xabcdef1234567890" - result, err := processor.base58ToHex(input) - require.NoError(t, err) - assert.Equal(t, input, result) - }) - - t.Run("valid base58 converts to hex", func(t *testing.T) { - // "3yZe7d" is base58 for bytes [1, 2, 3, 4] - input := "2VfUX" - result, err := processor.base58ToHex(input) - require.NoError(t, err) - assert.True(t, len(result) > 2) - assert.Equal(t, "0x", result[:2]) - }) - - t.Run("invalid base58 returns error", func(t *testing.T) { - // Base58 doesn't include 0, O, I, l - input := "0OIl" - _, err := processor.base58ToHex(input) - require.Error(t, err) - }) +func (f *fakeVoteSigner) VoteOutbound(ctx context.Context, txID string, utxID string, observation *uexecutortypes.OutboundObservation) (string, error) { + if f.err != nil { + return "", f.err + } + f.outboundVotes++ + return f.txHash, nil } -func TestEventProcessorConstructInbound(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("nil event returns error", func(t *testing.T) { - inbound, err := processor.constructInbound(nil) - require.Error(t, err) - assert.Nil(t, inbound) - assert.Contains(t, err.Error(), "event is nil") - }) - - t.Run("nil event data returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "0x123:0", - EventData: nil, - } - inbound, err := processor.constructInbound(event) - require.Error(t, err) - assert.Nil(t, inbound) - assert.Contains(t, err.Error(), "event data is missing") - }) - - t.Run("invalid JSON returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "0x123:0", - EventData: []byte("invalid json"), - } - inbound, err := processor.constructInbound(event) - require.Error(t, err) - assert.Nil(t, inbound) - }) - - t.Run("valid event data constructs inbound", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - LogIndex: 5, - Sender: "0xsender123", - Recipient: "push1recipient", - Token: "0xtoken", - Amount: "1000000", - TxType: 2, // FUNDS - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xabc123:5", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - require.NotNil(t, inbound) - assert.Equal(t, "eip155:1", inbound.SourceChain) - assert.Equal(t, "0xsender123", inbound.Sender) - assert.Equal(t, "1000000", inbound.Amount) - assert.Equal(t, uexecutortypes.TxType_FUNDS, inbound.TxType) - }) - - t.Run("passes all fields unconditionally to inbound", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - LogIndex: 3, - Sender: "0xsender", - Recipient: "0xrecipient", - Token: "0xtoken", - Amount: "500", - RawPayload: "0xdeadbeef", - VerificationData: "0xsigdata", - RevertFundRecipient: "0xrevert", - TxType: 3, // FUNDS_AND_PAYLOAD - FromCEA: true, - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xtxhash:3", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - require.NotNil(t, inbound) - assert.Equal(t, "0xrecipient", inbound.Recipient) - assert.Equal(t, "0xdeadbeef", inbound.RawPayload) - assert.Equal(t, "0xsigdata", inbound.VerificationData) - assert.True(t, inbound.IsCEA) - require.NotNil(t, inbound.RevertInstructions) - assert.Equal(t, "0xrevert", inbound.RevertInstructions.FundRecipient) - }) - - t.Run("passes raw payload and verification data for non-payload tx types", func(t *testing.T) { - // Core will strip these — UV just passes everything through - eventData := UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Recipient: "0xrecipient", - Amount: "1000", - RawPayload: "0xcafe", - VerificationData: "0xsig", - TxType: 2, // FUNDS (non-payload type) - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xhash:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Equal(t, "0xrecipient", inbound.Recipient) - assert.Equal(t, "0xcafe", inbound.RawPayload) - assert.Equal(t, "0xsig", inbound.VerificationData) - }) - - t.Run("no revert instructions when revert recipient is empty", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Amount: "100", - TxType: 0, // GAS - RevertFundRecipient: "", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xhash:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Nil(t, inbound.RevertInstructions) - }) - - t.Run("tx type mapping", func(t *testing.T) { - testCases := []struct { - txType uint - expected uexecutortypes.TxType - }{ - {0, uexecutortypes.TxType_GAS}, - {1, uexecutortypes.TxType_GAS_AND_PAYLOAD}, - {2, uexecutortypes.TxType_FUNDS}, - {3, uexecutortypes.TxType_FUNDS_AND_PAYLOAD}, - {99, uexecutortypes.TxType_UNSPECIFIED_TX}, // Unknown defaults to unspecified - } - - for _, tc := range testCases { - eventData := UniversalTx{ - SourceChain: "eip155:1", - TxType: tc.txType, - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Equal(t, tc.expected, inbound.TxType, "TxType %d should map to %v", tc.txType, tc.expected) - } - }) +type fakeEventHandler struct { + handled []string + err error } -func TestEventProcessorParseOutboundEventData(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("nil event returns error", func(t *testing.T) { - data, err := processor.parseOutboundEventData(nil) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "event is nil") - }) - - t.Run("empty event data returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "test", - EventData: []byte{}, - } - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "event data is empty") - }) - - t.Run("valid outbound event extracts IDs and gas fee", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - GasFeeUsed: "42000000000000", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.NoError(t, err) - assert.Equal(t, "0x1234", data.TxID) - assert.Equal(t, "0xabcd", data.UniversalTxID) - assert.Equal(t, "42000000000000", data.GasFeeUsed) - }) - - t.Run("missing tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "", - UniversalTxID: "0xabcd", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "tx_id not found") - }) - - t.Run("missing universal_tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "universal_tx_id not found") - }) +func (f *fakeEventHandler) HandleEvent(ctx context.Context, event *store.Event) error { + f.handled = append(f.handled, event.EventID) + return f.err } -func TestEventProcessorBuildOutboundObservation(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - GasFeeUsed: "42000000000000", - } - - event := &store.Event{ - EventID: "0xabc123:5", - BlockHeight: 12345, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.True(t, obs.Success) - assert.Equal(t, uint64(12345), obs.BlockHeight) - assert.Equal(t, "0xabc123", obs.TxHash) - assert.Equal(t, "42000000000000", obs.GasFeeUsed) - }) - - t.Run("missing gas fee defaults to 0", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - } - - event := &store.Event{ - EventID: "0xabc123:5", - BlockHeight: 12345, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.Equal(t, "0", obs.GasFeeUsed) - }) - - t.Run("handles base58 tx hash", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - } - - event := &store.Event{ - EventID: "2VfUX:0", // Base58 encoded - BlockHeight: 100, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.True(t, len(obs.TxHash) >= 2) - }) +func newTestDB(t *testing.T) *ucdb.DB { + t.Helper() + database, err := ucdb.OpenInMemoryDB(true) + require.NoError(t, err) + t.Cleanup(func() { _ = database.Close() }) + return database } -func TestProcessOutboundEvent(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - setupDB := func(t *testing.T) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - return database - } - - t.Run("nil event data returns parse error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: nil, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - t.Run("empty event data returns parse error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte{}, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - t.Run("invalid JSON event data returns parse error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte("not json"), - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - t.Run("missing tx_id returns parse error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "", - UniversalTxID: "0xutxid", - }) - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventData, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - t.Run("missing universal_tx_id returns parse error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "0xtxid", - UniversalTxID: "", - }) - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventData, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") +func seedConfirmedEvent(t *testing.T, database *ucdb.DB, eventID, eventType string, eventData []byte) { + t.Helper() + result := database.Client().Create(&store.Event{ + EventID: eventID, + Type: eventType, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, }) + require.NoError(t, result.Error) } -func TestProcessInboundEvent(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - setupDB := func(t *testing.T) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - return database - } - - t.Run("nil event data returns construct error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: nil, - } - err := ep.processInboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to construct inbound") - }) - - t.Run("invalid JSON event data returns construct error", func(t *testing.T) { - database := setupDB(t) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte("{not valid json}"), - } - err := ep.processInboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to construct inbound") - }) +func TestNewEventProcessor(t *testing.T) { + processor := NewEventProcessor(nil, "eip155:1", zerolog.Nop()) + + require.NotNil(t, processor) + assert.Equal(t, "eip155:1", processor.chainID) + assert.False(t, processor.running) + assert.NotNil(t, processor.stopCh) + assert.NotNil(t, processor.chainStore) + assert.Empty(t, processor.handlers) } -func TestProcessConfirmedEventsRouting(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - setupDB := func(t *testing.T, events []store.Event) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - for _, e := range events { - result := database.Client().Create(&e) - require.NoError(t, result.Error) - } - return database - } - - t.Run("no confirmed events returns nil", func(t *testing.T) { - database := setupDB(t, nil) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) +func TestEventProcessor_DispatchesByType(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - }) - - t.Run("only pending events are ignored", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xpending:0", - Status: store.StatusPending, - Type: store.EventTypeInbound, - EventData: []byte("{}"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Event should remain PENDING (not picked up) - var evt store.Event - database.Client().Where("event_id = ?", "0xpending:0").First(&evt) - assert.Equal(t, store.StatusPending, evt.Status) - }) - - t.Run("inbound with bad data fails gracefully and continues to next event", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xbad_inbound:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: []byte("not json"), - }, - { - EventID: "0xbad_inbound2:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: nil, - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - // Should not return error - errors on individual events are logged and skipped - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Both events should remain CONFIRMED (failed to process, not updated) - var evt1, evt2 store.Event - database.Client().Where("event_id = ?", "0xbad_inbound:0").First(&evt1) - assert.Equal(t, store.StatusConfirmed, evt1.Status) - database.Client().Where("event_id = ?", "0xbad_inbound2:0").First(&evt2) - assert.Equal(t, store.StatusConfirmed, evt2.Status) - }) - - t.Run("outbound with bad data fails gracefully and continues to next event", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xbad_outbound:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte("not json"), - }, - { - EventID: "0xbad_outbound2:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte{}, - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Both events should remain CONFIRMED - var evt1, evt2 store.Event - database.Client().Where("event_id = ?", "0xbad_outbound:0").First(&evt1) - assert.Equal(t, store.StatusConfirmed, evt1.Status) - database.Client().Where("event_id = ?", "0xbad_outbound2:0").First(&evt2) - assert.Equal(t, store.StatusConfirmed, evt2.Status) - }) - - t.Run("mixed inbound and outbound with bad data both fail gracefully", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xin:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: []byte("bad"), - }, - { - EventID: "0xout:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte("bad"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + inboundHandler := &fakeEventHandler{} + ep.RegisterHandler(store.EventTypeInbound, inboundHandler) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - var inEvt, outEvt store.Event - database.Client().Where("event_id = ?", "0xin:0").First(&inEvt) - assert.Equal(t, store.StatusConfirmed, inEvt.Status) - database.Client().Where("event_id = ?", "0xout:0").First(&outEvt) - assert.Equal(t, store.StatusConfirmed, outEvt.Status) - }) - - t.Run("outbound missing tx_id in valid JSON stays CONFIRMED", func(t *testing.T) { - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "", - UniversalTxID: "0xutxid", - }) - database := setupDB(t, []store.Event{ - { - EventID: "0xno_txid:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: eventData, - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - var evt store.Event - database.Client().Where("event_id = ?", "0xno_txid:0").First(&evt) - assert.Equal(t, store.StatusConfirmed, evt.Status) - }) - - t.Run("unknown event type is silently skipped", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xunknown:0", - Status: store.StatusConfirmed, - Type: "UNKNOWN_TYPE", - EventData: []byte("{}"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, []byte("{}")) + seedConfirmedEvent(t, database, "0xout:0", store.EventTypeOutbound, []byte("{}")) // no handler registered - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + require.NoError(t, ep.processConfirmedEvents(context.Background())) - // Event should remain CONFIRMED (no handler for this type) - var evt store.Event - database.Client().Where("event_id = ?", "0xunknown:0").First(&evt) - assert.Equal(t, store.StatusConfirmed, evt.Status) - }) + assert.Equal(t, []string{"0xin:0"}, inboundHandler.handled) } -func TestProcessLoopContextCancellation(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - t.Run("processLoop exits promptly on context cancel", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - err := ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - // Cancel context and wait for stop - cancel() +func TestEventProcessor_HandlerErrorKeepsProcessing(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - // The wg.Wait inside Stop() will block until processLoop exits - done := make(chan struct{}) - go func() { - ep.Stop() - close(done) - }() + failing := &fakeEventHandler{err: fmt.Errorf("boom")} + ep.RegisterHandler(store.EventTypeInbound, failing) - select { - case <-done: - // processLoop exited within reasonable time - case <-time.After(10 * time.Second): - t.Fatal("processLoop did not exit within 10 seconds after context cancellation") - } + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, []byte("{}")) + seedConfirmedEvent(t, database, "0xin:1", store.EventTypeInbound, []byte("{}")) - assert.False(t, ep.IsRunning()) - }) -} + require.NoError(t, ep.processConfirmedEvents(context.Background())) -func TestProcessLoopStopChannel(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) + // both attempted despite errors, both still CONFIRMED for retry + assert.Len(t, failing.handled, 2) + events, err := NewChainStore(database).GetConfirmedEvents(10) require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - t.Run("processLoop exits promptly on stop signal", func(t *testing.T) { - ctx := context.Background() - - err := ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - done := make(chan struct{}) - go func() { - ep.Stop() - close(done) - }() - - select { - case <-done: - // processLoop exited promptly - case <-time.After(10 * time.Second): - t.Fatal("processLoop did not exit within 10 seconds after stop signal") - } - - assert.False(t, ep.IsRunning()) - }) -} - -func TestProcessConfirmedEventsDBError(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - t.Run("nil database returns error", func(t *testing.T) { - ep := &EventProcessor{ - chainStore: NewChainStore(nil), - logger: logger, - chainID: "eip155:1", - inboundEnabled: true, - outboundEnabled: true, - } - - err := ep.processConfirmedEvents(ctx) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get confirmed events") - }) -} - -func TestEventProcessorStruct(t *testing.T) { - t.Run("struct has expected fields", func(t *testing.T) { - ep := &EventProcessor{} - assert.Nil(t, ep.signer) - assert.Nil(t, ep.chainStore) - assert.Empty(t, ep.chainID) - assert.False(t, ep.running) - assert.Nil(t, ep.stopCh) - assert.False(t, ep.inboundEnabled) - assert.False(t, ep.outboundEnabled) - }) + assert.Len(t, events, 2) } -func TestNewEventProcessorEnabledFlags(t *testing.T) { - logger := zerolog.Nop() +func TestEventProcessor_PendingEventsIgnored(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - t.Run("both enabled", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - assert.True(t, ep.inboundEnabled) - assert.True(t, ep.outboundEnabled) - }) + handler := &fakeEventHandler{} + ep.RegisterHandler(store.EventTypeInbound, handler) - t.Run("inbound only", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", true, false, logger) - assert.True(t, ep.inboundEnabled) - assert.False(t, ep.outboundEnabled) + result := database.Client().Create(&store.Event{ + EventID: "0xpending:0", + Type: store.EventTypeInbound, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusPending, + EventData: []byte("{}"), }) + require.NoError(t, result.Error) - t.Run("outbound only", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", false, true, logger) - assert.False(t, ep.inboundEnabled) - assert.True(t, ep.outboundEnabled) - }) + require.NoError(t, ep.processConfirmedEvents(context.Background())) - t.Run("both disabled", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", false, false, logger) - assert.False(t, ep.inboundEnabled) - assert.False(t, ep.outboundEnabled) - }) + assert.Empty(t, handler.handled) } -func TestEventProcessorStartDoubleStart(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // First start should succeed - err = ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) +func TestEventProcessor_NilDatabaseErrors(t *testing.T) { + ep := NewEventProcessor(nil, "eip155:1", zerolog.Nop()) + ep.RegisterHandler(store.EventTypeInbound, &fakeEventHandler{}) - // Second start should be rejected - err = ep.Start(ctx) + err := ep.processConfirmedEvents(context.Background()) require.Error(t, err) - assert.Contains(t, err.Error(), "already running") - assert.True(t, ep.IsRunning()) - - // Clean up - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) + assert.Contains(t, err.Error(), "failed to get confirmed events") } -func TestEventProcessorStopIdempotent(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) +func TestEventProcessor_Lifecycle(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start the processor - err = ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - // First stop - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) - - // Second stop should be idempotent (no error, no panic) - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) - - // Third stop also fine - err = ep.Stop() - require.NoError(t, err) -} - -func TestEventProcessorIsRunningStateTransitions(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - // Initial state: not running + // initial state assert.False(t, ep.IsRunning()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // After start: running - err = ep.Start(ctx) - require.NoError(t, err) + // start + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - // After stop: not running - err = ep.Stop() - require.NoError(t, err) + // double start rejected + err := ep.Start(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "already running") + + // stop, idempotent + require.NoError(t, ep.Stop()) assert.False(t, ep.IsRunning()) + require.NoError(t, ep.Stop()) - // Can restart after stop - err = ep.Start(ctx) - require.NoError(t, err) + // restart works + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - - // Clean up - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) + require.NoError(t, ep.Stop()) } -func TestEventProcessorStopViaContextCancel(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) +func TestEventProcessor_StopViaContextCancel(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) ctx, cancel := context.WithCancel(context.Background()) - - err = ep.Start(ctx) - require.NoError(t, err) + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - // Cancel context - the processLoop should exit cancel() - // Stop should still work cleanly after context cancellation - err = ep.Stop() - require.NoError(t, err) + done := make(chan struct{}) + go func() { + _ = ep.Stop() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("processLoop did not exit after context cancellation") + } assert.False(t, ep.IsRunning()) } -func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - // Helper to create an in-memory DB and seed confirmed events - setupDB := func(t *testing.T, events []store.Event) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) +func TestBase58ToHex(t *testing.T) { + t.Run("empty string returns 0x", func(t *testing.T) { + result, err := base58ToHex("") require.NoError(t, err) - for _, e := range events { - result := database.Client().Create(&e) - require.NoError(t, result.Error) - } - return database - } - - inboundEventData, _ := json.Marshal(UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Amount: "1000", - TxType: 2, + assert.Equal(t, "0x", result) }) - outboundEventData, _ := json.Marshal(OutboundEvent{ - TxID: "0xtxid", - UniversalTxID: "0xutxid", + t.Run("already hex returns as is", func(t *testing.T) { + input := "0xabcdef1234567890" + result, err := base58ToHex(input) + require.NoError(t, err) + assert.Equal(t, input, result) }) - makeEvents := func() []store.Event { - return []store.Event{ - { - EventID: "0xaaa:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: inboundEventData, - }, - { - EventID: "0xbbb:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: outboundEventData, - }, - } - } - - t.Run("inbound disabled skips inbound events, leaves them CONFIRMED", func(t *testing.T) { - database := setupDB(t, makeEvents()) - // inbound=false, outbound=false (no signer so outbound will also fail to vote, but that's ok) - ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) - - err := ep.processConfirmedEvents(ctx) + t.Run("valid base58 converts to hex", func(t *testing.T) { + result, err := base58ToHex("2VfUX") require.NoError(t, err) + assert.True(t, len(result) > 2) + assert.Equal(t, "0x", result[:2]) + }) - // Inbound event should still be CONFIRMED (skipped, not processed) - var inboundEvt store.Event - database.Client().Where("event_id = ?", "0xaaa:0").First(&inboundEvt) - assert.Equal(t, store.StatusConfirmed, inboundEvt.Status) + t.Run("invalid base58 returns error", func(t *testing.T) { + // Base58 doesn't include 0, O, I, l + _, err := base58ToHex("0OIl") + require.Error(t, err) }) +} - t.Run("outbound disabled skips outbound events, leaves them CONFIRMED", func(t *testing.T) { - database := setupDB(t, makeEvents()) - ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) +func TestEventTxHash(t *testing.T) { + t.Run("hex event id", func(t *testing.T) { + assert.Equal(t, "0xabc123", eventTxHash("0xabc123:5")) + }) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + t.Run("base58 event id converts", func(t *testing.T) { + got := eventTxHash("2VfUX:0") + assert.Equal(t, "0x", got[:2]) + }) - // Outbound event should still be CONFIRMED (skipped, not processed) - var outboundEvt store.Event - database.Client().Where("event_id = ?", "0xbbb:0").First(&outboundEvt) - assert.Equal(t, store.StatusConfirmed, outboundEvt.Status) + t.Run("invalid base58 falls back to raw value", func(t *testing.T) { + assert.Equal(t, "0OIl", eventTxHash("0OIl:0")) }) +} - t.Run("inbound enabled but outbound disabled skips only outbound", func(t *testing.T) { - // Seed only outbound events so we don't hit nil signer panic on inbound - database := setupDB(t, []store.Event{ - { - EventID: "0xbbb:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: outboundEventData, - }, - }) - ep := NewEventProcessor(nil, database, "eip155:1", true, false, logger) +func TestMarkEventCompleted(t *testing.T) { + database := newTestDB(t) + cs := NewChainStore(database) + seedConfirmedEvent(t, database, "0xdone:0", store.EventTypeInbound, []byte("{}")) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + event := &store.Event{EventID: "0xdone:0", Type: store.EventTypeInbound} + require.NoError(t, markEventCompleted(cs, zerolog.Nop(), event, "0xvote")) - // Outbound event should still be CONFIRMED (skipped due to outbound disabled) - var outboundEvt store.Event - database.Client().Where("event_id = ?", "0xbbb:0").First(&outboundEvt) - assert.Equal(t, store.StatusConfirmed, outboundEvt.Status) - }) + rows, err := cs.UpdateEventStatus("0xdone:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) - t.Run("outbound enabled but inbound disabled skips only inbound", func(t *testing.T) { - // Seed only inbound events so we don't hit nil signer panic on outbound - database := setupDB(t, []store.Event{ - { - EventID: "0xaaa:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: inboundEventData, - }, - }) - ep := NewEventProcessor(nil, database, "eip155:1", false, true, logger) + // already completed: no-op, no error + require.NoError(t, markEventCompleted(cs, zerolog.Nop(), event, "0xvote2")) +} - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) +func TestEncodeUint256Result(t *testing.T) { + out, err := EncodeUint256Result(big.NewInt(1_000_000)) + require.NoError(t, err) + require.Len(t, out, 32) + assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(out)) - // Inbound event should still be CONFIRMED (skipped due to inbound disabled) - var inboundEvt store.Event - database.Client().Where("event_id = ?", "0xaaa:0").First(&inboundEvt) - assert.Equal(t, store.StatusConfirmed, inboundEvt.Status) - }) + out, err = EncodeUint256Result(nil) + require.NoError(t, err) + assert.Equal(t, make([]byte, 32), out) + + _, err = EncodeUint256Result(big.NewInt(-1)) + assert.Error(t, err) } diff --git a/universalClient/externalchains/common/inbound_observation_event_processor.go b/universalClient/externalchains/common/inbound_observation_event_processor.go new file mode 100644 index 00000000..c72f8842 --- /dev/null +++ b/universalClient/externalchains/common/inbound_observation_event_processor.go @@ -0,0 +1,133 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + "github.com/rs/zerolog" +) + +// InboundObservation is the inbound observation payload stored for INBOUND events +type InboundObservation struct { + SourceChain string `json:"sourceChain"` + LogIndex uint `json:"logIndex"` + Sender string `json:"sender"` + Recipient string `json:"recipient"` + Token string `json:"bridgeToken"` + Amount string `json:"bridgeAmount"` // uint256 as decimal string + RawPayload string `json:"rawPayload,omitempty"` // hex-encoded raw payload bytes from source chain + VerificationData string `json:"verificationData"` + RevertFundRecipient string `json:"revertFundRecipient,omitempty"` + TxType uint `json:"txType"` // enum backing uint as decimal string + FromCEA bool `json:"fromCEA"` // true if inbound is initiated by a CEA +} + +// InboundObservationEventProcessor handles INBOUND events: it builds the +// inbound observation from the stored event and votes it on Push chain. +type InboundObservationEventProcessor struct { + signer VoteSigner + chainStore *ChainStore + logger zerolog.Logger +} + +// NewInboundObservationEventProcessor creates the handler for INBOUND events. +func NewInboundObservationEventProcessor( + signer VoteSigner, + database *db.DB, + logger zerolog.Logger, +) *InboundObservationEventProcessor { + return &InboundObservationEventProcessor{ + signer: signer, + chainStore: NewChainStore(database), + logger: logger.With().Str("component", "inbound_observation_event_processor").Logger(), + } +} + +// HandleEvent implements EventHandler for INBOUND events. +func (p *InboundObservationEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + p.logger.Debug(). + Str("event_id", event.EventID). + Msg("processing inbound event") + + // Extract inbound data from event + inbound, err := p.buildInboundObservation(event) + if err != nil { + return fmt.Errorf("failed to build inbound observation: %w", err) + } + + // Execute vote on blockchain + voteTxHash, err := p.signer.VoteInbound(ctx, inbound) + if err != nil { + return fmt.Errorf("failed to vote on inbound - keeping status for retry: %w", err) + } + + return markEventCompleted(p.chainStore, p.logger, event, voteTxHash) +} + +// buildInboundObservation builds an Inbound observation from event data +func (p *InboundObservationEventProcessor) buildInboundObservation(event *store.Event) (*uexecutortypes.Inbound, error) { + var eventData InboundObservation + + if event == nil { + return nil, fmt.Errorf("event is nil") + } + + if event.EventData == nil { + return nil, fmt.Errorf("event data is missing for event_id: %s", event.EventID) + } + + if err := json.Unmarshal(event.EventData, &eventData); err != nil { + return nil, fmt.Errorf("failed to unmarshal event data: %w", err) + } + + // Map txType from eventData to proper enum value + txType := uexecutortypes.TxType_UNSPECIFIED_TX + switch eventData.TxType { + case 0: + txType = uexecutortypes.TxType_GAS + case 1: + txType = uexecutortypes.TxType_GAS_AND_PAYLOAD + case 2: + txType = uexecutortypes.TxType_FUNDS + case 3: + txType = uexecutortypes.TxType_FUNDS_AND_PAYLOAD + default: + txType = uexecutortypes.TxType_UNSPECIFIED_TX + } + + txHashHex := eventTxHash(event.EventID) + + inboundMsg := &uexecutortypes.Inbound{ + SourceChain: eventData.SourceChain, + TxHash: txHashHex, + Sender: eventData.Sender, + Recipient: eventData.Recipient, + Amount: eventData.Amount, + AssetAddr: eventData.Token, + LogIndex: strconv.FormatUint(uint64(eventData.LogIndex), 10), + TxType: txType, + IsCEA: eventData.FromCEA, + RawPayload: eventData.RawPayload, + } + + // Set revert instructions if revert fund recipient is present + if eventData.RevertFundRecipient != "" { + inboundMsg.RevertInstructions = &uexecutortypes.RevertInstructions{ + FundRecipient: eventData.RevertFundRecipient, + } + } + + // Use event's VerificationData if present, otherwise fall back to txHash + if eventData.VerificationData == "" || eventData.VerificationData == "0x" { + inboundMsg.VerificationData = txHashHex + } else { + inboundMsg.VerificationData = eventData.VerificationData + } + + return inboundMsg, nil +} diff --git a/universalClient/externalchains/common/inbound_observation_event_processor_test.go b/universalClient/externalchains/common/inbound_observation_event_processor_test.go new file mode 100644 index 00000000..4abfe0ab --- /dev/null +++ b/universalClient/externalchains/common/inbound_observation_event_processor_test.go @@ -0,0 +1,214 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" +) + +func TestInboundBuildInboundObservation(t *testing.T) { + processor := NewInboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("nil event returns error", func(t *testing.T) { + inbound, err := processor.buildInboundObservation(nil) + require.Error(t, err) + assert.Nil(t, inbound) + assert.Contains(t, err.Error(), "event is nil") + }) + + t.Run("nil event data returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "0x123:0", + EventData: nil, + } + inbound, err := processor.buildInboundObservation(event) + require.Error(t, err) + assert.Nil(t, inbound) + assert.Contains(t, err.Error(), "event data is missing") + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "0x123:0", + EventData: []byte("invalid json"), + } + inbound, err := processor.buildInboundObservation(event) + require.Error(t, err) + assert.Nil(t, inbound) + }) + + t.Run("valid event data constructs inbound", func(t *testing.T) { + eventData := InboundObservation{ + SourceChain: "eip155:1", + LogIndex: 5, + Sender: "0xsender123", + Recipient: "push1recipient", + Token: "0xtoken", + Amount: "1000000", + TxType: 2, // FUNDS + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xabc123:5", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + require.NotNil(t, inbound) + assert.Equal(t, "eip155:1", inbound.SourceChain) + assert.Equal(t, "0xsender123", inbound.Sender) + assert.Equal(t, "1000000", inbound.Amount) + assert.Equal(t, "0xabc123", inbound.TxHash) + assert.Equal(t, uexecutortypes.TxType_FUNDS, inbound.TxType) + }) + + t.Run("passes all fields unconditionally to inbound", func(t *testing.T) { + eventData := InboundObservation{ + SourceChain: "eip155:1", + LogIndex: 3, + Sender: "0xsender", + Recipient: "0xrecipient", + Token: "0xtoken", + Amount: "500", + RawPayload: "0xdeadbeef", + VerificationData: "0xsigdata", + RevertFundRecipient: "0xrevert", + TxType: 3, // FUNDS_AND_PAYLOAD + FromCEA: true, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xtxhash:3", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + require.NotNil(t, inbound) + assert.Equal(t, "0xrecipient", inbound.Recipient) + assert.Equal(t, "0xdeadbeef", inbound.RawPayload) + assert.Equal(t, "0xsigdata", inbound.VerificationData) + assert.True(t, inbound.IsCEA) + require.NotNil(t, inbound.RevertInstructions) + assert.Equal(t, "0xrevert", inbound.RevertInstructions.FundRecipient) + }) + + t.Run("no revert instructions when revert recipient is empty", func(t *testing.T) { + eventData := InboundObservation{ + SourceChain: "eip155:1", + Sender: "0xsender", + Amount: "100", + TxType: 0, // GAS + RevertFundRecipient: "", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xhash:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Nil(t, inbound.RevertInstructions) + }) + + t.Run("falls back verification data to tx hash", func(t *testing.T) { + eventData := InboundObservation{ + SourceChain: "eip155:1", + VerificationData: "", + TxType: 0, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xhash:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Equal(t, "0xhash", inbound.VerificationData) + }) + + t.Run("tx type mapping", func(t *testing.T) { + testCases := []struct { + txType uint + expected uexecutortypes.TxType + }{ + {0, uexecutortypes.TxType_GAS}, + {1, uexecutortypes.TxType_GAS_AND_PAYLOAD}, + {2, uexecutortypes.TxType_FUNDS}, + {3, uexecutortypes.TxType_FUNDS_AND_PAYLOAD}, + {99, uexecutortypes.TxType_UNSPECIFIED_TX}, // Unknown defaults to unspecified + } + + for _, tc := range testCases { + eventData := InboundObservation{ + SourceChain: "eip155:1", + TxType: tc.txType, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xabc:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Equal(t, tc.expected, inbound.TxType, "TxType %d should map to %v", tc.txType, tc.expected) + } + }) +} + +func TestInboundHandleEvent(t *testing.T) { + ctx := context.Background() + + t.Run("construct failure returns error, event stays CONFIRMED", func(t *testing.T) { + database := newTestDB(t) + processor := NewInboundObservationEventProcessor(&fakeVoteSigner{txHash: "0xvote"}, database, zerolog.Nop()) + seedConfirmedEvent(t, database, "0xbad:0", store.EventTypeInbound, []byte("not json")) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xbad:0", EventData: []byte("not json")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to build inbound observation") + }) + + t.Run("vote failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewInboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) + eventData, _ := json.Marshal(InboundObservation{SourceChain: "eip155:1", TxType: 0}) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", EventData: eventData}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to vote on inbound") + }) + + t.Run("successful vote marks event completed", func(t *testing.T) { + database := newTestDB(t) + signer := &fakeVoteSigner{txHash: "0xvote"} + processor := NewInboundObservationEventProcessor(signer, database, zerolog.Nop()) + eventData, _ := json.Marshal(InboundObservation{SourceChain: "eip155:1", TxType: 0}) + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, eventData) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", Type: store.EventTypeInbound, EventData: eventData}) + require.NoError(t, err) + assert.Equal(t, 1, signer.inboundVotes) + + rows, err := NewChainStore(database).UpdateEventStatus("0xin:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + }) +} diff --git a/universalClient/externalchains/common/outbound_observation_event_processor.go b/universalClient/externalchains/common/outbound_observation_event_processor.go new file mode 100644 index 00000000..8ba4ee29 --- /dev/null +++ b/universalClient/externalchains/common/outbound_observation_event_processor.go @@ -0,0 +1,119 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + "github.com/rs/zerolog" +) + +// OutboundObservation is the outbound observation payload stored for OUTBOUND events +// Event structure: +// - txID at 1st indexed position (bytes32) +// - universalTxID at 2nd indexed position (bytes32) +type OutboundObservation struct { + TxID string `json:"tx_id"` // bytes32 hex-encoded (0x...) + UniversalTxID string `json:"universal_tx_id"` // bytes32 hex-encoded (0x...) + GasFeeUsed string `json:"gas_fee_used,omitempty"` // gas fee used in wei (decimal string) + // PC20 export only: wrapper token address deployed/minted on the destination + // at settlement (observed in the finalize event). Core uses it to flip the + // PC20 deploy flag; empty for non-PC20 settlements. + Pc20WrapperAddress string `json:"pc20_wrapper_address,omitempty"` +} + +// OutboundObservationEventProcessor handles OUTBOUND events: it builds the +// outbound observation from the stored event and votes it on Push chain. +type OutboundObservationEventProcessor struct { + signer VoteSigner + chainStore *ChainStore + logger zerolog.Logger +} + +// NewOutboundObservationEventProcessor creates the handler for OUTBOUND events. +func NewOutboundObservationEventProcessor( + signer VoteSigner, + database *db.DB, + logger zerolog.Logger, +) *OutboundObservationEventProcessor { + return &OutboundObservationEventProcessor{ + signer: signer, + chainStore: NewChainStore(database), + logger: logger.With().Str("component", "outbound_observation_event_processor").Logger(), + } +} + +// HandleEvent implements EventHandler for OUTBOUND events. +func (p *OutboundObservationEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + p.logger.Debug(). + Str("event_id", event.EventID). + Msg("processing outbound event") + + // Parse outbound event data once + outboundData, err := p.parseOutboundEventData(event) + if err != nil { + return fmt.Errorf("failed to parse outbound event data: %w", err) + } + + // Build observation from parsed data + observation, err := p.buildOutboundObservation(event, outboundData) + if err != nil { + return fmt.Errorf("failed to build outbound observation: %w", err) + } + + // Vote on outbound + voteTxHash, err := p.signer.VoteOutbound(ctx, outboundData.TxID, outboundData.UniversalTxID, observation) + if err != nil { + return fmt.Errorf("failed to vote on outbound: %w", err) + } + + return markEventCompleted(p.chainStore, p.logger, event, voteTxHash) +} + +// parseOutboundEventData unmarshals event data into an OutboundObservation struct +func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store.Event) (*OutboundObservation, error) { + if event == nil { + return nil, fmt.Errorf("event is nil") + } + + if len(event.EventData) == 0 { + return nil, fmt.Errorf("event data is empty") + } + + var eventData OutboundObservation + if err := json.Unmarshal(event.EventData, &eventData); err != nil { + return nil, fmt.Errorf("failed to unmarshal event data: %w", err) + } + + if eventData.TxID == "" { + return nil, fmt.Errorf("tx_id not found in event data") + } + + if eventData.UniversalTxID == "" { + return nil, fmt.Errorf("universal_tx_id not found in event data") + } + + return &eventData, nil +} + +// buildOutboundObservation builds an OutboundObservation from event metadata and parsed outbound data +func (p *OutboundObservationEventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundObservation) (*uexecutortypes.OutboundObservation, error) { + gasFeeUsed := "0" + if outboundData.GasFeeUsed != "" { + gasFeeUsed = outboundData.GasFeeUsed + } + + observation := &uexecutortypes.OutboundObservation{ + Success: true, + BlockHeight: event.BlockHeight, + TxHash: eventTxHash(event.EventID), + ErrorMsg: "", + GasFeeUsed: gasFeeUsed, + Pc20WrapperAddress: outboundData.Pc20WrapperAddress, + } + + return observation, nil +} diff --git a/universalClient/externalchains/common/outbound_observation_event_processor_test.go b/universalClient/externalchains/common/outbound_observation_event_processor_test.go new file mode 100644 index 00000000..af3db22c --- /dev/null +++ b/universalClient/externalchains/common/outbound_observation_event_processor_test.go @@ -0,0 +1,191 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/store" +) + +func TestOutboundParseOutboundEventData(t *testing.T) { + processor := NewOutboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("nil event returns error", func(t *testing.T) { + data, err := processor.parseOutboundEventData(nil) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "event is nil") + }) + + t.Run("empty event data returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "test", + EventData: []byte{}, + } + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "event data is empty") + }) + + t.Run("valid outbound event extracts IDs and gas fee", func(t *testing.T) { + eventData := OutboundObservation{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + GasFeeUsed: "42000000000000", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.NoError(t, err) + assert.Equal(t, "0x1234", data.TxID) + assert.Equal(t, "0xabcd", data.UniversalTxID) + assert.Equal(t, "42000000000000", data.GasFeeUsed) + }) + + t.Run("missing tx_id returns error", func(t *testing.T) { + eventData := OutboundObservation{ + TxID: "", + UniversalTxID: "0xabcd", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "tx_id not found") + }) + + t.Run("missing universal_tx_id returns error", func(t *testing.T) { + eventData := OutboundObservation{ + TxID: "0x1234", + UniversalTxID: "", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "universal_tx_id not found") + }) +} + +func TestOutboundBuildOutboundObservation(t *testing.T) { + processor := NewOutboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { + outboundData := &OutboundObservation{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + GasFeeUsed: "42000000000000", + } + + event := &store.Event{ + EventID: "0xabc123:5", + BlockHeight: 12345, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.True(t, obs.Success) + assert.Equal(t, uint64(12345), obs.BlockHeight) + assert.Equal(t, "0xabc123", obs.TxHash) + assert.Equal(t, "42000000000000", obs.GasFeeUsed) + }) + + t.Run("missing gas fee defaults to 0", func(t *testing.T) { + outboundData := &OutboundObservation{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + } + + event := &store.Event{ + EventID: "0xabc123:5", + BlockHeight: 12345, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.Equal(t, "0", obs.GasFeeUsed) + }) + + t.Run("handles base58 tx hash", func(t *testing.T) { + outboundData := &OutboundObservation{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + } + + event := &store.Event{ + EventID: "2VfUX:0", // Base58 encoded + BlockHeight: 100, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.True(t, len(obs.TxHash) >= 2) + assert.Equal(t, "0x", obs.TxHash[:2]) + }) +} + +func TestOutboundHandleEvent(t *testing.T) { + ctx := context.Background() + + t.Run("parse failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewOutboundObservationEventProcessor(&fakeVoteSigner{txHash: "0xvote"}, database, zerolog.Nop()) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xbad:0", EventData: []byte("not json")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse outbound event data") + }) + + t.Run("vote failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewOutboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) + eventData, _ := json.Marshal(OutboundObservation{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", EventData: eventData}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to vote on outbound") + }) + + t.Run("successful vote marks event completed", func(t *testing.T) { + database := newTestDB(t) + signer := &fakeVoteSigner{txHash: "0xvote"} + processor := NewOutboundObservationEventProcessor(signer, database, zerolog.Nop()) + eventData, _ := json.Marshal(OutboundObservation{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + seedConfirmedEvent(t, database, "0xout:0", store.EventTypeOutbound, eventData) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", Type: store.EventTypeOutbound, EventData: eventData}) + require.NoError(t, err) + assert.Equal(t, 1, signer.outboundVotes) + + rows, err := NewChainStore(database).UpdateEventStatus("0xout:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + }) +} diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index f3b2317c..54a26cee 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -2,11 +2,35 @@ package common import ( "context" + "fmt" "math/big" + "github.com/pushchain/push-chain-node/universalClient/uread" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) +// ReadRequestHandler executes a read request on one destination chain. +// Consumed by the push watcher's read processor. +type ReadRequestHandler interface { + ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) +} + +// EncodeUint256Result canonically encodes a balance/amount as abi.encode(uint256) +// so read results are byte-identical across validators and decodable by the +// requesting contract. The bounds check guards against a malicious RPC value +// that would not fit (FillBytes panics on overflow). +func EncodeUint256Result(v *big.Int) ([]byte, error) { + if v == nil { + v = big.NewInt(0) + } + if v.Sign() < 0 || v.BitLen() > 256 { + return nil, fmt.Errorf("value out of uint256 range") + } + out := make([]byte, 32) + v.FillBytes(out) + return out, nil +} + // ChainClient defines the interface for chain-specific implementations type ChainClient interface { // Start initializes and starts the chain client @@ -21,6 +45,11 @@ type ChainClient interface { // GetTxBuilder returns the TxBuilder for this chain // Returns an error if txBuilder is not supported for this chain (e.g., Push chain) GetTxBuilder() (TxBuilder, error) + + // GetReadRequestHandler returns the handler executing read requests + // destined for this chain + // Returns an error if reads are not available (e.g. client not started) + GetReadRequestHandler() (ReadRequestHandler, error) } // FundMigrationData contains the data needed to build a fund migration transaction. @@ -90,33 +119,3 @@ type TxBuilder interface { // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. BroadcastFundMigrationTx(ctx context.Context, req *UnsignedSigningReq, data *FundMigrationData, signature []byte) (string, error) } - -// UniversalTx Payload -type UniversalTx struct { - SourceChain string `json:"sourceChain"` - LogIndex uint `json:"logIndex"` - Sender string `json:"sender"` - Recipient string `json:"recipient"` - Token string `json:"bridgeToken"` - Amount string `json:"bridgeAmount"` // uint256 as decimal string - RawPayload string `json:"rawPayload,omitempty"` // hex-encoded raw payload bytes from source chain - VerificationData string `json:"verificationData"` - RevertFundRecipient string `json:"revertFundRecipient,omitempty"` - TxType uint `json:"txType"` // enum backing uint as decimal string - FromCEA bool `json:"fromCEA"` // true if inbound is initiated by a CEA -} - -// OutboundEvent represents an outbound observation event from the gateway contract -// Event structure: -// - txID at 1st indexed position (bytes32) -// - universalTxID at 2nd indexed position (bytes32) -type OutboundEvent struct { - TxID string `json:"tx_id"` // bytes32 hex-encoded (0x...) - UniversalTxID string `json:"universal_tx_id"` // bytes32 hex-encoded (0x...) - GasFeeUsed string `json:"gas_fee_used,omitempty"` // gas fee used in wei (decimal string) - // PC20 export only: wrapper token address deployed/minted on the destination - // at settlement (observed in the finalize event). Core uses it to flip the - // PC20 deploy flag; empty for non-PC20 settlements. - Pc20WrapperAddress string `json:"pc20_wrapper_address,omitempty"` -} - diff --git a/universalClient/externalchains/evm/client.go b/universalClient/externalchains/evm/client.go index a9cf0c67..2b2af36a 100644 --- a/universalClient/externalchains/evm/client.go +++ b/universalClient/externalchains/evm/client.go @@ -14,6 +14,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -86,16 +87,14 @@ func NewClient( // Initialize components that don't require RPC client if pushSigner != nil { - inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled - outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled - client.eventProcessor = common.NewEventProcessor( - pushSigner, - database, - chainIDStr, - inboundEnabled, - outboundEnabled, - log, - ) + ep := common.NewEventProcessor(database, chainIDStr, log) + if config.Enabled != nil && config.Enabled.IsInboundEnabled { + ep.RegisterHandler(store.EventTypeInbound, common.NewInboundObservationEventProcessor(pushSigner, database, log)) + } + if config.Enabled != nil && config.Enabled.IsOutboundEnabled { + ep.RegisterHandler(store.EventTypeOutbound, common.NewOutboundObservationEventProcessor(pushSigner, database, log)) + } + client.eventProcessor = ep } return client, nil @@ -199,6 +198,14 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// GetReadRequestHandler returns the read request handler for this chain +func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if c.rpcClient == nil { + return nil, fmt.Errorf("read handler not available for chain %s (client not started)", c.chainIDStr) + } + return c, nil +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { // Create event listener if gateway is configured diff --git a/universalClient/externalchains/evm/event_confirmer.go b/universalClient/externalchains/evm/event_confirmer.go index 45cabc08..b4c73a83 100644 --- a/universalClient/externalchains/evm/event_confirmer.go +++ b/universalClient/externalchains/evm/event_confirmer.go @@ -181,7 +181,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice).String() // Unmarshal, set GasFeeUsed, re-marshal - var outboundEvent chaincommon.OutboundEvent + var outboundEvent chaincommon.OutboundObservation if unmarshalErr := json.Unmarshal(event.EventData, &outboundEvent); unmarshalErr != nil { ec.logger.Error(). Err(unmarshalErr). diff --git a/universalClient/externalchains/evm/event_confirmer_test.go b/universalClient/externalchains/evm/event_confirmer_test.go index 022812cb..55ae7f51 100644 --- a/universalClient/externalchains/evm/event_confirmer_test.go +++ b/universalClient/externalchains/evm/event_confirmer_test.go @@ -313,7 +313,7 @@ func TestEventConfirmer_UpdateStatusAndEventData_WithDB(t *testing.T) { _, memDB := newTestEventConfirmerWithDB(t) cs := common.NewChainStore(memDB) - outbound := common.OutboundEvent{ + outbound := common.OutboundObservation{ TxID: "0xtx1", UniversalTxID: "0xuni1", } @@ -347,7 +347,7 @@ func TestEventConfirmer_UpdateStatusAndEventData_WithDB(t *testing.T) { require.Len(t, confirmed, 1) assert.Equal(t, "0xoutbound1:0", confirmed[0].EventID) - var stored common.OutboundEvent + var stored common.OutboundObservation require.NoError(t, json.Unmarshal(confirmed[0].EventData, &stored)) assert.Equal(t, "123456789", stored.GasFeeUsed) } diff --git a/universalClient/externalchains/evm/event_parser.go b/universalClient/externalchains/evm/event_parser.go index 3d44d470..a603f8ad 100644 --- a/universalClient/externalchains/evm/event_parser.go +++ b/universalClient/externalchains/evm/event_parser.go @@ -125,7 +125,7 @@ func parseOutboundObservationEvent(log *types.Log, eventType string, logger zero } // Create OutboundEvent payload - payload := common.OutboundEvent{ + payload := common.OutboundObservation{ TxID: txID, UniversalTxID: universalTxID, Pc20WrapperAddress: wrapperAddr, @@ -168,7 +168,7 @@ func parseUniversalTxEvent(event *store.Event, log *types.Log, chainID string, l return } - payload := common.UniversalTx{ + payload := common.InboundObservation{ SourceChain: chainID, Sender: ethcommon.BytesToAddress(log.Topics[1].Bytes()).Hex(), Recipient: ethcommon.BytesToAddress(log.Topics[2].Bytes()).Hex(), @@ -215,7 +215,7 @@ func readWord(data []byte, i int) []byte { // decodePayload reads the raw payload bytes at the given offset and stores the hex string. // The core validator will decode the universal payload from these raw bytes. -func decodePayload(data []byte, dataOffset uint64, payload *common.UniversalTx, logger zerolog.Logger) { +func decodePayload(data []byte, dataOffset uint64, payload *common.InboundObservation, logger zerolog.Logger) { if dataOffset < uint64(32*5) { return } @@ -240,7 +240,7 @@ func decodeSignatureData(data []byte, w []byte, minOffset uint64) string { } // finalizeEvent marshals the payload and sets confirmation type on the event. -func finalizeEvent(event *store.Event, payload *common.UniversalTx, logger zerolog.Logger) { +func finalizeEvent(event *store.Event, payload *common.InboundObservation, logger zerolog.Logger) { if b, err := json.Marshal(payload); err == nil { event.EventData = b } else { @@ -266,7 +266,7 @@ UniversalTx Event (V2 - upgraded chains): - signatureData (bytes) — Word 5 (offset) - fromCEA (bool) — Word 6 */ -func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, payload *common.UniversalTx, logger zerolog.Logger) { +func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, payload *common.InboundObservation, logger zerolog.Logger) { data := log.Data decodePayload(data, dataOffset, payload, logger) diff --git a/universalClient/externalchains/evm/event_parser_test.go b/universalClient/externalchains/evm/event_parser_test.go index 9fb77e1e..88dbc878 100644 --- a/universalClient/externalchains/evm/event_parser_test.go +++ b/universalClient/externalchains/evm/event_parser_test.go @@ -337,7 +337,7 @@ func TestParseOutboundObservation_PC20Wrapper(t *testing.T) { wrapperOf := func(t *testing.T, e *store.Event) string { t.Helper() - var ob common.OutboundEvent + var ob common.OutboundObservation require.NoError(t, json.Unmarshal(e.EventData, &ob)) return ob.Pc20WrapperAddress } @@ -544,21 +544,21 @@ func TestDecodePayload(t *testing.T) { big.NewInt(int64(len(inner))).FillBytes(data[160:192]) copy(data[192:196], inner) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 160, payload, logger) assert.Equal(t, "0xdeadbeef", payload.RawPayload) }) t.Run("offset too small is ignored", func(t *testing.T) { data := make([]byte, 256) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 32, payload, logger) // < 32*5 assert.Empty(t, payload.RawPayload) }) t.Run("offset zero is ignored", func(t *testing.T) { data := make([]byte, 256) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 0, payload, logger) assert.Empty(t, payload.RawPayload) }) @@ -566,7 +566,7 @@ func TestDecodePayload(t *testing.T) { t.Run("readDynamicBytes fails gracefully", func(t *testing.T) { // Data is too short for the length word at the offset data := make([]byte, 168) // offset 160 + only 8 bytes; need 32 for length - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 160, payload, logger) assert.Empty(t, payload.RawPayload) }) @@ -634,13 +634,13 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 0 sets FAST confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 0, Sender: "0xabc"} + payload := &common.InboundObservation{TxType: 0, Sender: "0xabc"} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) assert.NotNil(t, event.EventData) - var decoded common.UniversalTx + var decoded common.InboundObservation err := json.Unmarshal(event.EventData, &decoded) require.NoError(t, err) assert.Equal(t, "0xabc", decoded.Sender) @@ -648,7 +648,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 1 sets FAST confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 1} + payload := &common.InboundObservation{TxType: 1} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) @@ -656,7 +656,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 2 sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 2} + payload := &common.InboundObservation{TxType: 2} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -664,7 +664,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 3 sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 3} + payload := &common.InboundObservation{TxType: 3} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -672,7 +672,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("high txType sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 255} + payload := &common.InboundObservation{TxType: 255} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -680,7 +680,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("event data is valid JSON", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{ + payload := &common.InboundObservation{ SourceChain: "eip155:1", Sender: "0xsender", Recipient: "0xrecipient", @@ -690,7 +690,7 @@ func TestFinalizeEvent(t *testing.T) { } finalizeEvent(event, payload, logger) - var decoded common.UniversalTx + var decoded common.InboundObservation err := json.Unmarshal(event.EventData, &decoded) require.NoError(t, err) assert.Equal(t, "eip155:1", decoded.SourceChain) diff --git a/universalClient/externalchains/evm/read_envelope.go b/universalClient/externalchains/evm/read_envelope.go new file mode 100644 index 00000000..8580ba0e --- /dev/null +++ b/universalClient/externalchains/evm/read_envelope.go @@ -0,0 +1,131 @@ +package evm + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" + ethcommon "github.com/ethereum/go-ethereum/common" +) + +// evmQueryType mirrors the EvmQueryEnvelope enum from the read spec. +type evmQueryType uint8 + +const ( + evmQueryAccountBalance evmQueryType = 0 + evmQueryERC20Balance evmQueryType = 1 + evmQueryContractCall evmQueryType = 2 + evmQueryStorageSlot evmQueryType = 3 +) + +// evmBlockRefType mirrors the EvmBlockRefType enum. Only AT_NUMBER exists in v1. +type evmBlockRefType uint8 + +const evmBlockRefAtNumber evmBlockRefType = 0 + +// evmQueryEnvelope is the decoded abi.encode(EvmQueryEnvelope) query. +type evmQueryEnvelope struct { + QueryType evmQueryType + RefType evmBlockRefType + BlockNumber uint64 + Payload []byte +} + +var ( + evmEnvelopeArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "queryType", Type: "uint8"}, + {Name: "blockRef", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "refType", Type: "uint8"}, + {Name: "blockNumber", Type: "uint64"}, + }}, + {Name: "payload", Type: "bytes"}, + }}) + + addressArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}) + addressPairArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "address"}) + addressBytesArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "bytes"}) + addressBytes32Args = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "bytes32"}) +) + +func mustReadArgs(marshalings ...abi.ArgumentMarshaling) abi.Arguments { + args := make(abi.Arguments, 0, len(marshalings)) + for i, m := range marshalings { + if m.Name == "" { + m.Name = fmt.Sprintf("arg%d", i) + } + typ, err := abi.NewType(m.Type, "", m.Components) + if err != nil { + panic(fmt.Sprintf("evm: invalid abi type %q: %v", m.Type, err)) + } + args = append(args, abi.Argument{Name: m.Name, Type: typ}) + } + return args +} + +type rawEvmEnvelope struct { + QueryType uint8 + BlockRef struct { + RefType uint8 + BlockNumber uint64 + } + Payload []byte +} + +// decodeEvmQueryEnvelope decodes ReadSpec.query for eip155 chains. +func decodeEvmQueryEnvelope(query []byte) (*evmQueryEnvelope, error) { + vals, err := evmEnvelopeArgs.Unpack(query) + if err != nil { + return nil, fmt.Errorf("failed to unpack EvmQueryEnvelope: %w", err) + } + raw := *abi.ConvertType(vals[0], new(rawEvmEnvelope)).(*rawEvmEnvelope) + + env := &evmQueryEnvelope{ + QueryType: evmQueryType(raw.QueryType), + RefType: evmBlockRefType(raw.BlockRef.RefType), + BlockNumber: raw.BlockRef.BlockNumber, + Payload: raw.Payload, + } + if env.QueryType > evmQueryStorageSlot { + return nil, fmt.Errorf("unknown EvmQueryType %d", env.QueryType) + } + if env.RefType != evmBlockRefAtNumber { + return nil, fmt.Errorf("unsupported EvmBlockRefType %d", env.RefType) + } + return env, nil +} + +// decodeAccountBalancePayload decodes abi.encode(address target). +func decodeAccountBalancePayload(payload []byte) (ethcommon.Address, error) { + vals, err := addressArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("failed to unpack AccountBalance payload: %w", err) + } + return vals[0].(ethcommon.Address), nil +} + +// decodeERC20BalancePayload decodes abi.encode(address token, address owner). +func decodeERC20BalancePayload(payload []byte) (token, owner ethcommon.Address, err error) { + vals, err := addressPairArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, ethcommon.Address{}, fmt.Errorf("failed to unpack ERC20Balance payload: %w", err) + } + return vals[0].(ethcommon.Address), vals[1].(ethcommon.Address), nil +} + +// decodeContractCallPayload decodes abi.encode(address target, bytes callData). +func decodeContractCallPayload(payload []byte) (ethcommon.Address, []byte, error) { + vals, err := addressBytesArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, nil, fmt.Errorf("failed to unpack ContractCall payload: %w", err) + } + return vals[0].(ethcommon.Address), vals[1].([]byte), nil +} + +// decodeStorageSlotPayload decodes abi.encode(address contractAddr, bytes32 slot). +func decodeStorageSlotPayload(payload []byte) (ethcommon.Address, ethcommon.Hash, error) { + vals, err := addressBytes32Args.Unpack(payload) + if err != nil { + return ethcommon.Address{}, ethcommon.Hash{}, fmt.Errorf("failed to unpack StorageSlot payload: %w", err) + } + slot := vals[1].([32]byte) + return vals[0].(ethcommon.Address), ethcommon.Hash(slot), nil +} diff --git a/universalClient/externalchains/evm/read_envelope_test.go b/universalClient/externalchains/evm/read_envelope_test.go new file mode 100644 index 00000000..af04832a --- /dev/null +++ b/universalClient/externalchains/evm/read_envelope_test.go @@ -0,0 +1,80 @@ +package evm + +import ( + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func packEvmEnvelope(t *testing.T, queryType, refType uint8, blockNumber uint64, payload []byte) []byte { + t.Helper() + data, err := evmEnvelopeArgs.Pack(rawEvmEnvelope{ + QueryType: queryType, + BlockRef: struct { + RefType uint8 + BlockNumber uint64 + }{refType, blockNumber}, + Payload: payload, + }) + require.NoError(t, err) + return data +} + +func TestDecodeEvmQueryEnvelope(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + env, err := decodeEvmQueryEnvelope(packEvmEnvelope(t, uint8(evmQueryAccountBalance), 0, 1234, payload)) + require.NoError(t, err) + assert.Equal(t, evmQueryAccountBalance, env.QueryType) + assert.Equal(t, evmBlockRefAtNumber, env.RefType) + assert.Equal(t, uint64(1234), env.BlockNumber) + + decoded, err := decodeAccountBalancePayload(env.Payload) + require.NoError(t, err) + assert.Equal(t, target, decoded) +} + +func TestDecodeEvmQueryEnvelope_Invalid(t *testing.T) { + _, err := decodeEvmQueryEnvelope([]byte{0x01, 0x02}) + assert.Error(t, err) + + // unknown query type + _, err = decodeEvmQueryEnvelope(packEvmEnvelope(t, 9, 0, 0, nil)) + assert.Error(t, err) + + // unknown block ref type + _, err = decodeEvmQueryEnvelope(packEvmEnvelope(t, 0, 7, 0, nil)) + assert.Error(t, err) +} + +func TestDecodeEvmPayloads(t *testing.T) { + token := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + owner := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + + erc20Payload, err := addressPairArgs.Pack(token, owner) + require.NoError(t, err) + gotToken, gotOwner, err := decodeERC20BalancePayload(erc20Payload) + require.NoError(t, err) + assert.Equal(t, token, gotToken) + assert.Equal(t, owner, gotOwner) + + callData := []byte{0xde, 0xad, 0xbe, 0xef} + callPayload, err := addressBytesArgs.Pack(token, callData) + require.NoError(t, err) + gotTarget, gotData, err := decodeContractCallPayload(callPayload) + require.NoError(t, err) + assert.Equal(t, token, gotTarget) + assert.Equal(t, callData, gotData) + + slot := [32]byte{0x0a} + slotPayload, err := addressBytes32Args.Pack(token, slot) + require.NoError(t, err) + gotAddr, gotSlot, err := decodeStorageSlotPayload(slotPayload) + require.NoError(t, err) + assert.Equal(t, token, gotAddr) + assert.Equal(t, ethcommon.Hash(slot), gotSlot) +} diff --git a/universalClient/externalchains/evm/read_executor.go b/universalClient/externalchains/evm/read_executor.go new file mode 100644 index 00000000..78fa0d7b --- /dev/null +++ b/universalClient/externalchains/evm/read_executor.go @@ -0,0 +1,125 @@ +package evm + +import ( + "context" + "fmt" + "math/big" + + ethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// balanceOfSelector is the 4-byte selector for balanceOf(address). +var balanceOfSelector = []byte{0x70, 0xa0, 0x82, 0x31} + +// ExecuteRead implements common.ChainReader for EVM chains. +// All validators must produce byte-identical results, so every query runs at the +// height pinned in the request; execution is gated until that height has +// min_confirmations confirmations so a reorg cannot invalidate the read. +func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + env, err := decodeEvmQueryEnvelope(req.Query) + if err != nil { + return uread.NewErrorResult(err), nil + } + + height := req.DestinationBlockHeight + if height == 0 { + height = env.BlockNumber + } + if height == 0 { + return uread.NewErrorResult(fmt.Errorf("read request has no target height")), nil + } + + if err := c.gateHeightConfirmed(ctx, height, uint64(req.MinConfirmations)); err != nil { + return nil, err + } + blockNum := new(big.Int).SetUint64(height) + + header, err := c.rpcClient.GetHeaderByNumber(ctx, blockNum) + if err != nil { + return nil, fmt.Errorf("failed to fetch header at %d: %w", height, err) + } + + var resultData []byte + switch env.QueryType { + case evmQueryAccountBalance: + target, decErr := decodeAccountBalancePayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + balance, rpcErr := c.rpcClient.GetBalanceAt(ctx, target, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + resultData, err = common.EncodeUint256Result(balance) + + case evmQueryERC20Balance: + token, owner, decErr := decodeERC20BalancePayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + callData := append(append([]byte{}, balanceOfSelector...), ethcommon.LeftPadBytes(owner.Bytes(), 32)...) + ret, rpcErr := c.rpcClient.CallContract(ctx, token, callData, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + if len(ret) < 32 { + return uread.NewErrorResult(fmt.Errorf("balanceOf returned %d bytes", len(ret))), nil + } + resultData, err = common.EncodeUint256Result(new(big.Int).SetBytes(ret[:32])) + + case evmQueryContractCall: + target, callData, decErr := decodeContractCallPayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + ret, rpcErr := c.rpcClient.CallContract(ctx, target, callData, blockNum) + if rpcErr != nil { + // eth_call reverts are deterministic at a pinned height — observable as ERROR. + return uread.NewErrorResult(rpcErr), nil + } + resultData = ret + + case evmQueryStorageSlot: + target, slot, decErr := decodeStorageSlotPayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + value, rpcErr := c.rpcClient.GetStorageAt(ctx, target, slot, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + var slotValue [32]byte + copy(slotValue[32-min(len(value), 32):], value) + resultData = slotValue[:] + + default: + return uread.NewErrorResult(fmt.Errorf("unknown EvmQueryType %d", env.QueryType)), nil + } + if err != nil { + return uread.NewErrorResult(err), nil + } + + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: height, + ObservedBlockHash: header.Hash().Bytes(), + }, nil +} + +// gateHeightConfirmed blocks execution until the target height has at least +// minConfirmations confirmations. An error is transient: the processor keeps +// the event CONFIRMED and retries next tick. +func (c *Client) gateHeightConfirmed(ctx context.Context, height, minConfirmations uint64) error { + latest, err := c.rpcClient.GetLatestBlock(ctx) + if err != nil { + return fmt.Errorf("failed to get latest block: %w", err) + } + if latest < height+minConfirmations { + return fmt.Errorf("height %d needs %d confirmations, chain at %d; not final yet", height, minConfirmations, latest) + } + return nil +} diff --git a/universalClient/externalchains/evm/read_executor_test.go b/universalClient/externalchains/evm/read_executor_test.go new file mode 100644 index 00000000..37c97491 --- /dev/null +++ b/universalClient/externalchains/evm/read_executor_test.go @@ -0,0 +1,280 @@ +package evm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// fakeHeader is a minimal valid block header JSON accepted by types.Header. +func fakeHeader(number uint64) map[string]any { + zeroHash := "0x0000000000000000000000000000000000000000000000000000000000000000" + return map[string]any{ + "parentHash": zeroHash, + "sha3Uncles": zeroHash, + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": zeroHash, + "transactionsRoot": zeroHash, + "receiptsRoot": zeroHash, + "logsBloom": "0x" + fmt.Sprintf("%0512x", 0), + "difficulty": "0x0", + "number": fmt.Sprintf("0x%x", number), + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "mixHash": zeroHash, + "nonce": "0x0000000000000000", + } +} + +type rpcFault struct { + code int + message string +} + +// newReadTestClient spins up a JSON-RPC server answering from results/faults +// keyed by method name, and returns a Client wired to it. +func newReadTestClient(t *testing.T, results map[string]any, faults map[string]rpcFault) *Client { + t.Helper() + + // every read is gated on the chain tip; default to a comfortably deep chain + // unless the test overrides eth_blockNumber + if results != nil { + if _, ok := results["eth_blockNumber"]; !ok { + if _, ok := faults["eth_blockNumber"]; !ok { + results["eth_blockNumber"] = "0x1000" + } + } + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)} + if fault, ok := faults[req.Method]; ok { + resp["error"] = map[string]any{"code": fault.code, "message": fault.message} + } else if result, ok := results[req.Method]; ok { + resp["result"] = result + } else { + t.Errorf("unexpected RPC method %s", req.Method) + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + + ethClient, err := ethclient.Dial(srv.URL) + require.NoError(t, err) + t.Cleanup(ethClient.Close) + + return &Client{ + logger: zerolog.Nop(), + rpcClient: &RPCClient{clients: []*ethclient.Client{ethClient}, logger: zerolog.Nop()}, + } +} + +func evmReadRequest(t *testing.T, queryType uint8, blockNumber uint64, payload []byte) *uread.ReadRequest { + t.Helper() + return &uread.ReadRequest{ + RequestID: "0xreq1", + DestinationChain: "eip155:11155111", + Query: packEvmEnvelope(t, queryType, 0, blockNumber, payload), + MinConfirmations: 1, + DestinationBlockHeight: 100, + } +} + +func TestExecuteRead_AccountBalance(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_getBalance": "0xf4240", // 1_000_000 + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(100), result.ObservedBlockHeight) + assert.Len(t, result.ObservedBlockHash, 32) +} + +func TestExecuteRead_ERC20Balance(t *testing.T) { + token := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + owner := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + payload, err := addressPairArgs.Pack(token, owner) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_call": "0x" + fmt.Sprintf("%064x", 42), + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryERC20Balance), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(42), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_ContractCall(t *testing.T) { + target := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + payload, err := addressBytesArgs.Pack(target, []byte{0xde, 0xad}) + require.NoError(t, err) + + t.Run("returns raw returndata", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_call": "0xcafebabe", + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryContractCall), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, []byte{0xca, 0xfe, 0xba, 0xbe}, result.ResultData) + }) + + t.Run("revert is a votable ERROR observation", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + }, map[string]rpcFault{ + "eth_call": {code: 3, message: "execution reverted"}, + }) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryContractCall), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + assert.Empty(t, result.ResultData) + }) +} + +func TestExecuteRead_StorageSlot(t *testing.T) { + target := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + payload, err := addressBytes32Args.Pack(target, [32]byte{0x01}) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_getStorageAt": "0x" + fmt.Sprintf("%064x", 7), + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryStorageSlot), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + require.Len(t, result.ResultData, 32) + assert.Equal(t, big.NewInt(7), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_InvalidEnvelope(t *testing.T) { + client := newReadTestClient(t, nil, nil) + + result, err := client.ExecuteRead(context.Background(), &uread.ReadRequest{ + RequestID: "0xreq1", + Query: []byte{0x01, 0x02}, + DestinationBlockHeight: 100, + }) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) +} + +func TestExecuteRead_RPCFailureIsTransient(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{}, map[string]rpcFault{ + "eth_getBlockByNumber": {code: -32000, message: "node is syncing"}, + }) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload)) + require.Error(t, err) + assert.Nil(t, result) +} + +func TestExecuteRead_EnvelopeBlockNumberUsedWhenNotPinned(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(55), + "eth_getBalance": "0x1", + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 55, payload) + req.DestinationBlockHeight = 0 // client-provided height in the envelope + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uint64(55), result.ObservedBlockHeight) +} + +func TestExecuteRead_MissingHeightIsVotableError(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, nil, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.DestinationBlockHeight = 0 + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) +} + +func TestExecuteRead_ConfirmationGate(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + t.Run("height not deep enough is transient", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_blockNumber": "0x64", // 100 + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.DestinationBlockHeight = 100 + req.MinConfirmations = 5 // needs chain at >= 105 + + result, err := client.ExecuteRead(context.Background(), req) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("executes once deep enough", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_blockNumber": "0x69", // 105 + "eth_getBlockByNumber": fakeHeader(100), + "eth_getBalance": "0x1", + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.DestinationBlockHeight = 100 + req.MinConfirmations = 5 + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uint64(100), result.ObservedBlockHeight) + }) +} diff --git a/universalClient/externalchains/evm/rpc_client.go b/universalClient/externalchains/evm/rpc_client.go index b8c83d04..433a8ef3 100644 --- a/universalClient/externalchains/evm/rpc_client.go +++ b/universalClient/externalchains/evm/rpc_client.go @@ -182,6 +182,45 @@ func (rc *RPCClient) GetBalance(ctx context.Context, address ethcommon.Address) return balance, err } +// GetBalanceAt fetches the native token balance for an address at a specific block. +func (rc *RPCClient) GetBalanceAt(ctx context.Context, address ethcommon.Address, blockNumber *big.Int) (*big.Int, error) { + var balance *big.Int + err := rc.executeWithFailover(ctx, "get_balance_at", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + balance, innerErr = client.BalanceAt(callCtx, address, blockNumber) + return innerErr + }) + return balance, err +} + +// GetStorageAt fetches a storage slot value for a contract at a specific block. +func (rc *RPCClient) GetStorageAt(ctx context.Context, address ethcommon.Address, slot ethcommon.Hash, blockNumber *big.Int) ([]byte, error) { + var value []byte + err := rc.executeWithFailover(ctx, "get_storage_at", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + value, innerErr = client.StorageAt(callCtx, address, slot, blockNumber) + return innerErr + }) + return value, err +} + +// GetHeaderByNumber fetches a block header by number. +func (rc *RPCClient) GetHeaderByNumber(ctx context.Context, blockNumber *big.Int) (*types.Header, error) { + var header *types.Header + err := rc.executeWithFailover(ctx, "get_header_by_number", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + header, innerErr = client.HeaderByNumber(callCtx, blockNumber) + return innerErr + }) + return header, err +} + // FilterLogs fetches logs matching the filter query func (rc *RPCClient) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { var logs []types.Log diff --git a/universalClient/externalchains/svm/client.go b/universalClient/externalchains/svm/client.go index bdafd098..e0afd3a0 100644 --- a/universalClient/externalchains/svm/client.go +++ b/universalClient/externalchains/svm/client.go @@ -12,6 +12,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -96,16 +97,14 @@ func NewClient( // Initialize components that don't require RPC client if pushSigner != nil { - inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled - outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled - client.eventProcessor = common.NewEventProcessor( - pushSigner, - database, - chainIDStr, - inboundEnabled, - outboundEnabled, - log, - ) + ep := common.NewEventProcessor(database, chainIDStr, log) + if config.Enabled != nil && config.Enabled.IsInboundEnabled { + ep.RegisterHandler(store.EventTypeInbound, common.NewInboundObservationEventProcessor(pushSigner, database, log)) + } + if config.Enabled != nil && config.Enabled.IsOutboundEnabled { + ep.RegisterHandler(store.EventTypeOutbound, common.NewOutboundObservationEventProcessor(pushSigner, database, log)) + } + client.eventProcessor = ep } return client, nil @@ -209,6 +208,14 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// GetReadRequestHandler returns the read request handler for this chain +func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if c.rpcClient == nil { + return nil, fmt.Errorf("read handler not available for chain %s (client not started)", c.chainIDStr) + } + return c, nil +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { // Create event listener if gateway is configured diff --git a/universalClient/externalchains/svm/event_parser.go b/universalClient/externalchains/svm/event_parser.go index 68f1dd4d..cc65ea4c 100644 --- a/universalClient/externalchains/svm/event_parser.go +++ b/universalClient/externalchains/svm/event_parser.go @@ -177,7 +177,7 @@ func parseOutboundObservationEvent(log string, signature string, slot uint64, lo } // Create OutboundEvent payload - payload := common.OutboundEvent{ + payload := common.OutboundObservation{ TxID: txID, UniversalTxID: universalTxID, GasFeeUsed: fmt.Sprintf("%d", gasUsed), @@ -250,7 +250,7 @@ func parseUniversalTxEvent(event *store.Event, decoded []byte, logIndex uint, ch } // decodeUniversalTxEvent decodes a TxWithFunds event -func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.UniversalTx, error) { +func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.InboundObservation, error) { if len(data) < 120 { logger.Warn(). Int("data_len", len(data)). @@ -258,7 +258,7 @@ func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.Univers } offset := 8 - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} // Parse sender (32 bytes) if len(data) < offset+32 { diff --git a/universalClient/externalchains/svm/event_parser_test.go b/universalClient/externalchains/svm/event_parser_test.go index 88ae4436..f7c6f060 100644 --- a/universalClient/externalchains/svm/event_parser_test.go +++ b/universalClient/externalchains/svm/event_parser_test.go @@ -307,7 +307,7 @@ func TestParseSendFundsEvent(t *testing.T) { assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) // Unmarshal EventData - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Equal(t, chainID, utx.SourceChain) @@ -344,7 +344,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, 0, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.False(t, utx.FromCEA) }) @@ -355,7 +355,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, 0, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Empty(t, utx.RawPayload) assert.Empty(t, utx.VerificationData) @@ -368,7 +368,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, maxU64, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Equal(t, "18446744073709551615", utx.Amount) }) @@ -423,7 +423,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { assert.Equal(t, store.StatusPending, event.Status) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0x"+hex.EncodeToString(txID[:]), outbound.TxID) assert.Equal(t, "0x"+hex.EncodeToString(utxID[:]), outbound.UniversalTxID) @@ -439,7 +439,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, solana.PublicKeyFromBytes(token[:]).String(), outbound.Pc20WrapperAddress) }) @@ -450,7 +450,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) }) @@ -461,7 +461,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeRevertUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) assert.Equal(t, "7777", outbound.GasFeeUsed) @@ -473,7 +473,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFundsRescued, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) assert.Equal(t, "3333", outbound.GasFeeUsed) @@ -514,7 +514,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Contains(t, outbound.TxID, "0x1111") assert.Contains(t, outbound.UniversalTxID, "0x2222") @@ -536,7 +536,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0x"+hex.EncodeToString(txID[:]), outbound.TxID) assert.Equal(t, "0x"+hex.EncodeToString(utxID[:]), outbound.UniversalTxID) @@ -549,7 +549,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0", outbound.GasFeeUsed) }) @@ -560,7 +560,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "18446744073709551615", outbound.GasFeeUsed) }) diff --git a/universalClient/externalchains/svm/read_envelope.go b/universalClient/externalchains/svm/read_envelope.go new file mode 100644 index 00000000..b32d172b --- /dev/null +++ b/universalClient/externalchains/svm/read_envelope.go @@ -0,0 +1,66 @@ +package svm + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// solanaQueryType mirrors the SolanaQueryEnvelope enum from the read spec. +type solanaQueryType uint8 + +const ( + solanaQueryLamportBalance solanaQueryType = 0 + solanaQuerySPLTokenAccount solanaQueryType = 1 + solanaQueryRawAccountData solanaQueryType = 2 +) + +// solanaQueryEnvelope is the decoded abi.encode(SolanaQueryEnvelope) query — +// ABI-encoded because it is built by UniversalCallback.sol on Push EVM. +// The target account pubkey travels in ReadSpec.account.owner (32 bytes), not here. +type solanaQueryEnvelope struct { + QueryType solanaQueryType + MinSlot uint64 + Payload []byte // empty for all v1 query types +} + +var svmEnvelopeArgs = func() abi.Arguments { + tupleTy, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "queryType", Type: "uint8"}, + {Name: "slotRef", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "minSlot", Type: "uint64"}, + }}, + {Name: "payload", Type: "bytes"}, + }) + if err != nil { + panic(fmt.Sprintf("svm: invalid envelope abi type: %v", err)) + } + return abi.Arguments{{Name: "envelope", Type: tupleTy}} +}() + +type rawSvmEnvelope struct { + QueryType uint8 + SlotRef struct { + MinSlot uint64 + } + Payload []byte +} + +// decodeSolanaQueryEnvelope decodes ReadSpec.query for solana chains. +func decodeSolanaQueryEnvelope(query []byte) (*solanaQueryEnvelope, error) { + vals, err := svmEnvelopeArgs.Unpack(query) + if err != nil { + return nil, fmt.Errorf("failed to unpack SolanaQueryEnvelope: %w", err) + } + raw := *abi.ConvertType(vals[0], new(rawSvmEnvelope)).(*rawSvmEnvelope) + + env := &solanaQueryEnvelope{ + QueryType: solanaQueryType(raw.QueryType), + MinSlot: raw.SlotRef.MinSlot, + Payload: raw.Payload, + } + if env.QueryType > solanaQueryRawAccountData { + return nil, fmt.Errorf("unknown SolanaQueryType %d", env.QueryType) + } + return env, nil +} diff --git a/universalClient/externalchains/svm/read_envelope_test.go b/universalClient/externalchains/svm/read_envelope_test.go new file mode 100644 index 00000000..d5103053 --- /dev/null +++ b/universalClient/externalchains/svm/read_envelope_test.go @@ -0,0 +1,34 @@ +package svm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeSolanaQueryEnvelope(t *testing.T) { + data, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{ + QueryType: uint8(solanaQuerySPLTokenAccount), + SlotRef: struct { + MinSlot uint64 + }{42}, + Payload: nil, + }) + require.NoError(t, err) + + env, err := decodeSolanaQueryEnvelope(data) + require.NoError(t, err) + assert.Equal(t, solanaQuerySPLTokenAccount, env.QueryType) + assert.Equal(t, uint64(42), env.MinSlot) + assert.Empty(t, env.Payload) + + _, err = decodeSolanaQueryEnvelope([]byte{0x00}) + assert.Error(t, err) + + // unknown query type + bad, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{QueryType: 9}) + require.NoError(t, err) + _, err = decodeSolanaQueryEnvelope(bad) + assert.Error(t, err) +} diff --git a/universalClient/externalchains/svm/read_executor.go b/universalClient/externalchains/svm/read_executor.go new file mode 100644 index 00000000..d5fc7085 --- /dev/null +++ b/universalClient/externalchains/svm/read_executor.go @@ -0,0 +1,98 @@ +package svm + +import ( + "context" + "encoding/binary" + "fmt" + "math/big" + + "github.com/gagliardetto/solana-go" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// splTokenAmountOffset is the byte offset of the u64 amount in an SPL token account. +const splTokenAmountOffset = 64 + +// ExecuteRead implements common.ChainReader for Solana chains. +// +// Solana cannot query state at an exact past slot, so reads run at finalized +// commitment with minContextSlot as a staleness floor. ObservedBlockHeight (the +// context slot) may differ across validators; core's ballot key covers the +// result value only, never the observed slot. +func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + env, err := decodeSolanaQueryEnvelope(req.Query) + if err != nil { + return uread.NewErrorResult(err), nil + } + + if len(req.Owner) != solana.PublicKeyLength { + return uread.NewErrorResult(fmt.Errorf("owner must be a 32-byte pubkey, got %d bytes", len(req.Owner))), nil + } + account := solana.PublicKeyFromBytes(req.Owner) + + minSlot := max(env.MinSlot, req.DestinationBlockHeight) + + switch env.QueryType { + case solanaQueryLamportBalance: + balance, slot, rpcErr := c.rpcClient.GetBalanceWithSlot(ctx, account) + if rpcErr != nil { + return nil, rpcErr + } + if slot < minSlot { + return nil, fmt.Errorf("observed slot %d below min slot %d", slot, minSlot) + } + resultData, encErr := common.EncodeUint256Result(new(big.Int).SetUint64(balance)) + if encErr != nil { + return uread.NewErrorResult(encErr), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: slot, + }, nil + + case solanaQuerySPLTokenAccount: + data, owner, found, slot, rpcErr := c.rpcClient.GetAccountInfoWithSlot(ctx, account, minSlot) + if rpcErr != nil { + return nil, rpcErr + } + if !found { + return uread.NewErrorResult(fmt.Errorf("token account %s not found", account)), nil + } + if !owner.Equals(solana.TokenProgramID) && !owner.Equals(solana.Token2022ProgramID) { + return uread.NewErrorResult(fmt.Errorf("account %s is not owned by a token program", account)), nil + } + if len(data) < splTokenAmountOffset+8 { + return uread.NewErrorResult(fmt.Errorf("token account data too short: %d bytes", len(data))), nil + } + amount := binary.LittleEndian.Uint64(data[splTokenAmountOffset : splTokenAmountOffset+8]) + resultData, encErr := common.EncodeUint256Result(new(big.Int).SetUint64(amount)) + if encErr != nil { + return uread.NewErrorResult(encErr), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: slot, + }, nil + + case solanaQueryRawAccountData: + data, _, found, slot, rpcErr := c.rpcClient.GetAccountInfoWithSlot(ctx, account, minSlot) + if rpcErr != nil { + return nil, rpcErr + } + if !found { + return uread.NewErrorResult(fmt.Errorf("account %s not found", account)), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: data, + ObservedBlockHeight: slot, + }, nil + + default: + return uread.NewErrorResult(fmt.Errorf("unknown SolanaQueryType %d", env.QueryType)), nil + } +} diff --git a/universalClient/externalchains/svm/read_executor_test.go b/universalClient/externalchains/svm/read_executor_test.go new file mode 100644 index 00000000..17331ec9 --- /dev/null +++ b/universalClient/externalchains/svm/read_executor_test.go @@ -0,0 +1,210 @@ +package svm + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gagliardetto/solana-go" + solrpc "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// accountInfoResult builds a getAccountInfo result with base64 data. +func accountInfoResult(slot uint64, owner solana.PublicKey, data []byte) map[string]any { + return map[string]any{ + "context": map[string]any{"slot": slot}, + "value": map[string]any{ + "data": []any{base64.StdEncoding.EncodeToString(data), "base64"}, + "executable": false, + "lamports": 1, + "owner": owner.String(), + "rentEpoch": 0, + }, + } +} + +// newReadTestClient spins up a JSON-RPC server answering from results keyed by +// method name, and returns a Client wired to it. +func newReadTestClient(t *testing.T, results map[string]any) *Client { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)} + if result, ok := results[req.Method]; ok { + resp["result"] = result + } else { + t.Errorf("unexpected RPC method %s", req.Method) + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + + return &Client{ + logger: zerolog.Nop(), + rpcClient: &RPCClient{clients: []*solrpc.Client{solrpc.New(srv.URL)}, logger: zerolog.Nop()}, + } +} + +func svmReadRequest(t *testing.T, queryType uint8, minSlot uint64, owner []byte) *uread.ReadRequest { + t.Helper() + query, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{ + QueryType: queryType, + SlotRef: struct { + MinSlot uint64 + }{minSlot}, + }) + require.NoError(t, err) + return &uread.ReadRequest{ + RequestID: "0xreq1", + DestinationChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Owner: owner, + Query: query, + } +} + +func testAccount() solana.PublicKey { + return solana.MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") +} + +func TestExecuteRead_LamportBalance(t *testing.T) { + account := testAccount() + + t.Run("success", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getBalance": map[string]any{ + "context": map[string]any{"slot": 900}, + "value": 5_000_000, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 800, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(5_000_000), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) + }) + + t.Run("observed slot below min slot is transient", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getBalance": map[string]any{ + "context": map[string]any{"slot": 700}, + "value": 5_000_000, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 800, account.Bytes())) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestExecuteRead_SPLTokenAccount(t *testing.T) { + account := testAccount() + + tokenAccountData := func(amount uint64) []byte { + data := make([]byte, 165) + binary.LittleEndian.PutUint64(data[splTokenAmountOffset:], amount) + return data + } + + t.Run("success", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.TokenProgramID, tokenAccountData(777)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 800, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(777), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) + }) + + t.Run("non token-program owner is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.SystemProgramID, tokenAccountData(777)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("truncated account data is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.TokenProgramID, make([]byte, 10)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("missing account is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": map[string]any{ + "context": map[string]any{"slot": 900}, + "value": nil, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) +} + +func TestExecuteRead_RawAccountData(t *testing.T) { + account := testAccount() + raw := []byte{0x01, 0x02, 0x03} + + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.SystemProgramID, raw), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryRawAccountData), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, raw, result.ResultData) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) +} + +func TestExecuteRead_InvalidInputs(t *testing.T) { + account := testAccount() + + t.Run("invalid envelope is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, nil) + + result, err := client.ExecuteRead(context.Background(), &uread.ReadRequest{ + RequestID: "0xreq1", + Owner: account.Bytes(), + Query: []byte{0x01}, + }) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("owner not 32 bytes is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, nil) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 0, []byte{0x01, 0x02})) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) +} diff --git a/universalClient/externalchains/svm/rpc_client.go b/universalClient/externalchains/svm/rpc_client.go index fb788b7a..ee1ac9a0 100644 --- a/universalClient/externalchains/svm/rpc_client.go +++ b/universalClient/externalchains/svm/rpc_client.go @@ -386,6 +386,53 @@ func (rc *RPCClient) GetAccountData(ctx context.Context, pubkey solana.PublicKey return accountData, err } +// GetBalanceWithSlot fetches the lamport balance for an account at finalized +// commitment, returning the context slot the value was observed at. +func (rc *RPCClient) GetBalanceWithSlot(ctx context.Context, pubkey solana.PublicKey) (uint64, uint64, error) { + var balance, slot uint64 + err := rc.executeWithFailover(ctx, "get_balance", func(client *rpc.Client) error { + resp, innerErr := client.GetBalance(ctx, pubkey, rpc.CommitmentFinalized) + if innerErr != nil { + return innerErr + } + balance = resp.Value + slot = resp.RPCContext.Context.Slot + return nil + }) + return balance, slot, err +} + +// GetAccountInfoWithSlot fetches account data at finalized commitment with an +// optional minimum context slot, returning the context slot it was observed at. +// found=false means the account does not exist (a valid, votable observation). +func (rc *RPCClient) GetAccountInfoWithSlot(ctx context.Context, pubkey solana.PublicKey, minContextSlot uint64) (data []byte, owner solana.PublicKey, found bool, slot uint64, err error) { + err = rc.executeWithFailover(ctx, "get_account_info", func(client *rpc.Client) error { + opts := &rpc.GetAccountInfoOpts{Commitment: rpc.CommitmentFinalized} + if minContextSlot > 0 { + opts.MinContextSlot = &minContextSlot + } + resp, innerErr := client.GetAccountInfoWithOpts(ctx, pubkey, opts) + if innerErr != nil { + if innerErr == rpc.ErrNotFound { + found = false + return nil + } + return innerErr + } + if resp.Value == nil { + found = false + slot = resp.RPCContext.Context.Slot + return nil + } + found = true + data = resp.Value.Data.GetBinary() + owner = resp.Value.Owner + slot = resp.RPCContext.Context.Slot + return nil + }) + return data, owner, found, slot, err +} + // Close closes all RPC connections func (rc *RPCClient) Close() { rc.mu.Lock() diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 647b548e..3de4dda0 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -16,6 +16,7 @@ import ( "github.com/cosmos/cosmos-sdk/types/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/cosmos/cosmos-sdk/x/authz" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" utsstypes "github.com/pushchain/push-chain-node/x/utss/types" @@ -367,6 +368,21 @@ func (c *Client) GetAllPendingOutbounds(ctx context.Context) ([]*uexecutortypes. return resp.Entries, resp.Outbounds, nil } +// ErrReadQueriesNotAvailable is returned until the core-side pending-read query +// exists. Callers treat it as "feature not live yet", not as a failure. +var ErrReadQueriesNotAvailable = errors.New("pushcore: pending read requests query not available yet (blocked on core)") + +// GetAllPendingReadRequests retrieves pending external read requests from Push Chain. +// +// TODO(core): blocked on x/uexecutor Query/PendingReadRequests +// (proto/uexecutor/v1/query.proto). Once it lands, mirror GetAllPendingOutbounds: +// call c.uexecutorClients[idx].AllPendingReadRequests with retryWithRoundRobin, +// map uexecutortypes.ReadRequest -> uread.ReadRequest (or drop the local type +// entirely), and delete ErrReadQueriesNotAvailable. +func (c *Client) GetAllPendingReadRequests(ctx context.Context) ([]*uread.ReadRequest, error) { + return nil, ErrReadQueriesNotAvailable +} + // createGRPCConnection creates a gRPC connection with appropriate transport security. // It automatically detects whether to use TLS based on the URL scheme // and adds default port 9090 if no port is specified. diff --git a/universalClient/pushsigner/pushsigner.go b/universalClient/pushsigner/pushsigner.go index 8e8dcbfe..f7f9dbe1 100644 --- a/universalClient/pushsigner/pushsigner.go +++ b/universalClient/pushsigner/pushsigner.go @@ -2,6 +2,7 @@ package pushsigner import ( "context" + "errors" "fmt" "strings" "sync" @@ -24,6 +25,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/config" "github.com/pushchain/push-chain-node/universalClient/pushcore" "github.com/pushchain/push-chain-node/universalClient/pushsigner/keys" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) @@ -132,6 +134,22 @@ func (s *Signer) VoteFundMigration(ctx context.Context, migrationID uint64, txHa return voteFundMigration(ctx, s, s.log, s.granter, migrationID, txHash, success) } +// ErrVoteReadNotAvailable is returned until the core-side vote msg exists. +var ErrVoteReadNotAvailable = errors.New("pushsigner: MsgVoteReadResult not available yet (blocked on core)") + +// VoteReadResult votes on an external read observation. +// +// TODO(core): blocked on uexecutortypes.MsgVoteReadResult +// (proto/uexecutor/v1/tx.proto). Once it lands: +// - add a voteReadResult builder in vote.go (Signer: granter, RequestId, +// Status, ResultData, ObservedBlockHeight, ObservedBlockHash) and route +// through vote() like voteInbound does; +// - ensure the validator AuthZ grant set includes the new msg type URL +// (grant_verifier.go + core-side grant creation). +func (s *Signer) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + return "", ErrVoteReadNotAvailable +} + // signAndBroadcastAuthZTx signs and broadcasts an AuthZ transaction func (s *Signer) signAndBroadcastAuthZTx( ctx context.Context, diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 351b38b9..297246b2 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -10,27 +10,34 @@ import ( "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushcore" + "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" "github.com/rs/zerolog" ) // Client implements the ChainClient interface for Push chain type Client struct { - logger zerolog.Logger - pushCore *pushcore.Client - database *db.DB - eventListener *EventListener - eventCleaner *common.EventCleaner - ctx context.Context - cancel context.CancelFunc + logger zerolog.Logger + pushCore *pushcore.Client + database *db.DB + eventListener *EventListener + eventCleaner *common.EventCleaner + eventProcessor *EventProcessor + ctx context.Context + cancel context.CancelFunc } -// NewClient creates a new Push chain client +// NewClient creates a new Push chain client. +// pushSigner and chainResolver may be nil; the READ_REQUEST handler is only +// registered when both are present. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, pushCore *pushcore.Client, chainID string, logger zerolog.Logger, + pushSigner *pushsigner.Signer, + chainResolver ChainResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -64,6 +71,22 @@ func NewClient( eventCleaner: eventCleaner, } + eventProcessor, err := NewEventProcessor(database, eventListener.cfg.PollInterval, logger) + if err != nil { + return nil, fmt.Errorf("failed to create event processor: %w", err) + } + + // READ_REQUEST events are executed on their destination chains (via + // chainResolver) and the results voted back. + if pushSigner != nil && chainResolver != nil { + readEventProcessor, err := NewReadEventProcessor(pushSigner, chainResolver, database, logger) + if err != nil { + return nil, fmt.Errorf("failed to create read event processor: %w", err) + } + eventProcessor.RegisterHandler(store.EventTypeReadRequest, readEventProcessor) + } + client.eventProcessor = eventProcessor + return client, nil } @@ -85,6 +108,13 @@ func (c *Client) Start(ctx context.Context) error { } } + // Start event processor + if c.eventProcessor != nil { + if err := c.eventProcessor.Start(c.ctx); err != nil { + return fmt.Errorf("failed to start event processor: %w", err) + } + } + c.logger.Info().Msg("Push chain client started successfully") return nil } @@ -110,6 +140,13 @@ func (c *Client) Stop() error { c.eventCleaner.Stop() } + // Stop event processor + if c.eventProcessor != nil { + if err := c.eventProcessor.Stop(); err != nil { + c.logger.Error().Err(err).Str("subsystem", "event_processor").Msg("subsystem failed to stop") + } + } + c.logger.Info().Msg("Push chain client stopped") return nil } diff --git a/universalClient/pushwatcher/client_test.go b/universalClient/pushwatcher/client_test.go index 26877bea..8df232c4 100644 --- a/universalClient/pushwatcher/client_test.go +++ b/universalClient/pushwatcher/client_test.go @@ -34,7 +34,7 @@ func TestNewClient(t *testing.T) { pc := newTestPushCoreClient() t.Run("success with nil config", func(t *testing.T) { - client, err := NewClient(database, nil, pc, "push-chain", logger) + client, err := NewClient(database, nil, pc, "push-chain", logger, nil, nil) require.NoError(t, err) require.NotNil(t, client) assert.NotNil(t, client.eventListener) @@ -48,27 +48,27 @@ func TestNewClient(t *testing.T) { CleanupIntervalSeconds: &cleanup, RetentionPeriodSeconds: &retention, } - client, err := NewClient(database, cfg, pc, "push-chain", logger) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil, nil) require.NoError(t, err) require.NotNil(t, client) assert.NotNil(t, client.eventCleaner) }) t.Run("nil pushcore fails", func(t *testing.T) { - _, err := NewClient(database, nil, nil, "push-chain", logger) + _, err := NewClient(database, nil, nil, "push-chain", logger, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "push client is nil") }) t.Run("nil database fails", func(t *testing.T) { - _, err := NewClient(nil, nil, pc, "push-chain", logger) + _, err := NewClient(nil, nil, pc, "push-chain", logger, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "database is nil") }) } func TestClient_StartStop(t *testing.T) { - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, nil) require.NoError(t, err) ctx := context.Background() @@ -96,7 +96,7 @@ func TestClient_StopBeforeStart(t *testing.T) { // Stop on a freshly created client (never started) should not panic. // The cancel func is nil, eventListener.Stop() returns ErrNotRunning but // the client logs and swallows that error, returning nil. - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, nil) require.NoError(t, err) // Should not panic or return error @@ -104,7 +104,7 @@ func TestClient_StopBeforeStart(t *testing.T) { } func TestClient_DoubleStop(t *testing.T) { - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, nil) require.NoError(t, err) ctx := context.Background() @@ -122,7 +122,7 @@ func TestClient_StartStopWithEventCleaner(t *testing.T) { CleanupIntervalSeconds: &cleanup, RetentionPeriodSeconds: &retention, } - client, err := NewClient(newTestDB(t), cfg, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), cfg, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, nil) require.NoError(t, err) require.NotNil(t, client.eventCleaner) @@ -140,7 +140,7 @@ func TestClient_StartStopWithEventCleaner(t *testing.T) { func TestClient_StartStopLifecycleMultiple(t *testing.T) { // Verify the client can be started and stopped multiple times (restart). - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, nil) require.NoError(t, err) ctx := context.Background() @@ -183,7 +183,7 @@ func TestNewClient_CleanerAlwaysWired(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - client, err := NewClient(database, tc.cfg, pc, "push-chain", logger) + client, err := NewClient(database, tc.cfg, pc, "push-chain", logger, nil, nil) require.NoError(t, err) require.NotNil(t, client.eventCleaner, "cleaner must always be wired up") }) @@ -199,7 +199,7 @@ func TestNewClient_NegativePollInterval(t *testing.T) { cfg := &config.ChainSpecificConfig{ EventPollingIntervalSeconds: &poll, } - client, err := NewClient(database, cfg, pc, "push-chain", logger) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil, nil) require.NoError(t, err) // Negative poll interval should fall back to default assert.Equal(t, DefaultPollInterval, client.eventListener.cfg.PollInterval) diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index 7193f444..54dbb19c 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -28,8 +28,9 @@ type Config struct { PollInterval time.Duration } -// EventListener polls Push chain for active TSS events and pending outbounds -// via gRPC, converts them to store.Events, and inserts them into the local DB. +// EventListener polls Push chain for active TSS events, pending outbounds and +// pending read requests via gRPC, converts them to store.Events, and inserts +// them into the local DB. type EventListener struct { pushCore *pushcore.Client chainStore *common.ChainStore @@ -134,17 +135,19 @@ func (el *EventListener) run(ctx context.Context) { } } -// poll fetches pending TSS, outbound & fund migration events, stores them, and updates latest block height. +// poll fetches pending TSS, outbound, fund migration & read request events, stores them, and updates latest block height. func (el *EventListener) poll(ctx context.Context) { tssCount := el.pollTssEvents(ctx) outboundCount := el.pollOutboundEvents(ctx) migrationCount := el.pollFundMigrationEvents(ctx) + readCount := el.pollReadRequestEvents(ctx) - if total := tssCount + outboundCount + migrationCount; total > 0 { + if total := tssCount + outboundCount + migrationCount + readCount; total > 0 { el.logger.Info(). Int("tss_events", tssCount). Int("outbound_events", outboundCount). Int("migration_events", migrationCount). + Int("read_request_events", readCount). Msg("stored new events") } @@ -235,6 +238,33 @@ func (el *EventListener) pollFundMigrationEvents(ctx context.Context) int { return newCount } +// pollReadRequestEvents fetches pending external read requests and inserts +// them into the DB. Returns new event count. +func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { + requests, err := el.pushCore.GetAllPendingReadRequests(ctx) + if err != nil { + if errors.Is(err, pushcore.ErrReadQueriesNotAvailable) { + // TODO(core): remove once Query/PendingReadRequests lands. + return 0 + } + el.logger.Error().Err(err).Msg("failed to fetch pending read requests") + return 0 + } + + var newCount int + for _, req := range requests { + event, err := convertReadRequestEvent(req) + if err != nil { + el.logger.Warn().Err(err).Str("request_id", req.RequestID).Msg("failed to convert read request") + continue + } + + newCount += el.storeEvent(event) + } + + return newCount +} + // storeEvent inserts an event into the DB if it doesn't already exist. // Returns 1 if stored, 0 if duplicate or error. func (el *EventListener) storeEvent(event *store.Event) int { diff --git a/universalClient/pushwatcher/event_parser.go b/universalClient/pushwatcher/event_parser.go index a2644d7d..1abb4a4f 100644 --- a/universalClient/pushwatcher/event_parser.go +++ b/universalClient/pushwatcher/event_parser.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" utsstypes "github.com/pushchain/push-chain-node/x/utss/types" ) @@ -94,6 +95,28 @@ func convertFundMigrationEvent(migration *utsstypes.FundMigration) (*store.Event }, nil } +// convertReadRequestEvent converts a pending external read request to a store.Event. +func convertReadRequestEvent(req *uread.ReadRequest) (*store.Event, error) { + if req == nil || req.RequestID == "" { + return nil, fmt.Errorf("read request is nil or missing request id") + } + + eventData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal read request event data: %w", err) + } + + return &store.Event{ + EventID: hashEventID(store.EventTypeReadRequest, req.RequestID), + BlockHeight: req.CreatedAtHeight, + ExpiryBlockHeight: req.ExpiryBlockHeight, + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, + }, nil +} + // convertOutboundToEvent converts a PendingOutboundEntry + OutboundTx to a store.Event. func convertOutboundToEvent(entry *uexecutortypes.PendingOutboundEntry, outbound *uexecutortypes.OutboundTx) (*store.Event, error) { if entry == nil || outbound == nil { diff --git a/universalClient/pushwatcher/event_parser_test.go b/universalClient/pushwatcher/event_parser_test.go index cf8a98dc..acd38a7a 100644 --- a/universalClient/pushwatcher/event_parser_test.go +++ b/universalClient/pushwatcher/event_parser_test.go @@ -421,3 +421,4 @@ func TestHashEventID(t *testing.T) { assert.Len(t, id, 64) // sha256 = 32 bytes = 64 hex chars }) } + diff --git a/universalClient/pushwatcher/event_processor.go b/universalClient/pushwatcher/event_processor.go new file mode 100644 index 00000000..f848ae76 --- /dev/null +++ b/universalClient/pushwatcher/event_processor.go @@ -0,0 +1,151 @@ +package pushwatcher + +import ( + "context" + "sync" + "time" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/rs/zerolog" +) + +const eventProcessBatchSize = 1000 + +// EventHandler processes one CONFIRMED push chain event of a registered type. +// Handlers own the event's status transitions; a returned error is logged and +// the event is retried next tick. +type EventHandler interface { + HandleEvent(ctx context.Context, event *store.Event) error +} + +// EventProcessor drains CONFIRMED events from the push chain DB and dispatches +// them to the handler registered for their type. Event types without a handler +// are ignored (e.g. TSS events, which are consumed by the TSS subsystem). +type EventProcessor struct { + chainStore *common.ChainStore + handlers map[string]EventHandler + cfg Config + logger zerolog.Logger + + mu sync.Mutex + running bool + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewEventProcessor creates a new push event processor. Register handlers +// before Start. +func NewEventProcessor( + database *db.DB, + pollInterval time.Duration, + logger zerolog.Logger, +) (*EventProcessor, error) { + if database == nil { + return nil, ErrNilDatabase + } + + if pollInterval <= 0 { + pollInterval = DefaultPollInterval + } + + return &EventProcessor{ + chainStore: common.NewChainStore(database), + handlers: make(map[string]EventHandler), + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_event_processor").Logger(), + }, nil +} + +// RegisterHandler registers a handler for an event type. Must be called before Start. +func (p *EventProcessor) RegisterHandler(eventType string, handler EventHandler) { + p.handlers[eventType] = handler +} + +// Start begins processing events. +func (p *EventProcessor) Start(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return ErrAlreadyRunning + } + + childCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + p.running = true + + p.logger.Debug(). + Dur("poll_interval", p.cfg.PollInterval). + Msg("starting push event processor") + + p.wg.Add(1) + go p.run(childCtx) + + return nil +} + +// Stop gracefully stops the processor. +func (p *EventProcessor) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return ErrNotRunning + } + + p.cancel() + p.wg.Wait() + p.running = false + + return nil +} + +func (p *EventProcessor) run(ctx context.Context) { + defer p.wg.Done() + + p.processConfirmedEvents(ctx) + + ticker := time.NewTicker(p.cfg.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.processConfirmedEvents(ctx) + } + } +} + +// processConfirmedEvents dispatches CONFIRMED events to their registered handlers. +func (p *EventProcessor) processConfirmedEvents(ctx context.Context) { + events, err := p.chainStore.GetConfirmedEvents(eventProcessBatchSize) + if err != nil { + p.logger.Error().Err(err).Msg("failed to query confirmed events") + return + } + + for _, event := range events { + handler, ok := p.handlers[event.Type] + if !ok { + continue + } + + select { + case <-ctx.Done(): + return + default: + } + + if err := handler.HandleEvent(ctx, &event); err != nil { + p.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Str("type", event.Type). + Msg("failed to process event") + } + } +} diff --git a/universalClient/pushwatcher/event_processor_test.go b/universalClient/pushwatcher/event_processor_test.go new file mode 100644 index 00000000..92e60bcd --- /dev/null +++ b/universalClient/pushwatcher/event_processor_test.go @@ -0,0 +1,90 @@ +package pushwatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" +) + +type fakeEventHandler struct { + handled []string + err error +} + +func (f *fakeEventHandler) HandleEvent(ctx context.Context, event *store.Event) error { + f.handled = append(f.handled, event.EventID) + return f.err +} + +func seedEvent(t *testing.T, cs *common.ChainStore, eventID, eventType string) { + t.Helper() + stored, err := cs.InsertEventIfNotExists(&store.Event{ + EventID: eventID, + Type: eventType, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("{}"), + }) + require.NoError(t, err) + require.True(t, stored) +} + +func TestEventProcessor_DispatchesByType(t *testing.T) { + database := newTestDB(t) + p, err := NewEventProcessor(database, 0, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + + readHandler := &fakeEventHandler{} + p.RegisterHandler(store.EventTypeReadRequest, readHandler) + + seedEvent(t, cs, "read-1", store.EventTypeReadRequest) + seedEvent(t, cs, "tss-1", store.EventTypeKeygen) // no handler registered + + p.processConfirmedEvents(context.Background()) + + assert.Equal(t, []string{"read-1"}, readHandler.handled) +} + +func TestEventProcessor_HandlerErrorKeepsProcessing(t *testing.T) { + database := newTestDB(t) + p, err := NewEventProcessor(database, 0, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + + failing := &fakeEventHandler{err: fmt.Errorf("boom")} + p.RegisterHandler(store.EventTypeReadRequest, failing) + + seedEvent(t, cs, "read-1", store.EventTypeReadRequest) + seedEvent(t, cs, "read-2", store.EventTypeReadRequest) + + p.processConfirmedEvents(context.Background()) + + // both attempted despite errors, both still CONFIRMED for retry + assert.Len(t, failing.handled, 2) + events, err := cs.GetConfirmedEvents(10) + require.NoError(t, err) + assert.Len(t, events, 2) +} + +func TestEventProcessor_NilDatabase(t *testing.T) { + _, err := NewEventProcessor(nil, 0, zerolog.Nop()) + assert.ErrorIs(t, err, ErrNilDatabase) +} + +func TestEventProcessor_StartStop(t *testing.T) { + p, err := NewEventProcessor(newTestDB(t), 0, zerolog.Nop()) + require.NoError(t, err) + + require.NoError(t, p.Start(context.Background())) + assert.Equal(t, ErrAlreadyRunning, p.Start(context.Background())) + require.NoError(t, p.Stop()) + assert.Equal(t, ErrNotRunning, p.Stop()) +} diff --git a/universalClient/pushwatcher/read_event_processor.go b/universalClient/pushwatcher/read_event_processor.go new file mode 100644 index 00000000..1544c91e --- /dev/null +++ b/universalClient/pushwatcher/read_event_processor.go @@ -0,0 +1,134 @@ +package pushwatcher + +import ( + "context" + "encoding/json" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" + "github.com/rs/zerolog" +) + +// ChainResolver resolves a CAIP-2 chain ID to its chain client. +// Satisfied by externalchains.Chains. +type ChainResolver interface { + GetClient(chainID string) (common.ChainClient, error) +} + +// readVoter submits a read observation vote to Push Chain. +// Satisfied by *pushsigner.Signer. +type readVoter interface { + VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) +} + +// ReadEventProcessor handles READ_REQUEST events: it executes each request on +// its destination chain via the resolved read handler and votes the result. +// Transient failures (destination not served, RPC errors, vote failure) keep +// the event CONFIRMED for retry; corrupt events flip to REVERTED. Expiry is +// core's job: expired requests leave the pending query. +type ReadEventProcessor struct { + voter readVoter + resolver ChainResolver + chainStore *common.ChainStore + logger zerolog.Logger +} + +// NewReadEventProcessor creates the handler for READ_REQUEST events. +func NewReadEventProcessor( + voter readVoter, + resolver ChainResolver, + database *db.DB, + logger zerolog.Logger, +) (*ReadEventProcessor, error) { + if database == nil { + return nil, ErrNilDatabase + } + + return &ReadEventProcessor{ + voter: voter, + resolver: resolver, + chainStore: common.NewChainStore(database), + logger: logger.With().Str("component", "push_read_event_processor").Logger(), + }, nil +} + +// HandleEvent implements EventHandler for READ_REQUEST events. +func (p *ReadEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + if p.isExpired(event) { + p.logger.Info().Str("event_id", event.EventID).Msg("read request expired; marking reverted") + p.markReverted(event.EventID) + return nil + } + + var req uread.ReadRequest + if err := json.Unmarshal(event.EventData, &req); err != nil { + p.markReverted(event.EventID) + return err + } + + log := p.logger.With().Str("request_id", req.RequestID).Logger() + + destClient, err := p.resolver.GetClient(req.DestinationChain) + if err != nil { + // destination not served by this validator yet; retry next tick + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("destination chain not served") + return nil + } + + handler, err := destClient.GetReadRequestHandler() + if err != nil { + // destination client not ready to serve reads yet; retry next tick + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("read handler not available") + return nil + } + + result, err := handler.ExecuteRead(ctx, &req) + if err != nil { + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("read execution failed; will retry") + return nil + } + + voteTxHash, err := p.voter.VoteReadResult(ctx, req.RequestID, result) + if err != nil { + // TODO(core): ErrVoteReadNotAvailable falls through here until MsgVoteReadResult lands. + log.Warn().Err(err).Msg("failed to vote read result; will retry") + return nil + } + + rowsAffected, err := p.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) + if err != nil { + return err + } + if rowsAffected == 0 { + return nil + } + + log.Info(). + Str("vote_tx_hash", voteTxHash). + Int32("status", int32(result.Status)). + Uint64("observed_height", result.ObservedBlockHeight). + Msg("read request voted") + + return nil +} + +// isExpired reports whether the request's expiry Push chain height has been +// reached, using the chain height persisted by the event listener. +func (p *ReadEventProcessor) isExpired(event *store.Event) bool { + if event.ExpiryBlockHeight == 0 { + return false + } + pushHeight, err := p.chainStore.GetChainHeight() + if err != nil { + return false + } + return pushHeight >= event.ExpiryBlockHeight +} + +func (p *ReadEventProcessor) markReverted(eventID string) { + if _, err := p.chainStore.UpdateEventStatus(eventID, store.StatusConfirmed, store.StatusReverted); err != nil { + p.logger.Error().Err(err).Str("event_id", eventID).Msg("failed to mark read request reverted") + } +} diff --git a/universalClient/pushwatcher/read_event_processor_test.go b/universalClient/pushwatcher/read_event_processor_test.go new file mode 100644 index 00000000..e1ad5bd6 --- /dev/null +++ b/universalClient/pushwatcher/read_event_processor_test.go @@ -0,0 +1,215 @@ +package pushwatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +type fakeReadVoter struct { + votes map[string]*uread.ReadResult + txHash string + err error +} + +func (f *fakeReadVoter) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + if f.err != nil { + return "", f.err + } + if f.votes == nil { + f.votes = make(map[string]*uread.ReadResult) + } + f.votes[requestID] = result + return f.txHash, nil +} + +type fakeDestClient struct { + result *uread.ReadResult + err error + notStarted bool +} + +func (f *fakeDestClient) Start(ctx context.Context) error { return nil } +func (f *fakeDestClient) Stop() error { return nil } +func (f *fakeDestClient) IsHealthy() bool { return true } +func (f *fakeDestClient) GetTxBuilder() (common.TxBuilder, error) { + return nil, fmt.Errorf("not supported") +} +func (f *fakeDestClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if f.notStarted { + return nil, fmt.Errorf("client not started") + } + return f, nil +} +func (f *fakeDestClient) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + return f.result, f.err +} + +type fakeChainResolver struct { + client common.ChainClient +} + +func (f *fakeChainResolver) GetClient(chainID string) (common.ChainClient, error) { + if f.client == nil { + return nil, fmt.Errorf("no client for %s", chainID) + } + return f.client, nil +} + +func testReadRequest() *uread.ReadRequest { + return &uread.ReadRequest{ + RequestID: "0xabc123", + DestinationChain: "eip155:11155111", + Query: []byte{0x01}, + MinConfirmations: 1, + DestinationBlockHeight: 100, + CreatedAtHeight: 7, + } +} + +func newTestReadEventProcessor(t *testing.T, voter readVoter, destClient common.ChainClient) (*ReadEventProcessor, *common.ChainStore) { + t.Helper() + database := newTestDB(t) + p, err := NewReadEventProcessor(voter, &fakeChainResolver{client: destClient}, database, zerolog.Nop()) + require.NoError(t, err) + return p, common.NewChainStore(database) +} + +func seedReadRequest(t *testing.T, cs *common.ChainStore, req *uread.ReadRequest) *store.Event { + t.Helper() + event, err := convertReadRequestEvent(req) + require.NoError(t, err) + stored, err := cs.InsertEventIfNotExists(event) + require.NoError(t, err) + require.True(t, stored) + return event +} + +func assertStatus(t *testing.T, cs *common.ChainStore, eventID, status string) { + t.Helper() + rows, err := cs.UpdateEventStatus(eventID, status, status) + require.NoError(t, err) + assert.Equal(t, int64(1), rows, "event %s not in status %s", eventID, status) +} + +func TestReadEventProcessor_SuccessFlow(t *testing.T) { + req := testReadRequest() + result := &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: []byte{0xaa}, + ObservedBlockHeight: 100, + } + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: result}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + require.Contains(t, voter.votes, req.RequestID) + assert.Equal(t, result, voter.votes[req.RequestID]) + assertStatus(t, cs, event.EventID, store.StatusCompleted) +} + +func TestReadEventProcessor_VoteFailureKeepsConfirmed(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{err: fmt.Errorf("MsgVoteReadResult not available")} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_ExecutionFailureRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{err: fmt.Errorf("rpc down")}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_UnservedChainRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, nil) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_HandlerUnavailableRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{notStarted: true}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_CorruptEventReverted(t *testing.T) { + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + + event := &store.Event{ + EventID: "corrupt-read", + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("not json"), + } + stored, err := cs.InsertEventIfNotExists(event) + require.NoError(t, err) + require.True(t, stored) + + require.Error(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusReverted) +} + +func TestReadEventProcessor_ExpiredMarkedReverted(t *testing.T) { + req := testReadRequest() + req.ExpiryBlockHeight = 50 + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + require.NoError(t, cs.UpdateChainHeight(100)) // push chain past expiry + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusReverted) +} + +func TestReadEventProcessor_NotExpiredProcessesNormally(t *testing.T) { + req := testReadRequest() + req.ExpiryBlockHeight = 200 + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + require.NoError(t, cs.UpdateChainHeight(100)) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + require.Contains(t, voter.votes, req.RequestID) + assertStatus(t, cs, event.EventID, store.StatusCompleted) +} diff --git a/universalClient/store/models.go b/universalClient/store/models.go index 98ef87d8..b3cc331b 100644 --- a/universalClient/store/models.go +++ b/universalClient/store/models.go @@ -28,6 +28,7 @@ const ( EventTypeSignFundMigrate = "SIGN_FUND_MIGRATE" EventTypeInbound = "INBOUND" EventTypeOutbound = "OUTBOUND" + EventTypeReadRequest = "READ_REQUEST" ) // Confirmation type values. diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index 69cf0f26..b4ea3cd4 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -84,6 +84,9 @@ type coordMockChainClient struct { func (m *coordMockChainClient) Start(context.Context) error { return nil } func (m *coordMockChainClient) Stop() error { return nil } func (m *coordMockChainClient) IsHealthy() bool { return true } +func (m *coordMockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *coordMockChainClient) GetTxBuilder() (common.TxBuilder, error) { if m.builderErr != nil { return nil, m.builderErr diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index 653d13d0..dae85fb3 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -79,9 +79,12 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo type mockChainClient struct{ builder *mockTxBuilder } -func (m *mockChainClient) Start(context.Context) error { return nil } -func (m *mockChainClient) Stop() error { return nil } -func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index b7df913f..b13aec47 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -76,9 +76,12 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo type mockChainClient struct{ builder *mockTxBuilder } -func (m *mockChainClient) Start(context.Context) error { return nil } -func (m *mockChainClient) Stop() error { return nil } -func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { diff --git a/universalClient/uread/types.go b/universalClient/uread/types.go new file mode 100644 index 00000000..bc706cb3 --- /dev/null +++ b/universalClient/uread/types.go @@ -0,0 +1,46 @@ +// Package uread is a TEMPORARY package: it mirrors the read-request proto types +// x/uexecutor will generate (proto/uexecutor/v1/read_request.proto + tx.proto). +// +// TODO(core): once core lands, replace every uread.* reference with the +// generated uexecutortypes equivalents and delete this package. +package uread + +// ReadRequest mirrors the pending read request tracked by x/uexecutor. +type ReadRequest struct { + RequestID string // uint256 as 0x-prefixed hex (from ReadRequested event) + DestinationChain string // CAIP-2, e.g. "eip155:1", "solana:mainnet-beta"; web2 uses "web2:https" + Owner []byte // ReadSpec.account.owner (20-byte addr / 32-byte pubkey) + Query []byte // chain-specific envelope, abi.encode(...) + MinConfirmations uint16 + DestinationBlockHeight uint64 // destination chain height the read is made at; not applicable for web2 + ExpiryBlockHeight uint64 // Push chain height at which the request expires + CreatedAtHeight uint64 // Push chain height at which the request was created +} + +// ReadStatus is the observed outcome a validator votes on. +type ReadStatus int32 + +const ( + ReadStatusSuccess ReadStatus = 1 + ReadStatusError ReadStatus = 2 +) + +// ReadResult is the canonical observation submitted via MsgVoteReadResult. +// All fields must be byte-identical across validators for quorum. +type ReadResult struct { + Status ReadStatus + ResultData []byte + ObservedBlockHeight uint64 // block number (EVM) or slot (SVM) + ObservedBlockHash []byte // 32 bytes; empty when the chain cannot pin one deterministically + ErrorMsg string // local diagnostic only — never part of the ballot +} + +// NewErrorResult builds an ERROR observation. ResultData stays empty so all +// validators voting ERROR converge on the same ballot regardless of local error text. +func NewErrorResult(err error) *ReadResult { + msg := "" + if err != nil { + msg = err.Error() + } + return &ReadResult{Status: ReadStatusError, ErrorMsg: msg} +}