From 3be58dda22ebd5e9713550959eb2c0d74c268545 Mon Sep 17 00:00:00 2001 From: ilija42 Date: Mon, 19 Oct 2026 18:57:50 +0200 Subject: [PATCH 1/4] EVM chain capability emits ChainTransactionObserved events to ChIP for every mined WriteReport transaction --- chain_capabilities/evm/actions/actions.go | 17 +- chain_capabilities/evm/actions/tx_events.go | 201 +++++++++ .../evm/actions/tx_events_test.go | 404 +++++++++++++++++ .../evm/actions/write_report.go | 34 +- .../evm/actions/write_report_test.go | 2 + chain_capabilities/evm/config/config.go | 16 + chain_capabilities/evm/go.mod | 2 +- chain_capabilities/evm/main.go | 37 +- .../txevents/chain_transaction_observed.pb.go | 408 ++++++++++++++++++ .../txevents/chain_transaction_observed.proto | 67 +++ chain_capabilities/evm/txevents/emitter.go | 192 +++++++++ .../evm/txevents/emitter_test.go | 114 +++++ chain_capabilities/evm/txevents/generate.go | 3 + 13 files changed, 1493 insertions(+), 4 deletions(-) create mode 100644 chain_capabilities/evm/actions/tx_events.go create mode 100644 chain_capabilities/evm/actions/tx_events_test.go create mode 100644 chain_capabilities/evm/txevents/chain_transaction_observed.pb.go create mode 100644 chain_capabilities/evm/txevents/chain_transaction_observed.proto create mode 100644 chain_capabilities/evm/txevents/emitter.go create mode 100644 chain_capabilities/evm/txevents/emitter_test.go create mode 100644 chain_capabilities/evm/txevents/generate.go diff --git a/chain_capabilities/evm/actions/actions.go b/chain_capabilities/evm/actions/actions.go index 2a1903f9e..c09a682ac 100644 --- a/chain_capabilities/evm/actions/actions.go +++ b/chain_capabilities/evm/actions/actions.go @@ -37,6 +37,7 @@ import ( "github.com/smartcontractkit/capabilities/chain_capabilities/evm/internal/contracts" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/metering" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/monitoring" + "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" ctypes "github.com/smartcontractkit/capabilities/libs/chainconsensus/types" commonMon "github.com/smartcontractkit/capabilities/libs/monitoring" ) @@ -63,10 +64,20 @@ type EVM struct { writeReportL1FeeActive limits.RangeLimiter[commoncfg.Timestamp] transmissionScheduler ts.TransmissionScheduler + + txEvents TxEventsOptions +} + +// Option configures optional behaviour of the EVM capability. +type Option func(*EVM) + +// WithTxEvents enables emission of ChainTransactionObserved events for the Transactions Viewer. +func WithTxEvents(opts TxEventsOptions) Option { + return func(e *EVM) { e.txEvents = opts } } func NewEVM(cfg config.Config, evmService types.EVMService, lggr logger.Logger, beholderProcessor beholder.ProtoProcessor, - messageBuilder *monitoring.MessageBuilder, handler chainconsensus.RequestHandler, chainSelector uint64, limitsFactory limits.Factory, transmissionScheduler ts.TransmissionScheduler) (*EVM, caperrors.Error) { + messageBuilder *monitoring.MessageBuilder, handler chainconsensus.RequestHandler, chainSelector uint64, limitsFactory limits.Factory, transmissionScheduler ts.TransmissionScheduler, opts ...Option) (*EVM, caperrors.Error) { keystoneForwarderAddress := common.HexToAddress(cfg.CREForwarderAddress) if keystoneForwarderAddress == (common.Address{}) { return &EVM{}, caperrors.NewPublicSystemError(errors.New("keystone forwarder address is not set"), caperrors.FailedPrecondition) @@ -88,6 +99,10 @@ func NewEVM(cfg config.Config, evmService types.EVMService, lggr logger.Logger, consensusHandler: handler, chainSelector: chainSelector, transmissionScheduler: transmissionScheduler, + txEvents: TxEventsOptions{Emitter: txevents.NopEmitter{}}, + } + for _, opt := range opts { + opt(e) } err = e.initLimiters(limitsFactory) if err != nil { diff --git a/chain_capabilities/evm/actions/tx_events.go b/chain_capabilities/evm/actions/tx_events.go new file mode 100644 index 000000000..3a1328b08 --- /dev/null +++ b/chain_capabilities/evm/actions/tx_events.go @@ -0,0 +1,201 @@ +package actions + +import ( + "context" + "encoding/hex" + "errors" + "math/big" + "time" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" + + "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" +) + +// TxEventsOptions wires ChainTransactionObserved emission into the EVM capability. The events +// feed the CRE Transactions Viewer through ChIP and the Blockchain Finality Service. +type TxEventsOptions struct { + // Emitter publishes the events. Nil or NopEmitter disables emission. + Emitter txevents.Emitter + // TransmitterOnly emits only the transactions this node sent. Otherwise every node that + // observed a transaction emits it and the consumer dedups by (chain selector, tx hash). + TransmitterOnly bool + // CapabilityID is stamped on every event. + CapabilityID string +} + +// txObservation is one transaction a WriteReport request saw on chain. +type txObservation struct { + hash evmtypes.Hash + txStatus txevents.TxStatus + receiverStatus txevents.ReceiverStatus + fee *big.Int + blockNumber uint64 + errorMessage string + // transmitter is true when this node sent the transaction. + transmitter bool + // duplicate is true when this node's transaction reverted because another node's + // transmission of the same report had already landed. + duplicate bool +} + +// txObservations accumulates the observations of one request, one entry per transaction hash, +// in the order the hashes were first seen. +type txObservations struct { + list []*txObservation +} + +func (o *txObservations) find(hash evmtypes.Hash) *txObservation { + for _, obs := range o.list { + if obs.hash == hash { + return obs + } + } + return nil +} + +// record merges obs into the entry for its hash: unset fields are filled in and flags are OR'd, +// so a hash-only observation taken before the receipt lookup is enriched by a later one. +func (o *txObservations) record(obs txObservation) { + cur := o.find(obs.hash) + if cur == nil { + c := obs + o.list = append(o.list, &c) + return + } + if cur.txStatus == txevents.TxStatus_TX_STATUS_UNSPECIFIED { + cur.txStatus = obs.txStatus + } + if cur.receiverStatus == txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED { + cur.receiverStatus = obs.receiverStatus + } + if cur.fee == nil { + cur.fee = obs.fee + } + if cur.blockNumber == 0 { + cur.blockNumber = obs.blockNumber + } + if cur.errorMessage == "" { + cur.errorMessage = obs.errorMessage + } + cur.transmitter = cur.transmitter || obs.transmitter + cur.duplicate = cur.duplicate || obs.duplicate +} + +// recordOwnTx records the transaction this node sent in a new transmission attempt. When the +// reply was built from a different transaction, ours reverted as a duplicate of a transmission +// that had already landed; it still cost gas and is recorded as such. When no reply transaction +// is known (the request failed after sending), the transaction is recorded with the status the +// transaction manager reported and no duplicate flag. +func (o *txObservations) recordOwnTx(result *evmtypes.TransactionResult) { + if result == nil || result.TxStatus == evmtypes.TxFatal || result.TxHash == (evmtypes.Hash{}) { + return + } + obs := txObservation{ + hash: result.TxHash, + txStatus: txStatusFromTxmgr(result.TxStatus), + transmitter: true, + duplicate: len(o.list) > 0 && o.find(result.TxHash) == nil, + } + o.record(obs) +} + +func txStatusFromTxmgr(s evmtypes.TransactionStatus) txevents.TxStatus { + switch s { + case evmtypes.TxSuccess: + return txevents.TxStatus_TX_STATUS_SUCCESS + case evmtypes.TxReverted: + return txevents.TxStatus_TX_STATUS_REVERTED + default: + return txevents.TxStatus_TX_STATUS_UNSPECIFIED + } +} + +func txStatusFromReceipt(receiptStatus uint64) txevents.TxStatus { + if receiptStatus == 0 { + return txevents.TxStatus_TX_STATUS_REVERTED + } + return txevents.TxStatus_TX_STATUS_SUCCESS +} + +func receiverStatusFromReply(s evm.ReceiverContractExecutionStatus) txevents.ReceiverStatus { + switch s { + case evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS: + return txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS + case evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED: + return txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED + default: + return txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED + } +} + +func blockNumberFromReceipt(bn *big.Int) uint64 { + if bn == nil || !bn.IsUint64() { + return 0 + } + return bn.Uint64() +} + +// formatTxHash renders an EVM hash the way explorers and the Transactions Viewer expect. +func formatTxHash(h evmtypes.Hash) string { + return "0x" + hex.EncodeToString(h[:]) +} + +func buildTxEvent(obs *txObservation, md capabilities.RequestMetadata, chainSelector uint64, capabilityID string, observedAt time.Time) *txevents.ChainTransactionObserved { + ev := &txevents.ChainTransactionObserved{ + WorkflowExecutionId: md.WorkflowExecutionID, + WorkflowId: md.WorkflowID, + WorkflowOwner: md.WorkflowOwner, + ReferenceId: md.ReferenceID, + WorkflowDonId: md.WorkflowDonID, + OrgId: md.OrgID, + + ChainSelector: chainSelector, + TxHash: formatTxHash(obs.hash), + CapabilityId: capabilityID, + + TxStatus: obs.txStatus, + ReceiverStatus: obs.receiverStatus, + BlockNumber: obs.blockNumber, + ErrorMessage: obs.errorMessage, + + ObservedAtMs: observedAt.UnixMilli(), + Transmitter: obs.transmitter, + Duplicate: obs.duplicate, + } + if obs.fee != nil { + ev.TransactionFee = obs.fee.String() + } + return ev +} + +// emitTxEvents publishes one ChainTransactionObserved per observed transaction. It runs after +// the WriteReport outcome is decided, regardless of whether the request succeeded, and never +// changes that outcome: failures are counted by the emitter and logged here. The request context +// may already be done when the request failed, so emission uses a detached context bounded by +// the emitter's own timeout. +func (e *EVM) emitTxEvents(ctx context.Context, lggr logger.SugaredLogger, md capabilities.RequestMetadata, observed []*txObservation) { + if e.txEvents.Emitter == nil || len(observed) == 0 { + return + } + if _, nop := e.txEvents.Emitter.(txevents.NopEmitter); nop { + return + } + emitCtx := context.WithoutCancel(ctx) + now := time.Now() + for _, obs := range observed { + if e.txEvents.TransmitterOnly && !obs.transmitter { + continue + } + ev := buildTxEvent(obs, md, e.chainSelector, e.txEvents.CapabilityID, now) + if err := e.txEvents.Emitter.Emit(emitCtx, ev); err != nil { + if errors.Is(err, txevents.ErrEmitterUnavailable) { + continue // logged once per process by the emitter + } + lggr.Errorw("Failed to emit ChainTransactionObserved", append(txevents.LogKVs(ev), "error", err.Error())...) + } + } +} diff --git a/chain_capabilities/evm/actions/tx_events_test.go b/chain_capabilities/evm/actions/tx_events_test.go new file mode 100644 index 000000000..83de63cee --- /dev/null +++ b/chain_capabilities/evm/actions/tx_events_test.go @@ -0,0 +1,404 @@ +package actions + +import ( + "context" + "errors" + "math/big" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" + + "github.com/smartcontractkit/capabilities/chain_capabilities/common/test" + "github.com/smartcontractkit/capabilities/chain_capabilities/evm/internal/contracts" + "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" +) + +const testCapabilityID = "evm:ChainSelector:1@1.0.0" + +// recordingTxEventEmitter captures emitted events so tests can assert on them. +type recordingTxEventEmitter struct { + mu sync.Mutex + events []*txevents.ChainTransactionObserved + err error +} + +func (r *recordingTxEventEmitter) Emit(_ context.Context, ev *txevents.ChainTransactionObserved) error { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, ev) + return r.err +} + +func recordingEmitter(t *testing.T, service *EVM) *recordingTxEventEmitter { + t.Helper() + rec, ok := service.txEvents.Emitter.(*recordingTxEventEmitter) + require.True(t, ok, "service must use the recording emitter") + return rec +} + +func recordedTxEvents(t *testing.T, service *EVM) []*txevents.ChainTransactionObserved { + t.Helper() + rec := recordingEmitter(t, service) + rec.mu.Lock() + defer rec.mu.Unlock() + return append([]*txevents.ChainTransactionObserved(nil), rec.events...) +} + +func TestTxObservations(t *testing.T) { + t.Parallel() + replyHash := evmtypes.Hash(test.RandomBytes(32)) + ownHash := evmtypes.Hash(test.RandomBytes(32)) + + t.Run("record merges by hash and only fills unset fields", func(t *testing.T) { + var o txObservations + o.record(txObservation{hash: replyHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver"}) + o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, blockNumber: 7, errorMessage: "ignored"}) + o.record(txObservation{hash: replyHash, fee: big.NewInt(2000), transmitter: true}) + require.Len(t, o.list, 1) + got := o.list[0] + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, got.txStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, got.receiverStatus) + require.Equal(t, uint64(7), got.blockNumber) + require.Equal(t, "invalid receiver", got.errorMessage) + require.Equal(t, big.NewInt(2000), got.fee) + require.True(t, got.transmitter) + require.False(t, got.duplicate) + }) + + t.Run("recordOwnTx marks the reply transaction as sent by this node", func(t *testing.T) { + var o txObservations + o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS}) + o.recordOwnTx(&evmtypes.TransactionResult{TxHash: replyHash, TxStatus: evmtypes.TxSuccess}) + require.Len(t, o.list, 1) + require.True(t, o.list[0].transmitter) + require.False(t, o.list[0].duplicate) + }) + + t.Run("recordOwnTx adds our reverted duplicate when the reply came from another transaction", func(t *testing.T) { + var o txObservations + o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS}) + o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) + require.Len(t, o.list, 2) + require.False(t, o.list[0].transmitter) + dup := o.list[1] + require.Equal(t, ownHash, dup.hash) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.txStatus) + require.True(t, dup.transmitter) + require.True(t, dup.duplicate) + }) + + t.Run("recordOwnTx without a reply transaction is not flagged duplicate", func(t *testing.T) { + var o txObservations + o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) + require.Len(t, o.list, 1) + require.True(t, o.list[0].transmitter) + require.False(t, o.list[0].duplicate) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, o.list[0].txStatus) + }) + + t.Run("recordOwnTx ignores nil, fatal and zero-hash results", func(t *testing.T) { + var o txObservations + o.recordOwnTx(nil) + o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxFatal}) + o.recordOwnTx(&evmtypes.TransactionResult{TxStatus: evmtypes.TxSuccess}) + require.Empty(t, o.list) + }) +} + +func TestBuildTxEvent(t *testing.T) { + t.Parallel() + hash := evmtypes.Hash(test.RandomBytes(32)) + md := capabilities.RequestMetadata{ + WorkflowID: "wf-id", + WorkflowOwner: "0xowner", + OrgID: "org-1", + WorkflowExecutionID: "exec-1", + WorkflowDonID: 7, + ReferenceID: "write-step", + } + now := time.UnixMilli(1_700_000_000_000) + + ev := buildTxEvent(&txObservation{ + hash: hash, + txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, + receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, + fee: big.NewInt(2000), + blockNumber: 99, + errorMessage: "receiver contract execution failure", + transmitter: true, + duplicate: false, + }, md, 1, testCapabilityID, now) + + require.Equal(t, "exec-1", ev.WorkflowExecutionId) + require.Equal(t, "wf-id", ev.WorkflowId) + require.Equal(t, "0xowner", ev.WorkflowOwner) + require.Equal(t, "write-step", ev.ReferenceId) + require.Equal(t, uint32(7), ev.WorkflowDonId) + require.Equal(t, "org-1", ev.OrgId) + require.Equal(t, uint64(1), ev.ChainSelector) + require.Equal(t, formatTxHash(hash), ev.TxHash) + require.Len(t, ev.TxHash, 66) + require.Equal(t, "0x", ev.TxHash[:2]) + require.Equal(t, testCapabilityID, ev.CapabilityId) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(99), ev.BlockNumber) + require.Equal(t, "receiver contract execution failure", ev.ErrorMessage) + require.Equal(t, now.UnixMilli(), ev.ObservedAtMs) + require.True(t, ev.Transmitter) + require.False(t, ev.Duplicate) + + noFee := buildTxEvent(&txObservation{hash: hash}, md, 1, testCapabilityID, now) + require.Empty(t, noFee.TransactionFee) + require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, noFee.TxStatus) +} + +func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { + t.Parallel() + + t.Run("prior successful transmission emits one success event not sent by this node", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(1234)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + fixture := newWriteReportTestFixture(t) + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, ev.ReceiverStatus) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1234), ev.BlockNumber) + require.Empty(t, ev.ErrorMessage) + require.False(t, ev.Transmitter) + require.False(t, ev.Duplicate) + require.Equal(t, fixture.metadata.WorkflowExecutionID, ev.WorkflowExecutionId) + require.Equal(t, fixture.metadata.WorkflowID, ev.WorkflowId) + require.Equal(t, fixture.metadata.WorkflowOwner, ev.WorkflowOwner) + require.Equal(t, fixture.metadata.ReferenceID, ev.ReferenceId) + require.Equal(t, fixture.metadata.WorkflowDonID, ev.WorkflowDonId) + require.Equal(t, uint64(1), ev.ChainSelector) + require.Equal(t, testCapabilityID, ev.CapabilityId) + require.NotZero(t, ev.ObservedAtMs) + }) + + t.Run("prior invalid receiver emits a receiver-reverted event with the revert reason", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + fixture := newWriteReportTestFixture(t) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{InvalidReceiver: true, State: contracts.TransmissionStateInvalidReceiver, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectFailedTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(55)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) + require.Equal(t, fixture.transmissionID.InvalidReceiverMessage(), ev.ErrorMessage) + require.Equal(t, uint64(55), ev.BlockNumber) + require.False(t, ev.Transmitter) + mockForwarderClient.AssertNotCalled(t, "InvokeOnReport", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("new transmission sent by this node is flagged transmitter", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + fixture := newWriteReportTestFixture(t) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + mockForwarderClient. + On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). + Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess, TxIdempotencyKey: "key"}, nil).Once() + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(77)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + require.Equal(t, formatTxHash(txHash), events[0].TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, events[0].TxStatus) + require.True(t, events[0].Transmitter) + require.False(t, events[0].Duplicate) + }) + + runDuplicate := func(t *testing.T, transmitterOnly bool) ([]*txevents.ChainTransactionObserved, evmtypes.Hash, evmtypes.Hash) { + t.Helper() + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + service.txEvents.TransmitterOnly = transmitterOnly + fixture := newWriteReportTestFixture(t) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() + + ownHash := evmtypes.Hash(test.RandomBytes(32)) + landedHash := evmtypes.Hash(test.RandomBytes(32)) + mockForwarderClient. + On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). + Return(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted, TxIdempotencyKey: "dup"}, nil).Once() + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + expectSuccessfulTransmissionEvent(t, mockForwarderClient, landedHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: landedHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(88)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: landedHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + return recordedTxEvents(t, service), landedHash, ownHash + } + + t.Run("duplicate transmission emits the landed transaction and our reverted duplicate", func(t *testing.T) { + events, landedHash, ownHash := runDuplicate(t, false) + require.Len(t, events, 2) + + landed := events[0] + require.Equal(t, formatTxHash(landedHash), landed.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, landed.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, landed.ReceiverStatus) + require.Equal(t, "2000", landed.TransactionFee) + require.False(t, landed.Transmitter) + require.False(t, landed.Duplicate) + + dup := events[1] + require.Equal(t, formatTxHash(ownHash), dup.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, dup.ReceiverStatus) + require.Empty(t, dup.TransactionFee) + require.Zero(t, dup.BlockNumber) + require.True(t, dup.Transmitter) + require.True(t, dup.Duplicate) + require.NotEqual(t, txevents.IdempotencyKey(landed), txevents.IdempotencyKey(dup)) + }) + + t.Run("transmitter-only mode emits only transactions this node sent", func(t *testing.T) { + events, _, ownHash := runDuplicate(t, true) + require.Len(t, events, 1) + require.Equal(t, formatTxHash(ownHash), events[0].TxHash) + require.True(t, events[0].Transmitter) + require.True(t, events[0].Duplicate) + }) + + t.Run("receipt lookup failure still emits the transaction hash", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil) + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(nil, errors.New("rpc down")) + + fixture := newWriteReportTestFixture(t) + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.Error(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + require.Equal(t, formatTxHash(txHash), events[0].TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, events[0].TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, events[0].ReceiverStatus) + require.Empty(t, events[0].TransactionFee) + }) + + t.Run("emitter failure does not fail the write", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + recordingEmitter(t, service).err = errors.New("chip unreachable") + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + fixture := newWriteReportTestFixture(t) + txResult, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + require.Equal(t, txHash[:], txResult.Response.TxHash) + require.Len(t, recordedTxEvents(t, service), 1) + }) + + t.Run("disabled emission leaves the write untouched", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + service.txEvents.Emitter = txevents.NopEmitter{} + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + fixture := newWriteReportTestFixture(t) + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + }) +} diff --git a/chain_capabilities/evm/actions/write_report.go b/chain_capabilities/evm/actions/write_report.go index 418b4f9ae..330b985f7 100644 --- a/chain_capabilities/evm/actions/write_report.go +++ b/chain_capabilities/evm/actions/write_report.go @@ -55,6 +55,12 @@ type WriteReport struct { executionTimestamp time.Time gasMeter *gasmeter.Meter + + // observed collects every transaction this request saw on chain; ownTx is the transaction + // this node sent, if it made a new transmission attempt. Both feed ChainTransactionObserved + // emission once the outcome is decided. + observed txObservations + ownTx *evmtypes.TransactionResult } func (e *EVM) WriteReport(ctx context.Context, metadata capabilities.RequestMetadata, input *evm.WriteReportRequest) (*capabilities.ResponseAndMetadata[*evm.WriteReportReply], caperrors.Error) { @@ -103,7 +109,10 @@ func (e *EVM) executeWriteReport(ctx context.Context, request *evm.WriteReportRe gasMeter: e.gasMeter, } - return wr.executeWriteReport(ctx, request, metadata, telemetryContext) + reply, responseMetadata, err := wr.executeWriteReport(ctx, request, metadata, telemetryContext) + wr.observed.recordOwnTx(wr.ownTx) + e.emitTxEvents(ctx, wr.lggr, metadata, wr.observed.list) + return reply, responseMetadata, err } func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.WriteReportRequest, metadata capabilities.RequestMetadata, telemetryContext monitoring.TelemetryContext) (*evm.WriteReportReply, capabilities.ResponseMetadata, error) { @@ -216,6 +225,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write e.lggr.Errorw("Transaction failed", "error", err.Error()) return nil, capabilities.ResponseMetadata{}, err } + e.ownTx = transactionResult newTransmissionInfo, err := capcommon.WithPollingRetry(ctx, e.lggr, func(ctx context.Context) (contracts.TransmissionInfo, error) { readTransmissionInfo, readTransmissionErr := e.forwarderClient.GetTransmissionInfo(ctx, transmissionID) @@ -482,11 +492,25 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash }) }) if err != nil { + // The transaction is on chain even though its receipt is not reachable; record what is + // known so the Transactions Viewer still learns about it. + e.observed.record(txObservation{ + hash: txHash, + receiverStatus: receiverStatusFromReply(receiverStatus), + errorMessage: derefString(errorMessage), + }) // txHash is the final hash carried by this reply (set as reply.TxHash below), so it is safe // to surface here: it lets users locate the tx on-chain when the RPC is flaky, and lets us // filter logs per node by hash. Callers add the user-facing reason (e.g. invalid receiver). return nil, fmt.Errorf("failed to get transaction receipt for tx hash 0x%x: %w", txHash[:], err) } + e.observed.record(txObservation{ + hash: txHash, + txStatus: txStatusFromReceipt(txReceipt.Status), + receiverStatus: receiverStatusFromReply(receiverStatus), + blockNumber: blockNumberFromReceipt(txReceipt.BlockNumber), + errorMessage: derefString(errorMessage), + }) receiptGasInfo := evmtypes.ReceiptGasInfo{ GasUsed: txReceipt.GasUsed, EffectiveGasPrice: txReceipt.EffectiveGasPrice, @@ -512,6 +536,7 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash if txReceipt.Status == 0 { txStatus = evm.TxStatus_TX_STATUS_REVERTED } + e.observed.record(txObservation{hash: txHash, fee: transactionFee.TransactionFee, errorMessage: derefString(message)}) e.lggr.Infow("Successfully fetched tx receipt", "txHash", hex.EncodeToString(txHash[:]), @@ -529,6 +554,13 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash }, nil } +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + // meteringFromReply returns billing metadata carrying the on-chain transaction fee contained // in the reply, so that every node reports the gas spent on chain, regardless of which node // transmitted. Deriving it from the reply guarantees the metered fee matches the reply's diff --git a/chain_capabilities/evm/actions/write_report_test.go b/chain_capabilities/evm/actions/write_report_test.go index f4bc2d10b..76a63175a 100644 --- a/chain_capabilities/evm/actions/write_report_test.go +++ b/chain_capabilities/evm/actions/write_report_test.go @@ -2249,6 +2249,7 @@ func createMocksAndCapability(t *testing.T, lggr logger.Logger) (*mocks2.EVMServ beholderProcessor: test.NopBeholderProcessor{}, messageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), transmissionScheduler: transmissionScheduler, + txEvents: TxEventsOptions{Emitter: &recordingTxEventEmitter{}, CapabilityID: testCapabilityID}, } require.NoError(t, service.initLimiters(limits.Factory{Logger: lggr})) t.Cleanup(func() { assert.NoError(t, service.Close()) }) @@ -2275,6 +2276,7 @@ func createMocksAndCapabilityWithScheduler( beholderProcessor: test.NopBeholderProcessor{}, messageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), transmissionScheduler: scheduler, + txEvents: TxEventsOptions{Emitter: &recordingTxEventEmitter{}, CapabilityID: testCapabilityID}, } require.NoError(t, service.initLimiters(limits.Factory{Logger: lggr})) t.Cleanup(func() { assert.NoError(t, service.Close()) }) diff --git a/chain_capabilities/evm/config/config.go b/chain_capabilities/evm/config/config.go index 8cb56045f..75debc5b6 100644 --- a/chain_capabilities/evm/config/config.go +++ b/chain_capabilities/evm/config/config.go @@ -21,4 +21,20 @@ type Config struct { // DeltaStage for staggered transmission scheduling DeltaStage time.Duration `json:"deltaStage"` IsLocaL bool `json:"isLocal"` // use in integration-test to skip transmission scheduler initialization for log_trigger + + // TxEvents controls emission of ChainTransactionObserved events, which feed the CRE + // Transactions Viewer through ChIP and the Blockchain Finality Service (PLEX-3409). + TxEvents TxEventsConfig `json:"txEvents"` +} + +// TxEventsConfig configures ChainTransactionObserved emission. +type TxEventsConfig struct { + // Enabled turns emission on. The node must also run with CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED + // for the events to leave the process; otherwise they are counted as dropped. + Enabled bool `json:"enabled"` + // TransmitterOnly restricts emission to transactions this node sent. By default every node + // that observed a transaction emits it and the consumer dedups by (chain selector, tx hash). + TransmitterOnly bool `json:"transmitterOnly"` + // Domain is the ChIP source domain the events are published to. Defaults to txevents.DefaultDomain. + Domain string `json:"domain"` } diff --git a/chain_capabilities/evm/go.mod b/chain_capabilities/evm/go.mod index 6b1cb9d9e..5bbbb4bfe 100644 --- a/chain_capabilities/evm/go.mod +++ b/chain_capabilities/evm/go.mod @@ -182,7 +182,7 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/log v0.22.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 // indirect go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect diff --git a/chain_capabilities/evm/main.go b/chain_capabilities/evm/main.go index 833218732..c53cd4051 100644 --- a/chain_capabilities/evm/main.go +++ b/chain_capabilities/evm/main.go @@ -29,6 +29,7 @@ import ( "github.com/smartcontractkit/capabilities/chain_capabilities/evm/config" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/monitoring" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/trigger" + "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" "github.com/smartcontractkit/capabilities/libs/loopserver" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" @@ -178,7 +179,11 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor c.lggr.Infow("DeltaStage not configured, transmission scheduling disabled") } - c.EVM, err = actions.NewEVM(*cfg, evmRelayer, c.lggr, processor, messageBuilder, c.consensusHandler, c.chainSelector, c.limitsFactory, scheduler) + txEventsOpt, err := c.txEventsOption(cfg) + if err != nil { + return err + } + c.EVM, err = actions.NewEVM(*cfg, evmRelayer, c.lggr, processor, messageBuilder, c.consensusHandler, c.chainSelector, c.limitsFactory, scheduler, txEventsOpt) if err != nil { return fmt.Errorf("failed to init evm relayer for chainID %d from relayer: %w", cfg.ChainID, err) } @@ -227,6 +232,32 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor return nil } +// txEventsOption wires ChainTransactionObserved emission. Events go to ChIP through the LOOP's +// durable emitter, the same handle the gas meter uses (injected via MeteringConfig rather than +// the process-global); when the LOOP has none they are dropped and counted. +func (c *capabilityGRPCService) txEventsOption(cfg *config.Config) (actions.Option, error) { + opts := actions.TxEventsOptions{ + Emitter: txevents.NopEmitter{}, + TransmitterOnly: cfg.TxEvents.TransmitterOnly, + CapabilityID: c.id, + } + if cfg.TxEvents.Enabled { + var sink txevents.Sink + if rmEmitter := c.meteringCfg.ResourceManagerConfig.Emitter; rmEmitter != nil { + sink = rmEmitter + } else { + c.lggr.Errorw("Transaction events enabled but this LOOP has no durable emitter; events will not be delivered") + } + emitter, err := txevents.NewDurableEmitter(c.lggr, cfg.TxEvents.Domain, sink) + if err != nil { + return nil, fmt.Errorf("failed to create tx events emitter: %w", err) + } + opts.Emitter = emitter + c.lggr.Infow("Transaction events enabled", "domain", cfg.TxEvents.Domain, "transmitterOnly", cfg.TxEvents.TransmitterOnly) + } + return actions.WithTxEvents(opts), nil +} + func (c *capabilityGRPCService) unmarshalConfig(configStr string) (*config.Config, error) { var cfg config.Config if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { @@ -270,6 +301,10 @@ func (c *capabilityGRPCService) unmarshalConfig(configStr string) (*config.Confi c.lggr.Infof("MaxUnknownRequestsCacheSize is zero, setting to %d.", cfg.MaxUnknownRequestsCacheSize) } + if cfg.TxEvents.Domain == "" { + cfg.TxEvents.Domain = txevents.DefaultDomain + } + // DeltaStage is optional - if not set, transmission scheduling will be disabled return &cfg, nil } diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go new file mode 100644 index 000000000..9432035c8 --- /dev/null +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go @@ -0,0 +1,408 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v5.29.3 +// source: chain_capabilities/evm/txevents/chain_transaction_observed.proto + +// Package txevents defines the chain-agnostic transaction event every chain capability emits +// for the CRE Transactions Viewer. The package name is intentionally free of the chain family so +// Aptos, Solana and Stellar can emit the same schema; the file lives in the EVM module only until +// the ChIP domain and schema registration are settled with Data Platform (PLEX-3409). + +package txevents + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TxStatus is the on-chain outcome of the transaction itself, taken from its receipt. +type TxStatus int32 + +const ( + // The receipt was not available when the event was emitted. Consumers fetch it by hash. + TxStatus_TX_STATUS_UNSPECIFIED TxStatus = 0 + TxStatus_TX_STATUS_SUCCESS TxStatus = 1 + TxStatus_TX_STATUS_REVERTED TxStatus = 2 +) + +// Enum value maps for TxStatus. +var ( + TxStatus_name = map[int32]string{ + 0: "TX_STATUS_UNSPECIFIED", + 1: "TX_STATUS_SUCCESS", + 2: "TX_STATUS_REVERTED", + } + TxStatus_value = map[string]int32{ + "TX_STATUS_UNSPECIFIED": 0, + "TX_STATUS_SUCCESS": 1, + "TX_STATUS_REVERTED": 2, + } +) + +func (x TxStatus) Enum() *TxStatus { + p := new(TxStatus) + *p = x + return p +} + +func (x TxStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TxStatus) Descriptor() protoreflect.EnumDescriptor { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0].Descriptor() +} + +func (TxStatus) Type() protoreflect.EnumType { + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0] +} + +func (x TxStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TxStatus.Descriptor instead. +func (TxStatus) EnumDescriptor() ([]byte, []int) { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} +} + +// ReceiverStatus is the outcome of the receiver (user) contract call made by the forwarder. A +// transaction can succeed on chain while the receiver call reverted ("internally reverted"). +type ReceiverStatus int32 + +const ( + ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED ReceiverStatus = 0 + ReceiverStatus_RECEIVER_STATUS_SUCCESS ReceiverStatus = 1 + ReceiverStatus_RECEIVER_STATUS_REVERTED ReceiverStatus = 2 +) + +// Enum value maps for ReceiverStatus. +var ( + ReceiverStatus_name = map[int32]string{ + 0: "RECEIVER_STATUS_UNSPECIFIED", + 1: "RECEIVER_STATUS_SUCCESS", + 2: "RECEIVER_STATUS_REVERTED", + } + ReceiverStatus_value = map[string]int32{ + "RECEIVER_STATUS_UNSPECIFIED": 0, + "RECEIVER_STATUS_SUCCESS": 1, + "RECEIVER_STATUS_REVERTED": 2, + } +) + +func (x ReceiverStatus) Enum() *ReceiverStatus { + p := new(ReceiverStatus) + *p = x + return p +} + +func (x ReceiverStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ReceiverStatus) Descriptor() protoreflect.EnumDescriptor { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1].Descriptor() +} + +func (ReceiverStatus) Type() protoreflect.EnumType { + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1] +} + +func (x ReceiverStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ReceiverStatus.Descriptor instead. +func (ReceiverStatus) EnumDescriptor() ([]byte, []int) { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{1} +} + +// ChainTransactionObserved records one transaction a chain capability saw land on chain while +// serving a WriteReport. One event is emitted per mined transaction, including reverted and +// duplicate ones. The Blockchain Finality Service dedups events by (chain_selector, tx_hash), +// fetches the receipt and tracks finality, so the body is kept minimal: no report payload, +// signatures or request data are included. +type ChainTransactionObserved struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Attribution, copied from the capability request metadata. + WorkflowExecutionId string `protobuf:"bytes,1,opt,name=workflow_execution_id,json=workflowExecutionId,proto3" json:"workflow_execution_id,omitempty"` + WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + WorkflowOwner string `protobuf:"bytes,3,opt,name=workflow_owner,json=workflowOwner,proto3" json:"workflow_owner,omitempty"` + // Workflow step reference that issued the write. + ReferenceId string `protobuf:"bytes,4,opt,name=reference_id,json=referenceId,proto3" json:"reference_id,omitempty"` + WorkflowDonId uint32 `protobuf:"varint,5,opt,name=workflow_don_id,json=workflowDonId,proto3" json:"workflow_don_id,omitempty"` + // Informational only. Consumers must resolve the organization from the execution record. + OrgId string `protobuf:"bytes,6,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + // Transaction identity. + ChainSelector uint64 `protobuf:"varint,7,opt,name=chain_selector,json=chainSelector,proto3" json:"chain_selector,omitempty"` + // Chain-native encoding. EVM: 0x-prefixed lowercase hex. + TxHash string `protobuf:"bytes,8,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + CapabilityId string `protobuf:"bytes,9,opt,name=capability_id,json=capabilityId,proto3" json:"capability_id,omitempty"` + // Outcome as observed by the emitting node. + TxStatus TxStatus `protobuf:"varint,10,opt,name=tx_status,json=txStatus,proto3,enum=chain_capabilities.txevents.v1.TxStatus" json:"tx_status,omitempty"` + ReceiverStatus ReceiverStatus `protobuf:"varint,11,opt,name=receiver_status,json=receiverStatus,proto3,enum=chain_capabilities.txevents.v1.ReceiverStatus" json:"receiver_status,omitempty"` + // Decimal string in the chain's base unit (wei). Empty when unknown. + TransactionFee string `protobuf:"bytes,12,opt,name=transaction_fee,json=transactionFee,proto3" json:"transaction_fee,omitempty"` + // 0 when unknown. + BlockNumber uint64 `protobuf:"varint,13,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` + // User-facing revert reason, when the receiver reverted. + ErrorMessage string `protobuf:"bytes,14,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + // Emission context. + // Unix milliseconds at which the node observed the outcome. + ObservedAtMs int64 `protobuf:"varint,15,opt,name=observed_at_ms,json=observedAtMs,proto3" json:"observed_at_ms,omitempty"` + // True when the emitting node sent this transaction. + Transmitter bool `protobuf:"varint,16,opt,name=transmitter,proto3" json:"transmitter,omitempty"` + // True when this transaction reverted because another node's transmission of the same report + // had already landed. Such transactions still cost gas and must be visible to the user. + Duplicate bool `protobuf:"varint,17,opt,name=duplicate,proto3" json:"duplicate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChainTransactionObserved) Reset() { + *x = ChainTransactionObserved{} + mi := &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChainTransactionObserved) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChainTransactionObserved) ProtoMessage() {} + +func (x *ChainTransactionObserved) ProtoReflect() protoreflect.Message { + mi := &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChainTransactionObserved.ProtoReflect.Descriptor instead. +func (*ChainTransactionObserved) Descriptor() ([]byte, []int) { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} +} + +func (x *ChainTransactionObserved) GetWorkflowExecutionId() string { + if x != nil { + return x.WorkflowExecutionId + } + return "" +} + +func (x *ChainTransactionObserved) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *ChainTransactionObserved) GetWorkflowOwner() string { + if x != nil { + return x.WorkflowOwner + } + return "" +} + +func (x *ChainTransactionObserved) GetReferenceId() string { + if x != nil { + return x.ReferenceId + } + return "" +} + +func (x *ChainTransactionObserved) GetWorkflowDonId() uint32 { + if x != nil { + return x.WorkflowDonId + } + return 0 +} + +func (x *ChainTransactionObserved) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *ChainTransactionObserved) GetChainSelector() uint64 { + if x != nil { + return x.ChainSelector + } + return 0 +} + +func (x *ChainTransactionObserved) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *ChainTransactionObserved) GetCapabilityId() string { + if x != nil { + return x.CapabilityId + } + return "" +} + +func (x *ChainTransactionObserved) GetTxStatus() TxStatus { + if x != nil { + return x.TxStatus + } + return TxStatus_TX_STATUS_UNSPECIFIED +} + +func (x *ChainTransactionObserved) GetReceiverStatus() ReceiverStatus { + if x != nil { + return x.ReceiverStatus + } + return ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED +} + +func (x *ChainTransactionObserved) GetTransactionFee() string { + if x != nil { + return x.TransactionFee + } + return "" +} + +func (x *ChainTransactionObserved) GetBlockNumber() uint64 { + if x != nil { + return x.BlockNumber + } + return 0 +} + +func (x *ChainTransactionObserved) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *ChainTransactionObserved) GetObservedAtMs() int64 { + if x != nil { + return x.ObservedAtMs + } + return 0 +} + +func (x *ChainTransactionObserved) GetTransmitter() bool { + if x != nil { + return x.Transmitter + } + return false +} + +func (x *ChainTransactionObserved) GetDuplicate() bool { + if x != nil { + return x.Duplicate + } + return false +} + +var File_chain_capabilities_evm_txevents_chain_transaction_observed_proto protoreflect.FileDescriptor + +const file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc = "" + + "\n" + + "@chain_capabilities/evm/txevents/chain_transaction_observed.proto\x12\x1echain_capabilities.txevents.v1\"\xd4\x05\n" + + "\x18ChainTransactionObserved\x122\n" + + "\x15workflow_execution_id\x18\x01 \x01(\tR\x13workflowExecutionId\x12\x1f\n" + + "\vworkflow_id\x18\x02 \x01(\tR\n" + + "workflowId\x12%\n" + + "\x0eworkflow_owner\x18\x03 \x01(\tR\rworkflowOwner\x12!\n" + + "\freference_id\x18\x04 \x01(\tR\vreferenceId\x12&\n" + + "\x0fworkflow_don_id\x18\x05 \x01(\rR\rworkflowDonId\x12\x15\n" + + "\x06org_id\x18\x06 \x01(\tR\x05orgId\x12%\n" + + "\x0echain_selector\x18\a \x01(\x04R\rchainSelector\x12\x17\n" + + "\atx_hash\x18\b \x01(\tR\x06txHash\x12#\n" + + "\rcapability_id\x18\t \x01(\tR\fcapabilityId\x12E\n" + + "\ttx_status\x18\n" + + " \x01(\x0e2(.chain_capabilities.txevents.v1.TxStatusR\btxStatus\x12W\n" + + "\x0freceiver_status\x18\v \x01(\x0e2..chain_capabilities.txevents.v1.ReceiverStatusR\x0ereceiverStatus\x12'\n" + + "\x0ftransaction_fee\x18\f \x01(\tR\x0etransactionFee\x12!\n" + + "\fblock_number\x18\r \x01(\x04R\vblockNumber\x12#\n" + + "\rerror_message\x18\x0e \x01(\tR\ferrorMessage\x12$\n" + + "\x0eobserved_at_ms\x18\x0f \x01(\x03R\fobservedAtMs\x12 \n" + + "\vtransmitter\x18\x10 \x01(\bR\vtransmitter\x12\x1c\n" + + "\tduplicate\x18\x11 \x01(\bR\tduplicate*T\n" + + "\bTxStatus\x12\x19\n" + + "\x15TX_STATUS_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11TX_STATUS_SUCCESS\x10\x01\x12\x16\n" + + "\x12TX_STATUS_REVERTED\x10\x02*l\n" + + "\x0eReceiverStatus\x12\x1f\n" + + "\x1bRECEIVER_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17RECEIVER_STATUS_SUCCESS\x10\x01\x12\x1c\n" + + "\x18RECEIVER_STATUS_REVERTED\x10\x02BSZQgithub.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txeventsb\x06proto3" + +var ( + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescOnce sync.Once + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescData []byte +) + +func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP() []byte { + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescOnce.Do(func() { + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc), len(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc))) + }) + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescData +} + +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_goTypes = []any{ + (TxStatus)(0), // 0: chain_capabilities.txevents.v1.TxStatus + (ReceiverStatus)(0), // 1: chain_capabilities.txevents.v1.ReceiverStatus + (*ChainTransactionObserved)(nil), // 2: chain_capabilities.txevents.v1.ChainTransactionObserved +} +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_depIdxs = []int32{ + 0, // 0: chain_capabilities.txevents.v1.ChainTransactionObserved.tx_status:type_name -> chain_capabilities.txevents.v1.TxStatus + 1, // 1: chain_capabilities.txevents.v1.ChainTransactionObserved.receiver_status:type_name -> chain_capabilities.txevents.v1.ReceiverStatus + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init() } +func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init() { + if File_chain_capabilities_evm_txevents_chain_transaction_observed_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc), len(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc)), + NumEnums: 2, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_goTypes, + DependencyIndexes: file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_depIdxs, + EnumInfos: file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes, + MessageInfos: file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes, + }.Build() + File_chain_capabilities_evm_txevents_chain_transaction_observed_proto = out.File + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_goTypes = nil + file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_depIdxs = nil +} diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.proto b/chain_capabilities/evm/txevents/chain_transaction_observed.proto new file mode 100644 index 000000000..2f733eb38 --- /dev/null +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +// Package txevents defines the chain-agnostic transaction event every chain capability emits +// for the CRE Transactions Viewer. The package name is intentionally free of the chain family so +// Aptos, Solana and Stellar can emit the same schema; the file lives in the EVM module only until +// the ChIP domain and schema registration are settled with Data Platform (PLEX-3409). +package chain_capabilities.txevents.v1; + +option go_package = "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txevents"; + +// TxStatus is the on-chain outcome of the transaction itself, taken from its receipt. +enum TxStatus { + // The receipt was not available when the event was emitted. Consumers fetch it by hash. + TX_STATUS_UNSPECIFIED = 0; + TX_STATUS_SUCCESS = 1; + TX_STATUS_REVERTED = 2; +} + +// ReceiverStatus is the outcome of the receiver (user) contract call made by the forwarder. A +// transaction can succeed on chain while the receiver call reverted ("internally reverted"). +enum ReceiverStatus { + RECEIVER_STATUS_UNSPECIFIED = 0; + RECEIVER_STATUS_SUCCESS = 1; + RECEIVER_STATUS_REVERTED = 2; +} + +// ChainTransactionObserved records one transaction a chain capability saw land on chain while +// serving a WriteReport. One event is emitted per mined transaction, including reverted and +// duplicate ones. The Blockchain Finality Service dedups events by (chain_selector, tx_hash), +// fetches the receipt and tracks finality, so the body is kept minimal: no report payload, +// signatures or request data are included. +message ChainTransactionObserved { + // Attribution, copied from the capability request metadata. + string workflow_execution_id = 1; + string workflow_id = 2; + string workflow_owner = 3; + // Workflow step reference that issued the write. + string reference_id = 4; + uint32 workflow_don_id = 5; + // Informational only. Consumers must resolve the organization from the execution record. + string org_id = 6; + + // Transaction identity. + uint64 chain_selector = 7; + // Chain-native encoding. EVM: 0x-prefixed lowercase hex. + string tx_hash = 8; + string capability_id = 9; + + // Outcome as observed by the emitting node. + TxStatus tx_status = 10; + ReceiverStatus receiver_status = 11; + // Decimal string in the chain's base unit (wei). Empty when unknown. + string transaction_fee = 12; + // 0 when unknown. + uint64 block_number = 13; + // User-facing revert reason, when the receiver reverted. + string error_message = 14; + + // Emission context. + // Unix milliseconds at which the node observed the outcome. + int64 observed_at_ms = 15; + // True when the emitting node sent this transaction. + bool transmitter = 16; + // True when this transaction reverted because another node's transmission of the same report + // had already landed. Such transactions still cost gas and must be visible to the user. + bool duplicate = 17; +} diff --git a/chain_capabilities/evm/txevents/emitter.go b/chain_capabilities/evm/txevents/emitter.go new file mode 100644 index 000000000..025e5a12d --- /dev/null +++ b/chain_capabilities/evm/txevents/emitter.go @@ -0,0 +1,192 @@ +package txevents + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +const ( + // DefaultDomain is the ChIP source domain (and therefore the Kafka topic) the events are + // published to. Placeholder until Data Platform provisions the dedicated domain (PLEX-3409). + DefaultDomain = "cre-chain-transactions" + + // Attribute names understood by chainlink-common's durable emitter and chipingress client. + attrSource = "source" + attrType = "type" + attrIdempotencyKey = "idempotencykey" // chipingress.IdempotencyKeyAttr + attrSubject = "subject" + + // emitTimeout bounds the synchronous durable-emitter insert so a slow node database cannot + // stall the WriteReport that produced the event. + emitTimeout = 2 * time.Second +) + +// EntityType returns the CloudEvent type, and the schema full name, of ChainTransactionObserved. +// It is a function because the generated proto registers itself in an init function that runs +// after package-level variables are initialised. +func EntityType() string { + return string(proto.MessageName(&ChainTransactionObserved{})) +} + +// ErrEmitterUnavailable is returned when the LOOP has no durable emitter, which is the case when +// the node runs without CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED. Events are dropped and counted. +var ErrEmitterUnavailable = errors.New("durable emitter not available in this process") + +// Sink delivers an encoded event with its routing attributes. chainlink-common's +// durableemitter.DurableEmitter satisfies it; the LOOP server injects that instance through +// resourcemanager.Config (loop.Server.MeteringConfig) so capabilities never reach for the +// process-global emitter. +type Sink interface { + Emit(ctx context.Context, body []byte, attrKVs ...any) error +} + +// Emitter publishes ChainTransactionObserved events. +type Emitter interface { + Emit(ctx context.Context, event *ChainTransactionObserved) error +} + +// NopEmitter discards events. Used when transaction events are disabled. +type NopEmitter struct{} + +// Emit implements Emitter. +func (NopEmitter) Emit(context.Context, *ChainTransactionObserved) error { return nil } + +// IdempotencyKey identifies an observed outcome independently of the node that observed it, so +// that every node emitting the same transaction with the same status produces the same key and +// ChIP or the Blockchain Finality Service can dedup on it. +func IdempotencyKey(ev *ChainTransactionObserved) string { + return fmt.Sprintf("%d:%s:%s", ev.GetChainSelector(), ev.GetTxHash(), ev.GetTxStatus().String()) +} + +// Subject identifies the transaction itself and is set as the CloudEvent subject. +func Subject(ev *ChainTransactionObserved) string { + return fmt.Sprintf("%d:%s", ev.GetChainSelector(), ev.GetTxHash()) +} + +// LogKVs returns the event's identifying fields as logger key/values. +func LogKVs(ev *ChainTransactionObserved) []any { + return []any{ + "txHash", ev.GetTxHash(), + "chainSelector", ev.GetChainSelector(), + "txStatus", ev.GetTxStatus().String(), + "receiverStatus", ev.GetReceiverStatus().String(), + "transmitter", ev.GetTransmitter(), + "duplicate", ev.GetDuplicate(), + "workflowExecutionID", ev.GetWorkflowExecutionId(), + "referenceID", ev.GetReferenceId(), + } +} + +// DurableEmitter publishes events through the LOOP's durable emitter, which persists them in the +// node database and delivers them to ChIP Ingress at least once. +type DurableEmitter struct { + lggr logger.Logger + domain string + sink Sink + metrics *emitterMetrics + + unavailableLogged atomic.Bool +} + +// NewDurableEmitter returns an emitter publishing to the given ChIP source domain through sink. +// A nil sink means the LOOP has no durable emitter: events are dropped, counted and the +// condition is logged once. +func NewDurableEmitter(lggr logger.Logger, domain string, sink Sink) (*DurableEmitter, error) { + if domain == "" { + domain = DefaultDomain + } + m, err := newEmitterMetrics() + if err != nil { + return nil, err + } + return &DurableEmitter{ + lggr: logger.Named(lggr, "TxEvents"), + domain: domain, + sink: sink, + metrics: m, + }, nil +} + +// Emit implements Emitter. +func (d *DurableEmitter) Emit(ctx context.Context, ev *ChainTransactionObserved) error { + if ev == nil { + return errors.New("nil ChainTransactionObserved") + } + if d.sink == nil { + d.metrics.recordError(ctx, ev, "unavailable") + if d.unavailableLogged.CompareAndSwap(false, true) { + d.lggr.Warnw("Transaction events are enabled but this LOOP has no durable emitter; events are dropped", "domain", d.domain) + } + return ErrEmitterUnavailable + } + body, err := proto.Marshal(ev) + if err != nil { + d.metrics.recordError(ctx, ev, "marshal") + return fmt.Errorf("marshal ChainTransactionObserved: %w", err) + } + + emitCtx, cancel := context.WithTimeout(ctx, emitTimeout) + defer cancel() + err = d.sink.Emit(emitCtx, body, + attrSource, d.domain, + attrType, EntityType(), + attrIdempotencyKey, IdempotencyKey(ev), + attrSubject, Subject(ev), + ) + if err != nil { + d.metrics.recordError(ctx, ev, "emit") + return fmt.Errorf("emit ChainTransactionObserved: %w", err) + } + d.metrics.recordEmitted(ctx, ev) + d.lggr.Debugw("Emitted ChainTransactionObserved", LogKVs(ev)...) + return nil +} + +type emitterMetrics struct { + emitted metric.Int64Counter + errors metric.Int64Counter +} + +func newEmitterMetrics() (*emitterMetrics, error) { + meter := beholder.GetMeter() + emitted, err := meter.Int64Counter("chain_capability_tx_events_emitted_count", + metric.WithDescription("Count of ChainTransactionObserved events accepted by the durable emitter")) + if err != nil { + return nil, fmt.Errorf("failed to create tx events emitted counter: %w", err) + } + errs, err := meter.Int64Counter("chain_capability_tx_events_emit_error_count", + metric.WithDescription("Count of ChainTransactionObserved events that could not be emitted, by reason")) + if err != nil { + return nil, fmt.Errorf("failed to create tx events error counter: %w", err) + } + return &emitterMetrics{emitted: emitted, errors: errs}, nil +} + +func (m *emitterMetrics) recordEmitted(ctx context.Context, ev *ChainTransactionObserved) { + m.emitted.Add(ctx, 1, metric.WithAttributes(eventAttrs(ev)...)) +} + +func (m *emitterMetrics) recordError(ctx context.Context, ev *ChainTransactionObserved, reason string) { + m.errors.Add(ctx, 1, metric.WithAttributes(append(eventAttrs(ev), attribute.String("reason", reason))...)) +} + +func eventAttrs(ev *ChainTransactionObserved) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("chain_selector", strconv.FormatUint(ev.GetChainSelector(), 10)), + attribute.String("tx_status", ev.GetTxStatus().String()), + attribute.String("receiver_status", ev.GetReceiverStatus().String()), + attribute.Bool("transmitter", ev.GetTransmitter()), + attribute.Bool("duplicate", ev.GetDuplicate()), + } +} diff --git a/chain_capabilities/evm/txevents/emitter_test.go b/chain_capabilities/evm/txevents/emitter_test.go new file mode 100644 index 000000000..ebda60e6b --- /dev/null +++ b/chain_capabilities/evm/txevents/emitter_test.go @@ -0,0 +1,114 @@ +package txevents + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func testEvent() *ChainTransactionObserved { + return &ChainTransactionObserved{ + WorkflowExecutionId: "exec-1", + ChainSelector: 16015286601757825753, + TxHash: "0xabc", + TxStatus: TxStatus_TX_STATUS_REVERTED, + ReceiverStatus: ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, + Transmitter: true, + Duplicate: true, + } +} + +func TestEntityType(t *testing.T) { + require.Equal(t, "chain_capabilities.txevents.v1.ChainTransactionObserved", EntityType()) +} + +func TestIdempotencyKeyAndSubject(t *testing.T) { + ev := testEvent() + require.Equal(t, "16015286601757825753:0xabc:TX_STATUS_REVERTED", IdempotencyKey(ev)) + require.Equal(t, "16015286601757825753:0xabc", Subject(ev)) + + // The key does not depend on who observed the transaction. + other := proto.Clone(ev).(*ChainTransactionObserved) + other.Transmitter = false + other.Duplicate = false + other.ObservedAtMs = 42 + require.Equal(t, IdempotencyKey(ev), IdempotencyKey(other)) +} + +type sinkFunc func(ctx context.Context, body []byte, attrKVs ...any) error + +func (f sinkFunc) Emit(ctx context.Context, body []byte, attrKVs ...any) error { + return f(ctx, body, attrKVs...) +} + +func TestDurableEmitter_Emit(t *testing.T) { + newEmitter := func(t *testing.T, emit sinkFunc) *DurableEmitter { + t.Helper() + d, err := NewDurableEmitter(logger.Test(t), "", emit) + require.NoError(t, err) + require.Equal(t, DefaultDomain, d.domain) + return d + } + + t.Run("passes source, type, keys and the marshalled body", func(t *testing.T) { + var gotBody []byte + var gotAttrs []any + d := newEmitter(t, func(_ context.Context, body []byte, attrKVs ...any) error { + gotBody, gotAttrs = body, attrKVs + return nil + }) + ev := testEvent() + require.NoError(t, d.Emit(t.Context(), ev)) + + var decoded ChainTransactionObserved + require.NoError(t, proto.Unmarshal(gotBody, &decoded)) + require.True(t, proto.Equal(ev, &decoded)) + require.Equal(t, []any{ + "source", DefaultDomain, + "type", EntityType(), + "idempotencykey", IdempotencyKey(ev), + "subject", Subject(ev), + }, gotAttrs) + }) + + t.Run("bounds the emit context", func(t *testing.T) { + d := newEmitter(t, func(ctx context.Context, _ []byte, _ ...any) error { + _, ok := ctx.Deadline() + require.True(t, ok, "emit context must carry a deadline") + return nil + }) + require.NoError(t, d.Emit(t.Context(), testEvent())) + }) + + t.Run("reports ErrEmitterUnavailable when the LOOP has no durable emitter", func(t *testing.T) { + d, err := NewDurableEmitter(logger.Test(t), "custom-domain", nil) + require.NoError(t, err) + require.Equal(t, "custom-domain", d.domain) + require.ErrorIs(t, d.Emit(t.Context(), testEvent()), ErrEmitterUnavailable) + require.ErrorIs(t, d.Emit(t.Context(), testEvent()), ErrEmitterUnavailable) + require.True(t, d.unavailableLogged.Load()) + }) + + t.Run("wraps other emit errors", func(t *testing.T) { + cause := errors.New("db down") + d := newEmitter(t, func(context.Context, []byte, ...any) error { return cause }) + err := d.Emit(t.Context(), testEvent()) + require.ErrorIs(t, err, cause) + require.NotErrorIs(t, err, ErrEmitterUnavailable) + }) + + t.Run("rejects a nil event", func(t *testing.T) { + d := newEmitter(t, func(context.Context, []byte, ...any) error { return nil }) + require.Error(t, d.Emit(t.Context(), nil)) + }) +} + +func TestNopEmitter(t *testing.T) { + require.NoError(t, NopEmitter{}.Emit(t.Context(), testEvent())) + require.NoError(t, NopEmitter{}.Emit(t.Context(), nil)) +} diff --git a/chain_capabilities/evm/txevents/generate.go b/chain_capabilities/evm/txevents/generate.go new file mode 100644 index 000000000..927c957eb --- /dev/null +++ b/chain_capabilities/evm/txevents/generate.go @@ -0,0 +1,3 @@ +package txevents + +//go:generate protoc --proto_path=../../../ --go_out=paths=source_relative:../../../ chain_capabilities/evm/txevents/chain_transaction_observed.proto From db12119f8e2843bbb91545de3c0ca4e0247cf13d Mon Sep 17 00:00:00 2001 From: ilija42 Date: Tue, 20 Oct 2026 16:58:27 +0200 Subject: [PATCH 2/4] Move the otel metric module to the direct requirements of the EVM capability as go mod tidy expects --- chain_capabilities/evm/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chain_capabilities/evm/go.mod b/chain_capabilities/evm/go.mod index 5bbbb4bfe..8475c7f5a 100644 --- a/chain_capabilities/evm/go.mod +++ b/chain_capabilities/evm/go.mod @@ -16,6 +16,7 @@ require ( github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 go.uber.org/zap v1.28.0 google.golang.org/protobuf v1.36.12 @@ -182,7 +183,6 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/log v0.22.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 // indirect go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect From cd5c2faa77125157da660782a85ba3af759c0670 Mon Sep 17 00:00:00 2001 From: ilija42 Date: Tue, 20 Oct 2026 18:12:03 +0200 Subject: [PATCH 3/4] EVM chain capability emits one ChainTransactionObserved per transaction, with receipt detail when the receipt resolves and basic detail otherwise --- chain_capabilities/evm/actions/tx_events.go | 276 +++++++---- .../evm/actions/tx_events_test.go | 441 ++++++++++++++---- .../evm/actions/write_report.go | 74 ++- .../txevents/chain_transaction_observed.pb.go | 156 +++++-- .../txevents/chain_transaction_observed.proto | 38 +- chain_capabilities/evm/txevents/emitter.go | 12 +- .../evm/txevents/emitter_test.go | 12 +- 7 files changed, 757 insertions(+), 252 deletions(-) diff --git a/chain_capabilities/evm/actions/tx_events.go b/chain_capabilities/evm/actions/tx_events.go index 3a1328b08..a2555162a 100644 --- a/chain_capabilities/evm/actions/tx_events.go +++ b/chain_capabilities/evm/actions/tx_events.go @@ -27,29 +27,96 @@ type TxEventsOptions struct { CapabilityID string } -// txObservation is one transaction a WriteReport request saw on chain. +func (o TxEventsOptions) enabled() bool { + if o.Emitter == nil { + return false + } + _, nop := o.Emitter.(txevents.NopEmitter) + return !nop +} + +// txObservation is what a WriteReport request knows about one transaction. type txObservation struct { - hash evmtypes.Hash - txStatus txevents.TxStatus - receiverStatus txevents.ReceiverStatus - fee *big.Int - blockNumber uint64 - errorMessage string - // transmitter is true when this node sent the transaction. - transmitter bool + hash evmtypes.Hash + txStatus txevents.TxStatus + receiverStatus txevents.ReceiverStatus + gasUsed uint64 + effectiveGasPrice *big.Int + fee *big.Int + blockNumber uint64 + errorMessage string // duplicate is true when this node's transaction reverted because another node's // transmission of the same report had already landed. duplicate bool } -// txObservations accumulates the observations of one request, one entry per transaction hash, -// in the order the hashes were first seen. -type txObservations struct { - list []*txObservation +// merge fills the fields unset in o from in and keeps the duplicate flag once set. +func (o *txObservation) merge(in txObservation) { + if o.txStatus == txevents.TxStatus_TX_STATUS_UNSPECIFIED { + o.txStatus = in.txStatus + } + if o.receiverStatus == txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED { + o.receiverStatus = in.receiverStatus + } + if o.gasUsed == 0 { + o.gasUsed = in.gasUsed + } + if o.effectiveGasPrice == nil { + o.effectiveGasPrice = in.effectiveGasPrice + } + if o.fee == nil { + o.fee = in.fee + } + if o.blockNumber == 0 { + o.blockNumber = in.blockNumber + } + if o.errorMessage == "" { + o.errorMessage = in.errorMessage + } + o.duplicate = o.duplicate || in.duplicate +} + +// txEventReporter emits exactly one ChainTransactionObserved per transaction a WriteReport +// request saw on chain. Transactions are recorded the moment their hash is known (record, +// ownTransaction). The event carries receipt detail when the receipt is resolved (resolved); +// otherwise it carries the basic information recorded so far and is emitted when the node's own +// transaction turns out to be a duplicate (duplicateOf) or when the request ends (flush). +// Emission never changes the outcome of the write: failures are counted by the emitter and +// logged here. A nil reporter is a no-op. +type txEventReporter struct { + emitter txevents.Emitter + transmitterOnly bool + capabilityID string + chainSelector uint64 + metadata capabilities.RequestMetadata + lggr logger.SugaredLogger + now func() time.Time + + // ownTx is the transaction this node sent in a new transmission attempt, nil otherwise. + ownTx *evmtypes.TransactionResult + // known lists the transactions recorded so far, in first-seen order. + known []*txObservation + emitted map[evmtypes.Hash]struct{} +} + +func (e *EVM) newTxEventReporter(lggr logger.SugaredLogger, md capabilities.RequestMetadata) *txEventReporter { + if !e.txEvents.enabled() { + return nil + } + return &txEventReporter{ + emitter: e.txEvents.Emitter, + transmitterOnly: e.txEvents.TransmitterOnly, + capabilityID: e.txEvents.CapabilityID, + chainSelector: e.chainSelector, + metadata: md, + lggr: lggr, + now: time.Now, + emitted: map[evmtypes.Hash]struct{}{}, + } } -func (o *txObservations) find(hash evmtypes.Hash) *txObservation { - for _, obs := range o.list { +func (r *txEventReporter) find(hash evmtypes.Hash) *txObservation { + for _, obs := range r.known { if obs.hash == hash { return obs } @@ -57,50 +124,106 @@ func (o *txObservations) find(hash evmtypes.Hash) *txObservation { return nil } -// record merges obs into the entry for its hash: unset fields are filled in and flags are OR'd, -// so a hash-only observation taken before the receipt lookup is enriched by a later one. -func (o *txObservations) record(obs txObservation) { - cur := o.find(obs.hash) - if cur == nil { - c := obs - o.list = append(o.list, &c) +// record merges obs into the entry for its hash, creating the entry on first sight. Nothing is +// emitted: the transaction is reported once its receipt is resolved or when the request ends. +func (r *txEventReporter) record(obs txObservation) { + if r == nil || obs.hash == (evmtypes.Hash{}) { return } - if cur.txStatus == txevents.TxStatus_TX_STATUS_UNSPECIFIED { - cur.txStatus = obs.txStatus + if cur := r.find(obs.hash); cur != nil { + cur.merge(obs) + return } - if cur.receiverStatus == txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED { - cur.receiverStatus = obs.receiverStatus + c := obs + r.known = append(r.known, &c) +} + +// ownTransaction records the transaction this node just sent with the status the transaction +// manager reported. The manager only returns once the transaction is mined, so this is the +// earliest the hash exists. Fatal results carry no usable hash. +func (r *txEventReporter) ownTransaction(result *evmtypes.TransactionResult) { + if r == nil || result == nil || result.TxStatus == evmtypes.TxFatal || result.TxHash == (evmtypes.Hash{}) { + return } - if cur.fee == nil { - cur.fee = obs.fee + r.ownTx = result + r.record(txObservation{hash: result.TxHash, txStatus: txStatusFromTxmgr(result.TxStatus)}) +} + +// resolved emits the transaction with receipt detail, completed with whatever was recorded +// about it before. +func (r *txEventReporter) resolved(ctx context.Context, obs txObservation) { + if r == nil { + return } - if cur.blockNumber == 0 { - cur.blockNumber = obs.blockNumber + if cur := r.find(obs.hash); cur != nil { + obs.merge(*cur) } - if cur.errorMessage == "" { - cur.errorMessage = obs.errorMessage + r.emit(ctx, txevents.TxDetail_TX_DETAIL_RECEIPT, obs) +} + +// duplicateOf emits this node's own transaction as a duplicate when the transmission that landed +// is a different transaction: ours reverted because the report had already been delivered, yet it +// cost gas and must be visible to the user. Its receipt is not fetched, so the detail is basic. +func (r *txEventReporter) duplicateOf(ctx context.Context, landed evmtypes.Hash) { + if r == nil || r.ownTx == nil || r.ownTx.TxHash == landed { + return } - cur.transmitter = cur.transmitter || obs.transmitter - cur.duplicate = cur.duplicate || obs.duplicate + r.record(txObservation{hash: r.ownTx.TxHash, duplicate: true}) + r.emit(ctx, txevents.TxDetail_TX_DETAIL_BASIC, *r.find(r.ownTx.TxHash)) } -// recordOwnTx records the transaction this node sent in a new transmission attempt. When the -// reply was built from a different transaction, ours reverted as a duplicate of a transmission -// that had already landed; it still cost gas and is recorded as such. When no reply transaction -// is known (the request failed after sending), the transaction is recorded with the status the -// transaction manager reported and no duplicate flag. -func (o *txObservations) recordOwnTx(result *evmtypes.TransactionResult) { - if result == nil || result.TxStatus == evmtypes.TxFatal || result.TxHash == (evmtypes.Hash{}) { +// flush emits, with basic detail, every recorded transaction not emitted yet. It runs when the +// request ends, so a transaction whose receipt lookup failed, or that was sent by a request that +// failed afterwards, is still reported. +func (r *txEventReporter) flush(ctx context.Context) { + if r == nil { return } - obs := txObservation{ - hash: result.TxHash, - txStatus: txStatusFromTxmgr(result.TxStatus), - transmitter: true, - duplicate: len(o.list) > 0 && o.find(result.TxHash) == nil, + for _, obs := range r.known { + r.emit(ctx, txevents.TxDetail_TX_DETAIL_BASIC, *obs) } - o.record(obs) +} + +func (r *txEventReporter) isOwn(hash evmtypes.Hash) bool { + return r.ownTx != nil && r.ownTx.TxHash == hash +} + +func (r *txEventReporter) emit(ctx context.Context, detail txevents.TxDetail, obs txObservation) { + if r == nil || obs.hash == (evmtypes.Hash{}) { + return + } + transmitter := r.isOwn(obs.hash) + if r.transmitterOnly && !transmitter { + return + } + if _, done := r.emitted[obs.hash]; done { + return + } + r.emitted[obs.hash] = struct{}{} + + ev := buildTxEvent(detail, obs, transmitter, r.metadata, r.chainSelector, r.capabilityID, r.now()) + // The request context may already be done (the write failed or timed out); the event must + // still go out, bounded by the emitter's own timeout. + if err := r.emitter.Emit(context.WithoutCancel(ctx), ev); err != nil { + if errors.Is(err, txevents.ErrEmitterUnavailable) { + return // logged once per process by the emitter + } + r.lggr.Errorw("Failed to emit ChainTransactionObserved", append(txevents.LogKVs(ev), "error", err.Error())...) + } +} + +// observeLandedTx records a transaction found through the forwarder's ReportProcessed log, which +// is the earliest point a node that did not send it learns its hash. Such a log can only exist +// for a transaction that succeeded on chain, so the status is known before the receipt is +// fetched; the receiver outcome comes from the forwarder state. +func (e *WriteReport) observeLandedTx(retriever *TxHashRetriever, txHash evmtypes.Hash, receiverStatus evm.ReceiverContractExecutionStatus, errorMessage string) { + e.txReporter.record(txObservation{ + hash: txHash, + txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, + receiverStatus: receiverStatusFromReply(receiverStatus), + blockNumber: retriever.BlockNumber(txHash), + errorMessage: errorMessage, + }) } func txStatusFromTxmgr(s evmtypes.TransactionStatus) txevents.TxStatus { @@ -144,8 +267,15 @@ func formatTxHash(h evmtypes.Hash) string { return "0x" + hex.EncodeToString(h[:]) } -func buildTxEvent(obs *txObservation, md capabilities.RequestMetadata, chainSelector uint64, capabilityID string, observedAt time.Time) *txevents.ChainTransactionObserved { - ev := &txevents.ChainTransactionObserved{ +func bigIntString(v *big.Int) string { + if v == nil { + return "" + } + return v.String() +} + +func buildTxEvent(detail txevents.TxDetail, obs txObservation, transmitter bool, md capabilities.RequestMetadata, chainSelector uint64, capabilityID string, at time.Time) *txevents.ChainTransactionObserved { + return &txevents.ChainTransactionObserved{ WorkflowExecutionId: md.WorkflowExecutionID, WorkflowId: md.WorkflowID, WorkflowOwner: md.WorkflowOwner, @@ -157,45 +287,17 @@ func buildTxEvent(obs *txObservation, md capabilities.RequestMetadata, chainSele TxHash: formatTxHash(obs.hash), CapabilityId: capabilityID, - TxStatus: obs.txStatus, - ReceiverStatus: obs.receiverStatus, - BlockNumber: obs.blockNumber, - ErrorMessage: obs.errorMessage, + Detail: detail, + TxStatus: obs.txStatus, + ReceiverStatus: obs.receiverStatus, + TransactionFee: bigIntString(obs.fee), + GasUsed: obs.gasUsed, + EffectiveGasPrice: bigIntString(obs.effectiveGasPrice), + BlockNumber: obs.blockNumber, + ErrorMessage: obs.errorMessage, - ObservedAtMs: observedAt.UnixMilli(), - Transmitter: obs.transmitter, + ObservedAtMs: at.UnixMilli(), + Transmitter: transmitter, Duplicate: obs.duplicate, } - if obs.fee != nil { - ev.TransactionFee = obs.fee.String() - } - return ev -} - -// emitTxEvents publishes one ChainTransactionObserved per observed transaction. It runs after -// the WriteReport outcome is decided, regardless of whether the request succeeded, and never -// changes that outcome: failures are counted by the emitter and logged here. The request context -// may already be done when the request failed, so emission uses a detached context bounded by -// the emitter's own timeout. -func (e *EVM) emitTxEvents(ctx context.Context, lggr logger.SugaredLogger, md capabilities.RequestMetadata, observed []*txObservation) { - if e.txEvents.Emitter == nil || len(observed) == 0 { - return - } - if _, nop := e.txEvents.Emitter.(txevents.NopEmitter); nop { - return - } - emitCtx := context.WithoutCancel(ctx) - now := time.Now() - for _, obs := range observed { - if e.txEvents.TransmitterOnly && !obs.transmitter { - continue - } - ev := buildTxEvent(obs, md, e.chainSelector, e.txEvents.CapabilityID, now) - if err := e.txEvents.Emitter.Emit(emitCtx, ev); err != nil { - if errors.Is(err, txevents.ErrEmitterUnavailable) { - continue // logged once per process by the emitter - } - lggr.Errorw("Failed to emit ChainTransactionObserved", append(txevents.LogKVs(ev), "error", err.Error())...) - } - } } diff --git a/chain_capabilities/evm/actions/tx_events_test.go b/chain_capabilities/evm/actions/tx_events_test.go index 83de63cee..2d38fef99 100644 --- a/chain_capabilities/evm/actions/tx_events_test.go +++ b/chain_capabilities/evm/actions/tx_events_test.go @@ -36,6 +36,12 @@ func (r *recordingTxEventEmitter) Emit(_ context.Context, ev *txevents.ChainTran return r.err } +func (r *recordingTxEventEmitter) recorded() []*txevents.ChainTransactionObserved { + r.mu.Lock() + defer r.mu.Unlock() + return append([]*txevents.ChainTransactionObserved(nil), r.events...) +} + func recordingEmitter(t *testing.T, service *EVM) *recordingTxEventEmitter { t.Helper() rec, ok := service.txEvents.Emitter.(*recordingTxEventEmitter) @@ -45,96 +51,208 @@ func recordingEmitter(t *testing.T, service *EVM) *recordingTxEventEmitter { func recordedTxEvents(t *testing.T, service *EVM) []*txevents.ChainTransactionObserved { t.Helper() - rec := recordingEmitter(t, service) - rec.mu.Lock() - defer rec.mu.Unlock() - return append([]*txevents.ChainTransactionObserved(nil), rec.events...) + return recordingEmitter(t, service).recorded() +} + +type txEventEmitterFunc func(ctx context.Context, ev *txevents.ChainTransactionObserved) error + +func (f txEventEmitterFunc) Emit(ctx context.Context, ev *txevents.ChainTransactionObserved) error { + return f(ctx, ev) +} + +func testRequestMetadata() capabilities.RequestMetadata { + return capabilities.RequestMetadata{ + WorkflowID: "wf-id", + WorkflowOwner: "0xowner", + OrgID: "org-1", + WorkflowExecutionID: "exec-1", + WorkflowDonID: 7, + ReferenceID: "write-step", + } +} + +func newTestReporter(t *testing.T, emitter txevents.Emitter, transmitterOnly bool) *txEventReporter { + t.Helper() + svc := &EVM{chainSelector: 1, txEvents: TxEventsOptions{Emitter: emitter, TransmitterOnly: transmitterOnly, CapabilityID: testCapabilityID}} + r := svc.newTxEventReporter(logger.Sugared(logger.Test(t)), testRequestMetadata()) + require.NotNil(t, r) + return r } -func TestTxObservations(t *testing.T) { +func TestTxEventReporter(t *testing.T) { t.Parallel() - replyHash := evmtypes.Hash(test.RandomBytes(32)) + ctx := t.Context() ownHash := evmtypes.Hash(test.RandomBytes(32)) + landedHash := evmtypes.Hash(test.RandomBytes(32)) + + t.Run("disabled emission yields a nil reporter whose methods are no-ops", func(t *testing.T) { + lggr := logger.Sugared(logger.Test(t)) + require.Nil(t, (&EVM{}).newTxEventReporter(lggr, testRequestMetadata())) + r := (&EVM{txEvents: TxEventsOptions{Emitter: txevents.NopEmitter{}}}).newTxEventReporter(lggr, testRequestMetadata()) + require.Nil(t, r) + r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxSuccess}) + r.record(txObservation{hash: ownHash}) + r.resolved(ctx, txObservation{hash: ownHash}) + r.duplicateOf(ctx, landedHash) + r.flush(ctx) + }) - t.Run("record merges by hash and only fills unset fields", func(t *testing.T) { - var o txObservations - o.record(txObservation{hash: replyHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver"}) - o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, blockNumber: 7, errorMessage: "ignored"}) - o.record(txObservation{hash: replyHash, fee: big.NewInt(2000), transmitter: true}) - require.Len(t, o.list, 1) - got := o.list[0] - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, got.txStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, got.receiverStatus) - require.Equal(t, uint64(7), got.blockNumber) - require.Equal(t, "invalid receiver", got.errorMessage) - require.Equal(t, big.NewInt(2000), got.fee) - require.True(t, got.transmitter) - require.False(t, got.duplicate) + t.Run("record emits nothing and flush emits one basic event per transaction, merged, in first-seen order", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, false) + r.record(txObservation{hash: landedHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, blockNumber: 100}) + r.record(txObservation{hash: landedHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver", blockNumber: 101}) + r.record(txObservation{hash: ownHash}) + require.Empty(t, rec.recorded()) + + r.flush(ctx) + r.flush(ctx) + events := rec.recorded() + require.Len(t, events, 2) + first := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, first.Detail) + require.Equal(t, formatTxHash(landedHash), first.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, first.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, first.ReceiverStatus) + require.Equal(t, "invalid receiver", first.ErrorMessage) + require.Equal(t, uint64(100), first.BlockNumber, "the first recorded value wins") + require.Empty(t, first.TransactionFee) + require.Zero(t, first.GasUsed) + require.False(t, first.Transmitter) + require.Equal(t, formatTxHash(ownHash), events[1].TxHash) + }) + + t.Run("resolved emits receipt detail once, completed from the record, and suppresses the basic event", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, false) + r.record(txObservation{hash: landedHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver"}) + r.resolved(ctx, txObservation{hash: landedHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, gasUsed: 1000, effectiveGasPrice: big.NewInt(2), fee: big.NewInt(2000), blockNumber: 1234}) + r.resolved(ctx, txObservation{hash: landedHash}) + r.flush(ctx) + + events := rec.recorded() + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) + require.Equal(t, "invalid receiver", ev.ErrorMessage) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, "2", ev.EffectiveGasPrice) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1234), ev.BlockNumber) + require.False(t, ev.Transmitter) + }) + + t.Run("ownTransaction records the transaction manager status and flags the transmitter", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, false) + r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted, TxIdempotencyKey: "key"}) + require.Empty(t, rec.recorded()) + r.flush(ctx) + + events := rec.recorded() + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) + require.Equal(t, formatTxHash(ownHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, ev.TxStatus) + require.True(t, ev.Transmitter) + require.False(t, ev.Duplicate) }) - t.Run("recordOwnTx marks the reply transaction as sent by this node", func(t *testing.T) { - var o txObservations - o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS}) - o.recordOwnTx(&evmtypes.TransactionResult{TxHash: replyHash, TxStatus: evmtypes.TxSuccess}) - require.Len(t, o.list, 1) - require.True(t, o.list[0].transmitter) - require.False(t, o.list[0].duplicate) + t.Run("ownTransaction ignores nil, fatal and zero-hash results", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, false) + r.ownTransaction(nil) + r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxFatal}) + r.ownTransaction(&evmtypes.TransactionResult{TxStatus: evmtypes.TxSuccess}) + r.duplicateOf(ctx, landedHash) + r.flush(ctx) + require.Empty(t, rec.recorded()) + require.Nil(t, r.ownTx) + }) + + t.Run("duplicateOf emits our transaction as a basic duplicate only when another one landed", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, false) + r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) + r.duplicateOf(ctx, ownHash) // ours is the transaction that landed + require.Empty(t, rec.recorded()) + + r.duplicateOf(ctx, landedHash) + r.resolved(ctx, txObservation{hash: ownHash, gasUsed: 21000}) + r.flush(ctx) + events := rec.recorded() + require.Len(t, events, 1) + dup := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, dup.Detail) + require.Equal(t, formatTxHash(ownHash), dup.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.TxStatus) + require.True(t, dup.Transmitter) + require.True(t, dup.Duplicate) + require.Empty(t, dup.TransactionFee) }) - t.Run("recordOwnTx adds our reverted duplicate when the reply came from another transaction", func(t *testing.T) { - var o txObservations - o.record(txObservation{hash: replyHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS}) - o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) - require.Len(t, o.list, 2) - require.False(t, o.list[0].transmitter) - dup := o.list[1] - require.Equal(t, ownHash, dup.hash) - require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.txStatus) - require.True(t, dup.transmitter) - require.True(t, dup.duplicate) + t.Run("transmitter-only mode emits only our own transaction", func(t *testing.T) { + rec := &recordingTxEventEmitter{} + r := newTestReporter(t, rec, true) + r.record(txObservation{hash: landedHash}) + r.resolved(ctx, txObservation{hash: landedHash}) + r.flush(ctx) + require.Empty(t, rec.recorded()) + + r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxSuccess}) + r.flush(ctx) + events := rec.recorded() + require.Len(t, events, 1) + require.Equal(t, formatTxHash(ownHash), events[0].TxHash) + require.True(t, events[0].Transmitter) }) - t.Run("recordOwnTx without a reply transaction is not flagged duplicate", func(t *testing.T) { - var o txObservations - o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) - require.Len(t, o.list, 1) - require.True(t, o.list[0].transmitter) - require.False(t, o.list[0].duplicate) - require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, o.list[0].txStatus) + t.Run("skips zero hashes and survives emitter errors", func(t *testing.T) { + rec := &recordingTxEventEmitter{err: errors.New("chip unreachable")} + r := newTestReporter(t, rec, false) + r.record(txObservation{}) + r.resolved(ctx, txObservation{}) + r.flush(ctx) + require.Empty(t, rec.recorded()) + r.resolved(ctx, txObservation{hash: landedHash}) + require.Len(t, rec.recorded(), 1) }) - t.Run("recordOwnTx ignores nil, fatal and zero-hash results", func(t *testing.T) { - var o txObservations - o.recordOwnTx(nil) - o.recordOwnTx(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxFatal}) - o.recordOwnTx(&evmtypes.TransactionResult{TxStatus: evmtypes.TxSuccess}) - require.Empty(t, o.list) + t.Run("emits even after the request context is done", func(t *testing.T) { + var gotCtx context.Context + r := newTestReporter(t, txEventEmitterFunc(func(ctx context.Context, _ *txevents.ChainTransactionObserved) error { + gotCtx = ctx + return nil + }), false) + cancelled, cancel := context.WithCancel(ctx) + cancel() + r.record(txObservation{hash: landedHash}) + r.flush(cancelled) + require.NotNil(t, gotCtx) + require.NoError(t, gotCtx.Err()) }) } func TestBuildTxEvent(t *testing.T) { t.Parallel() hash := evmtypes.Hash(test.RandomBytes(32)) - md := capabilities.RequestMetadata{ - WorkflowID: "wf-id", - WorkflowOwner: "0xowner", - OrgID: "org-1", - WorkflowExecutionID: "exec-1", - WorkflowDonID: 7, - ReferenceID: "write-step", - } + md := testRequestMetadata() now := time.UnixMilli(1_700_000_000_000) - ev := buildTxEvent(&txObservation{ - hash: hash, - txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, - receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, - fee: big.NewInt(2000), - blockNumber: 99, - errorMessage: "receiver contract execution failure", - transmitter: true, - duplicate: false, - }, md, 1, testCapabilityID, now) + ev := buildTxEvent(txevents.TxDetail_TX_DETAIL_RECEIPT, txObservation{ + hash: hash, + txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, + receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, + gasUsed: 1000, + effectiveGasPrice: big.NewInt(2), + fee: big.NewInt(2000), + blockNumber: 99, + errorMessage: "receiver contract execution failure", + duplicate: false, + }, true, md, 1, testCapabilityID, now) require.Equal(t, "exec-1", ev.WorkflowExecutionId) require.Equal(t, "wf-id", ev.WorkflowId) @@ -147,8 +265,11 @@ func TestBuildTxEvent(t *testing.T) { require.Len(t, ev.TxHash, 66) require.Equal(t, "0x", ev.TxHash[:2]) require.Equal(t, testCapabilityID, ev.CapabilityId) + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, "2", ev.EffectiveGasPrice) require.Equal(t, "2000", ev.TransactionFee) require.Equal(t, uint64(99), ev.BlockNumber) require.Equal(t, "receiver contract execution failure", ev.ErrorMessage) @@ -156,15 +277,19 @@ func TestBuildTxEvent(t *testing.T) { require.True(t, ev.Transmitter) require.False(t, ev.Duplicate) - noFee := buildTxEvent(&txObservation{hash: hash}, md, 1, testCapabilityID, now) - require.Empty(t, noFee.TransactionFee) - require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, noFee.TxStatus) + bare := buildTxEvent(txevents.TxDetail_TX_DETAIL_BASIC, txObservation{hash: hash}, false, md, 1, testCapabilityID, now) + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, bare.Detail) + require.Empty(t, bare.TransactionFee) + require.Empty(t, bare.EffectiveGasPrice) + require.Zero(t, bare.GasUsed) + require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, bare.TxStatus) + require.False(t, bare.Transmitter) } func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { t.Parallel() - t.Run("prior successful transmission emits one success event not sent by this node", func(t *testing.T) { + t.Run("prior successful transmission emits one receipt-detailed event", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). @@ -187,10 +312,13 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { events := recordedTxEvents(t, service) require.Len(t, events, 1) ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) require.Equal(t, formatTxHash(txHash), ev.TxHash) require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, ev.ReceiverStatus) require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, "2", ev.EffectiveGasPrice) require.Equal(t, uint64(1234), ev.BlockNumber) require.Empty(t, ev.ErrorMessage) require.False(t, ev.Transmitter) @@ -205,7 +333,7 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { require.NotZero(t, ev.ObservedAtMs) }) - t.Run("prior invalid receiver emits a receiver-reverted event with the revert reason", func(t *testing.T) { + t.Run("prior invalid receiver carries the revert reason, gas and fee", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) fixture := newWriteReportTestFixture(t) @@ -228,16 +356,19 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { events := recordedTxEvents(t, service) require.Len(t, events, 1) ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) require.Equal(t, formatTxHash(txHash), ev.TxHash) require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) require.Equal(t, fixture.transmissionID.InvalidReceiverMessage(), ev.ErrorMessage) require.Equal(t, uint64(55), ev.BlockNumber) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1000), ev.GasUsed) require.False(t, ev.Transmitter) mockForwarderClient.AssertNotCalled(t, "InvokeOnReport", mock.Anything, mock.Anything, mock.Anything, mock.Anything) }) - t.Run("new transmission sent by this node is flagged transmitter", func(t *testing.T) { + t.Run("own transmission emits one receipt-detailed event flagged transmitter", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) fixture := newWriteReportTestFixture(t) @@ -245,10 +376,12 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) + emittedBeforeReceipt := -1 mockForwarderClient. On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess, TxIdempotencyKey: "key"}, nil).Once() mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Run(func(mock.Arguments) { emittedBeforeReceipt = len(recordedTxEvents(t, service)) }). Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(77)} @@ -261,13 +394,58 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) + require.Zero(t, emittedBeforeReceipt, "nothing is emitted until the receipt is resolved") events := recordedTxEvents(t, service) require.Len(t, events, 1) - require.Equal(t, formatTxHash(txHash), events[0].TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, events[0].TxStatus) - require.True(t, events[0].Transmitter) - require.False(t, events[0].Duplicate) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.True(t, ev.Transmitter) + require.False(t, ev.Duplicate) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, uint64(77), ev.BlockNumber) + }) + + t.Run("own transmission whose receiver reverted carries the reason and the gas spent", func(t *testing.T) { + ctx := t.Context() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + fixture := newWriteReportTestFixture(t) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + mockForwarderClient. + On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). + Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess}, nil).Once() + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{State: contracts.TransmissionStateFailed, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(66)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.NoError(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) + require.Equal(t, UnknownIssueExecutingReceiverContractMessage, ev.ErrorMessage) + require.Equal(t, "2000", ev.TransactionFee) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, uint64(66), ev.BlockNumber) + require.True(t, ev.Transmitter) + require.False(t, ev.Duplicate) }) runDuplicate := func(t *testing.T, transmitterOnly bool) ([]*txevents.ChainTransactionObserved, evmtypes.Hash, evmtypes.Hash) { @@ -300,30 +478,35 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { return recordedTxEvents(t, service), landedHash, ownHash } - t.Run("duplicate transmission emits the landed transaction and our reverted duplicate", func(t *testing.T) { + t.Run("duplicate transmission emits our reverted duplicate with basic detail and the landed one with its receipt", func(t *testing.T) { events, landedHash, ownHash := runDuplicate(t, false) require.Len(t, events, 2) - landed := events[0] - require.Equal(t, formatTxHash(landedHash), landed.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, landed.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, landed.ReceiverStatus) - require.Equal(t, "2000", landed.TransactionFee) - require.False(t, landed.Transmitter) - require.False(t, landed.Duplicate) - - dup := events[1] + dup := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, dup.Detail) require.Equal(t, formatTxHash(ownHash), dup.TxHash) require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.TxStatus) require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, dup.ReceiverStatus) require.Empty(t, dup.TransactionFee) + require.Zero(t, dup.GasUsed) require.Zero(t, dup.BlockNumber) require.True(t, dup.Transmitter) require.True(t, dup.Duplicate) - require.NotEqual(t, txevents.IdempotencyKey(landed), txevents.IdempotencyKey(dup)) + + landed := events[1] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, landed.Detail) + require.Equal(t, formatTxHash(landedHash), landed.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, landed.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, landed.ReceiverStatus) + require.Equal(t, "2000", landed.TransactionFee) + require.Equal(t, uint64(1000), landed.GasUsed) + require.Equal(t, uint64(88), landed.BlockNumber) + require.False(t, landed.Transmitter) + require.False(t, landed.Duplicate) + require.NotEqual(t, txevents.IdempotencyKey(dup), txevents.IdempotencyKey(landed)) }) - t.Run("transmitter-only mode emits only transactions this node sent", func(t *testing.T) { + t.Run("transmitter-only mode emits only the transaction this node sent", func(t *testing.T) { events, _, ownHash := runDuplicate(t, true) require.Len(t, events, 1) require.Equal(t, formatTxHash(ownHash), events[0].TxHash) @@ -331,7 +514,7 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { require.True(t, events[0].Duplicate) }) - t.Run("receipt lookup failure still emits the transaction hash", func(t *testing.T) { + t.Run("receipt lookup failure emits the basic event known from the log", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) defer cancel() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) @@ -350,10 +533,76 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { events := recordedTxEvents(t, service) require.Len(t, events, 1) - require.Equal(t, formatTxHash(txHash), events[0].TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, events[0].TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, events[0].ReceiverStatus) - require.Empty(t, events[0].TransactionFee) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, ev.ReceiverStatus) + require.Equal(t, uint64(100), ev.BlockNumber, "block number comes from the ReportProcessed log") + require.Empty(t, ev.TransactionFee) + require.Zero(t, ev.GasUsed) + require.Equal(t, fixture.metadata.WorkflowExecutionID, ev.WorkflowExecutionId) + require.Equal(t, fixture.metadata.WorkflowID, ev.WorkflowId) + }) + + t.Run("request failing after the transaction was sent emits the basic event for it", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + _, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + fixture := newWriteReportTestFixture(t) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() + + txHash := evmtypes.Hash(test.RandomBytes(32)) + mockForwarderClient. + On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). + Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess}, nil).Once() + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{}, errors.New("rpc down")) + + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.Error(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) + require.True(t, ev.Transmitter) + require.False(t, ev.Duplicate) + require.Empty(t, ev.TransactionFee) + }) + + t.Run("fee calculation failure still emits the receipt detail with the gas spent", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil) + + txHash := evmtypes.Hash(test.RandomBytes(32)) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(1234)} + evmServiceMock.EXPECT(). + GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). + Return(&receipt, nil) + evmServiceMock.EXPECT(). + CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). + Return(nil, errors.New("fee oracle down")) + + fixture := newWriteReportTestFixture(t) + _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + require.Error(t, err) + + events := recordedTxEvents(t, service) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) + require.Equal(t, uint64(1000), ev.GasUsed) + require.Equal(t, "2", ev.EffectiveGasPrice) + require.Empty(t, ev.TransactionFee) + require.Equal(t, uint64(1234), ev.BlockNumber) }) t.Run("emitter failure does not fail the write", func(t *testing.T) { diff --git a/chain_capabilities/evm/actions/write_report.go b/chain_capabilities/evm/actions/write_report.go index 330b985f7..137aaadcc 100644 --- a/chain_capabilities/evm/actions/write_report.go +++ b/chain_capabilities/evm/actions/write_report.go @@ -56,11 +56,9 @@ type WriteReport struct { gasMeter *gasmeter.Meter - // observed collects every transaction this request saw on chain; ownTx is the transaction - // this node sent, if it made a new transmission attempt. Both feed ChainTransactionObserved - // emission once the outcome is decided. - observed txObservations - ownTx *evmtypes.TransactionResult + // txReporter emits ChainTransactionObserved the moment each transaction hash, and later its + // receipt, becomes known. Nil when transaction events are disabled. + txReporter *txEventReporter } func (e *EVM) WriteReport(ctx context.Context, metadata capabilities.RequestMetadata, input *evm.WriteReportRequest) (*capabilities.ResponseAndMetadata[*evm.WriteReportReply], caperrors.Error) { @@ -91,13 +89,14 @@ func (e *EVM) WriteReport(ctx context.Context, metadata capabilities.RequestMeta } func (e *EVM) executeWriteReport(ctx context.Context, request *evm.WriteReportRequest, metadata capabilities.RequestMetadata, telemetryContext monitoring.TelemetryContext) (*evm.WriteReportReply, capabilities.ResponseMetadata, error) { + lggr := e.messageBuilder.RequestLggr(e.lggr, telemetryContext) wr := &WriteReport{ EVMService: e.EVMService, forwarderClient: e.forwarderClient, ReceiverGasMinimum: e.ReceiverGasMinimum, chainSelector: e.chainSelector, - lggr: e.messageBuilder.RequestLggr(e.lggr, telemetryContext), + lggr: lggr, beholderProcessor: e.beholderProcessor, messageBuilder: e.messageBuilder, @@ -107,11 +106,13 @@ func (e *EVM) executeWriteReport(ctx context.Context, request *evm.WriteReportRe transmissionScheduler: e.transmissionScheduler, executionTimestamp: metadata.ExecutionTimestamp, gasMeter: e.gasMeter, + txReporter: e.newTxEventReporter(lggr, metadata), } reply, responseMetadata, err := wr.executeWriteReport(ctx, request, metadata, telemetryContext) - wr.observed.recordOwnTx(wr.ownTx) - e.emitTxEvents(ctx, wr.lggr, metadata, wr.observed.list) + // Transactions whose receipt was not resolved (the lookup failed, or the request failed after + // the hash was known) are reported with basic detail now that the outcome is decided. + wr.txReporter.flush(ctx) return reply, responseMetadata, err } @@ -156,6 +157,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write } e.lggr.Infow("Returning without a transmission attempt - prior transmission succeeded", "txHash", common.Bytes2Hex(txHash[:])) + e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS, "") reply, err := e.buildSuccessReply(ctx, *txHash) if err != nil { return nil, capabilities.ResponseMetadata{}, err @@ -173,6 +175,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write } e.lggr.Infow("Returning without a transmission attempt - prior transmission marked receiver invalid", "txHash", common.Bytes2Hex(txHash[:])) + e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(transmissionInfo, transmissionID)) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, transmissionInfo, transmissionID) if err != nil { // Surface the known revert reason so the user learns their receiver is invalid even @@ -198,6 +201,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write "receiverGasBudget", calculatedReceiverGasBudget, "transmissionReceiverGasBudget", transmissionInfo.GasLimit, ) + e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(transmissionInfo, transmissionID)) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, transmissionInfo, transmissionID) if err != nil { // The receiver reverted despite sufficient gas (user contract fault); surface the @@ -225,7 +229,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write e.lggr.Errorw("Transaction failed", "error", err.Error()) return nil, capabilities.ResponseMetadata{}, err } - e.ownTx = transactionResult + e.txReporter.ownTransaction(transactionResult) newTransmissionInfo, err := capcommon.WithPollingRetry(ctx, e.lggr, func(ctx context.Context) (contracts.TransmissionInfo, error) { readTransmissionInfo, readTransmissionErr := e.forwarderClient.GetTransmissionInfo(ctx, transmissionID) @@ -250,6 +254,8 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write if err != nil { return nil, capabilities.ResponseMetadata{}, err } + e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS, "") + e.txReporter.duplicateOf(ctx, *txHash) switch transactionResult.TxStatus { case evmtypes.TxReverted: // Report for this transaction has already been submitted and we sent a duplicate tx onchain which is fine, but wastes ethereum gas @@ -281,6 +287,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write txHash = originalTxHash } e.lggr.Errorw("Made a new transmission attempt - transmission failed", "txHash", common.Bytes2Hex(txHash[:]), "transmissionState", newTransmissionInfo.State.String()) + e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(newTransmissionInfo, transmissionID)) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, newTransmissionInfo, transmissionID) if err != nil { @@ -485,6 +492,10 @@ func revertReplyBuildError(transmissionInfo contracts.TransmissionInfo, transmis } func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash, receiverStatus evm.ReceiverContractExecutionStatus, errorMessage *string) (*evm.WriteReportReply, error) { + // Callers record the hash as soon as they learn it; this is a safety net so the transaction is + // still reported with basic detail if the lookups below fail. + e.txReporter.record(txObservation{hash: txHash, receiverStatus: receiverStatusFromReply(receiverStatus), errorMessage: derefString(errorMessage)}) + txReceipt, err := capcommon.WithQuickRetry(ctx, e.lggr, func(ctx context.Context) (*evmtypes.Receipt, error) { return e.GetTransactionReceipt(ctx, evmtypes.GeTransactionReceiptRequest{ Hash: txHash, @@ -492,25 +503,20 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash }) }) if err != nil { - // The transaction is on chain even though its receipt is not reachable; record what is - // known so the Transactions Viewer still learns about it. - e.observed.record(txObservation{ - hash: txHash, - receiverStatus: receiverStatusFromReply(receiverStatus), - errorMessage: derefString(errorMessage), - }) // txHash is the final hash carried by this reply (set as reply.TxHash below), so it is safe // to surface here: it lets users locate the tx on-chain when the RPC is flaky, and lets us // filter logs per node by hash. Callers add the user-facing reason (e.g. invalid receiver). return nil, fmt.Errorf("failed to get transaction receipt for tx hash 0x%x: %w", txHash[:], err) } - e.observed.record(txObservation{ - hash: txHash, - txStatus: txStatusFromReceipt(txReceipt.Status), - receiverStatus: receiverStatusFromReply(receiverStatus), - blockNumber: blockNumberFromReceipt(txReceipt.BlockNumber), - errorMessage: derefString(errorMessage), - }) + resolvedTx := txObservation{ + hash: txHash, + txStatus: txStatusFromReceipt(txReceipt.Status), + receiverStatus: receiverStatusFromReply(receiverStatus), + gasUsed: txReceipt.GasUsed, + effectiveGasPrice: txReceipt.EffectiveGasPrice, + blockNumber: blockNumberFromReceipt(txReceipt.BlockNumber), + errorMessage: derefString(errorMessage), + } receiptGasInfo := evmtypes.ReceiptGasInfo{ GasUsed: txReceipt.GasUsed, EffectiveGasPrice: txReceipt.EffectiveGasPrice, @@ -525,6 +531,8 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash return e.CalculateTransactionFee(ctx, receiptGasInfo) }) if err != nil { + // The gas spent is known from the receipt even though it could not be priced. + e.txReporter.resolved(ctx, resolvedTx) return nil, fmt.Errorf("failed to calculate transaction fee for tx hash 0x%x: %w", txHash[:], err) } message := errorMessage @@ -536,7 +544,9 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash if txReceipt.Status == 0 { txStatus = evm.TxStatus_TX_STATUS_REVERTED } - e.observed.record(txObservation{hash: txHash, fee: transactionFee.TransactionFee, errorMessage: derefString(message)}) + resolvedTx.fee = transactionFee.TransactionFee + resolvedTx.errorMessage = derefString(message) + e.txReporter.resolved(ctx, resolvedTx) e.lggr.Infow("Successfully fetched tx receipt", "txHash", hex.EncodeToString(txHash[:]), @@ -615,6 +625,21 @@ type TxHashRetriever struct { transmissionID contracts.TransmissionID keystoneForwarderClient contracts.CREForwarderClient lggr logger.Logger + + // logs caches the ReportProcessed logs of the last fetch so a hash can be reported with its + // block number as soon as it is known, before its receipt is fetched. + logs logDetailsList +} + +// BlockNumber returns the block holding the ReportProcessed log for txHash in the last fetched +// logs, or 0 when unknown. +func (thr *TxHashRetriever) BlockNumber(txHash evmtypes.Hash) uint64 { + for _, d := range thr.logs { + if d.TxHash == txHash { + return blockNumberFromReceipt(d.BlockNumber) + } + } + return 0 } func NewTxHashRetriever(forwarderClient contracts.CREForwarderClient, lggr logger.Logger, transmissionID contracts.TransmissionID) TxHashRetriever { @@ -716,6 +741,7 @@ func (thr *TxHashRetriever) fetchAndParseLogs(ctx context.Context) (logDetailsLi if err != nil { return nil, fmt.Errorf("malformed log data for transmission %s: %w", thr.transmissionID.String(), err) } + thr.logs = details return details, nil } diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go index 9432035c8..9bf2255c5 100644 --- a/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go @@ -26,11 +26,68 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// TxStatus is the on-chain outcome of the transaction itself, taken from its receipt. +// TxDetail says how much the emitting node learnt about the transaction. A node emits exactly +// one event per transaction it saw land while serving a request: RECEIPT once it resolved the +// receipt, BASIC otherwise (the receipt lookup failed, the request failed after the hash was +// known, or the transaction is the node's own duplicate, whose receipt is not fetched). +type TxDetail int32 + +const ( + TxDetail_TX_DETAIL_UNSPECIFIED TxDetail = 0 + // Hash, attribution and the statuses known from the transaction manager or the forwarder. + // Receipt-derived fields (gas, fee) are not set. + TxDetail_TX_DETAIL_BASIC TxDetail = 1 + // Receipt-derived status, gas used, effective gas price, fee and block number are set. + TxDetail_TX_DETAIL_RECEIPT TxDetail = 2 +) + +// Enum value maps for TxDetail. +var ( + TxDetail_name = map[int32]string{ + 0: "TX_DETAIL_UNSPECIFIED", + 1: "TX_DETAIL_BASIC", + 2: "TX_DETAIL_RECEIPT", + } + TxDetail_value = map[string]int32{ + "TX_DETAIL_UNSPECIFIED": 0, + "TX_DETAIL_BASIC": 1, + "TX_DETAIL_RECEIPT": 2, + } +) + +func (x TxDetail) Enum() *TxDetail { + p := new(TxDetail) + *p = x + return p +} + +func (x TxDetail) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TxDetail) Descriptor() protoreflect.EnumDescriptor { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0].Descriptor() +} + +func (TxDetail) Type() protoreflect.EnumType { + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0] +} + +func (x TxDetail) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TxDetail.Descriptor instead. +func (TxDetail) EnumDescriptor() ([]byte, []int) { + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} +} + +// TxStatus is the on-chain outcome of the transaction itself, as reported by the transaction +// manager or the forwarder log at BASIC detail and by the receipt at RECEIPT detail. type TxStatus int32 const ( - // The receipt was not available when the event was emitted. Consumers fetch it by hash. + // Not known when the event was emitted. Consumers fetch the receipt by hash. TxStatus_TX_STATUS_UNSPECIFIED TxStatus = 0 TxStatus_TX_STATUS_SUCCESS TxStatus = 1 TxStatus_TX_STATUS_REVERTED TxStatus = 2 @@ -61,11 +118,11 @@ func (x TxStatus) String() string { } func (TxStatus) Descriptor() protoreflect.EnumDescriptor { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0].Descriptor() + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1].Descriptor() } func (TxStatus) Type() protoreflect.EnumType { - return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0] + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1] } func (x TxStatus) Number() protoreflect.EnumNumber { @@ -74,7 +131,7 @@ func (x TxStatus) Number() protoreflect.EnumNumber { // Deprecated: Use TxStatus.Descriptor instead. func (TxStatus) EnumDescriptor() ([]byte, []int) { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{1} } // ReceiverStatus is the outcome of the receiver (user) contract call made by the forwarder. A @@ -112,11 +169,11 @@ func (x ReceiverStatus) String() string { } func (ReceiverStatus) Descriptor() protoreflect.EnumDescriptor { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1].Descriptor() + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[2].Descriptor() } func (ReceiverStatus) Type() protoreflect.EnumType { - return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1] + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[2] } func (x ReceiverStatus) Number() protoreflect.EnumNumber { @@ -125,14 +182,14 @@ func (x ReceiverStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ReceiverStatus.Descriptor instead. func (ReceiverStatus) EnumDescriptor() ([]byte, []int) { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{1} + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{2} } // ChainTransactionObserved records one transaction a chain capability saw land on chain while -// serving a WriteReport. One event is emitted per mined transaction, including reverted and -// duplicate ones. The Blockchain Finality Service dedups events by (chain_selector, tx_hash), -// fetches the receipt and tracks finality, so the body is kept minimal: no report payload, -// signatures or request data are included. +// serving a WriteReport, including reverted and duplicate ones. Each node emits it once per +// transaction, see TxDetail. The Blockchain Finality Service dedups events by +// (chain_selector, tx_hash), fetches the receipt and tracks finality, so the body is kept +// minimal: no report payload, signatures or request data are included. type ChainTransactionObserved struct { state protoimpl.MessageState `protogen:"open.v1"` // Attribution, copied from the capability request metadata. @@ -152,22 +209,29 @@ type ChainTransactionObserved struct { // Outcome as observed by the emitting node. TxStatus TxStatus `protobuf:"varint,10,opt,name=tx_status,json=txStatus,proto3,enum=chain_capabilities.txevents.v1.TxStatus" json:"tx_status,omitempty"` ReceiverStatus ReceiverStatus `protobuf:"varint,11,opt,name=receiver_status,json=receiverStatus,proto3,enum=chain_capabilities.txevents.v1.ReceiverStatus" json:"receiver_status,omitempty"` - // Decimal string in the chain's base unit (wei). Empty when unknown. + // Total cost in the chain's base unit (wei) as a decimal string, including the L1 data fee on + // rollups when the capability accounts for it. Empty when unknown. TransactionFee string `protobuf:"bytes,12,opt,name=transaction_fee,json=transactionFee,proto3" json:"transaction_fee,omitempty"` // 0 when unknown. BlockNumber uint64 `protobuf:"varint,13,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` // User-facing revert reason, when the receiver reverted. ErrorMessage string `protobuf:"bytes,14,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` // Emission context. - // Unix milliseconds at which the node observed the outcome. + // Unix milliseconds at which the node emitted the event. ObservedAtMs int64 `protobuf:"varint,15,opt,name=observed_at_ms,json=observedAtMs,proto3" json:"observed_at_ms,omitempty"` // True when the emitting node sent this transaction. Transmitter bool `protobuf:"varint,16,opt,name=transmitter,proto3" json:"transmitter,omitempty"` // True when this transaction reverted because another node's transmission of the same report // had already landed. Such transactions still cost gas and must be visible to the user. - Duplicate bool `protobuf:"varint,17,opt,name=duplicate,proto3" json:"duplicate,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Duplicate bool `protobuf:"varint,17,opt,name=duplicate,proto3" json:"duplicate,omitempty"` + // How much the node learnt about the transaction; see TxDetail. + Detail TxDetail `protobuf:"varint,18,opt,name=detail,proto3,enum=chain_capabilities.txevents.v1.TxDetail" json:"detail,omitempty"` + // Gas spent, from the receipt. 0 when unknown. + GasUsed uint64 `protobuf:"varint,19,opt,name=gas_used,json=gasUsed,proto3" json:"gas_used,omitempty"` + // Price paid per gas unit in wei as a decimal string, from the receipt. Empty when unknown. + EffectiveGasPrice string `protobuf:"bytes,20,opt,name=effective_gas_price,json=effectiveGasPrice,proto3" json:"effective_gas_price,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChainTransactionObserved) Reset() { @@ -319,11 +383,32 @@ func (x *ChainTransactionObserved) GetDuplicate() bool { return false } +func (x *ChainTransactionObserved) GetDetail() TxDetail { + if x != nil { + return x.Detail + } + return TxDetail_TX_DETAIL_UNSPECIFIED +} + +func (x *ChainTransactionObserved) GetGasUsed() uint64 { + if x != nil { + return x.GasUsed + } + return 0 +} + +func (x *ChainTransactionObserved) GetEffectiveGasPrice() string { + if x != nil { + return x.EffectiveGasPrice + } + return "" +} + var File_chain_capabilities_evm_txevents_chain_transaction_observed_proto protoreflect.FileDescriptor const file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc = "" + "\n" + - "@chain_capabilities/evm/txevents/chain_transaction_observed.proto\x12\x1echain_capabilities.txevents.v1\"\xd4\x05\n" + + "@chain_capabilities/evm/txevents/chain_transaction_observed.proto\x12\x1echain_capabilities.txevents.v1\"\xe1\x06\n" + "\x18ChainTransactionObserved\x122\n" + "\x15workflow_execution_id\x18\x01 \x01(\tR\x13workflowExecutionId\x12\x1f\n" + "\vworkflow_id\x18\x02 \x01(\tR\n" + @@ -343,7 +428,14 @@ const file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawD "\rerror_message\x18\x0e \x01(\tR\ferrorMessage\x12$\n" + "\x0eobserved_at_ms\x18\x0f \x01(\x03R\fobservedAtMs\x12 \n" + "\vtransmitter\x18\x10 \x01(\bR\vtransmitter\x12\x1c\n" + - "\tduplicate\x18\x11 \x01(\bR\tduplicate*T\n" + + "\tduplicate\x18\x11 \x01(\bR\tduplicate\x12@\n" + + "\x06detail\x18\x12 \x01(\x0e2(.chain_capabilities.txevents.v1.TxDetailR\x06detail\x12\x19\n" + + "\bgas_used\x18\x13 \x01(\x04R\agasUsed\x12.\n" + + "\x13effective_gas_price\x18\x14 \x01(\tR\x11effectiveGasPrice*Q\n" + + "\bTxDetail\x12\x19\n" + + "\x15TX_DETAIL_UNSPECIFIED\x10\x00\x12\x13\n" + + "\x0fTX_DETAIL_BASIC\x10\x01\x12\x15\n" + + "\x11TX_DETAIL_RECEIPT\x10\x02*T\n" + "\bTxStatus\x12\x19\n" + "\x15TX_STATUS_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11TX_STATUS_SUCCESS\x10\x01\x12\x16\n" + @@ -365,21 +457,23 @@ func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDe return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescData } -var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_goTypes = []any{ - (TxStatus)(0), // 0: chain_capabilities.txevents.v1.TxStatus - (ReceiverStatus)(0), // 1: chain_capabilities.txevents.v1.ReceiverStatus - (*ChainTransactionObserved)(nil), // 2: chain_capabilities.txevents.v1.ChainTransactionObserved + (TxDetail)(0), // 0: chain_capabilities.txevents.v1.TxDetail + (TxStatus)(0), // 1: chain_capabilities.txevents.v1.TxStatus + (ReceiverStatus)(0), // 2: chain_capabilities.txevents.v1.ReceiverStatus + (*ChainTransactionObserved)(nil), // 3: chain_capabilities.txevents.v1.ChainTransactionObserved } var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_depIdxs = []int32{ - 0, // 0: chain_capabilities.txevents.v1.ChainTransactionObserved.tx_status:type_name -> chain_capabilities.txevents.v1.TxStatus - 1, // 1: chain_capabilities.txevents.v1.ChainTransactionObserved.receiver_status:type_name -> chain_capabilities.txevents.v1.ReceiverStatus - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 1, // 0: chain_capabilities.txevents.v1.ChainTransactionObserved.tx_status:type_name -> chain_capabilities.txevents.v1.TxStatus + 2, // 1: chain_capabilities.txevents.v1.ChainTransactionObserved.receiver_status:type_name -> chain_capabilities.txevents.v1.ReceiverStatus + 0, // 2: chain_capabilities.txevents.v1.ChainTransactionObserved.detail:type_name -> chain_capabilities.txevents.v1.TxDetail + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init() } @@ -392,7 +486,7 @@ func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init( File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc), len(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 1, NumExtensions: 0, NumServices: 0, diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.proto b/chain_capabilities/evm/txevents/chain_transaction_observed.proto index 2f733eb38..03bc7f843 100644 --- a/chain_capabilities/evm/txevents/chain_transaction_observed.proto +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.proto @@ -8,9 +8,23 @@ package chain_capabilities.txevents.v1; option go_package = "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txevents"; -// TxStatus is the on-chain outcome of the transaction itself, taken from its receipt. +// TxDetail says how much the emitting node learnt about the transaction. A node emits exactly +// one event per transaction it saw land while serving a request: RECEIPT once it resolved the +// receipt, BASIC otherwise (the receipt lookup failed, the request failed after the hash was +// known, or the transaction is the node's own duplicate, whose receipt is not fetched). +enum TxDetail { + TX_DETAIL_UNSPECIFIED = 0; + // Hash, attribution and the statuses known from the transaction manager or the forwarder. + // Receipt-derived fields (gas, fee) are not set. + TX_DETAIL_BASIC = 1; + // Receipt-derived status, gas used, effective gas price, fee and block number are set. + TX_DETAIL_RECEIPT = 2; +} + +// TxStatus is the on-chain outcome of the transaction itself, as reported by the transaction +// manager or the forwarder log at BASIC detail and by the receipt at RECEIPT detail. enum TxStatus { - // The receipt was not available when the event was emitted. Consumers fetch it by hash. + // Not known when the event was emitted. Consumers fetch the receipt by hash. TX_STATUS_UNSPECIFIED = 0; TX_STATUS_SUCCESS = 1; TX_STATUS_REVERTED = 2; @@ -25,10 +39,10 @@ enum ReceiverStatus { } // ChainTransactionObserved records one transaction a chain capability saw land on chain while -// serving a WriteReport. One event is emitted per mined transaction, including reverted and -// duplicate ones. The Blockchain Finality Service dedups events by (chain_selector, tx_hash), -// fetches the receipt and tracks finality, so the body is kept minimal: no report payload, -// signatures or request data are included. +// serving a WriteReport, including reverted and duplicate ones. Each node emits it once per +// transaction, see TxDetail. The Blockchain Finality Service dedups events by +// (chain_selector, tx_hash), fetches the receipt and tracks finality, so the body is kept +// minimal: no report payload, signatures or request data are included. message ChainTransactionObserved { // Attribution, copied from the capability request metadata. string workflow_execution_id = 1; @@ -49,7 +63,8 @@ message ChainTransactionObserved { // Outcome as observed by the emitting node. TxStatus tx_status = 10; ReceiverStatus receiver_status = 11; - // Decimal string in the chain's base unit (wei). Empty when unknown. + // Total cost in the chain's base unit (wei) as a decimal string, including the L1 data fee on + // rollups when the capability accounts for it. Empty when unknown. string transaction_fee = 12; // 0 when unknown. uint64 block_number = 13; @@ -57,11 +72,18 @@ message ChainTransactionObserved { string error_message = 14; // Emission context. - // Unix milliseconds at which the node observed the outcome. + // Unix milliseconds at which the node emitted the event. int64 observed_at_ms = 15; // True when the emitting node sent this transaction. bool transmitter = 16; // True when this transaction reverted because another node's transmission of the same report // had already landed. Such transactions still cost gas and must be visible to the user. bool duplicate = 17; + + // How much the node learnt about the transaction; see TxDetail. + TxDetail detail = 18; + // Gas spent, from the receipt. 0 when unknown. + uint64 gas_used = 19; + // Price paid per gas unit in wei as a decimal string, from the receipt. Empty when unknown. + string effective_gas_price = 20; } diff --git a/chain_capabilities/evm/txevents/emitter.go b/chain_capabilities/evm/txevents/emitter.go index 025e5a12d..d6c4a715d 100644 --- a/chain_capabilities/evm/txevents/emitter.go +++ b/chain_capabilities/evm/txevents/emitter.go @@ -62,11 +62,13 @@ type NopEmitter struct{} // Emit implements Emitter. func (NopEmitter) Emit(context.Context, *ChainTransactionObserved) error { return nil } -// IdempotencyKey identifies an observed outcome independently of the node that observed it, so -// that every node emitting the same transaction with the same status produces the same key and -// ChIP or the Blockchain Finality Service can dedup on it. +// IdempotencyKey identifies a transaction at a detail level independently of the node that +// observed it, so that every node that learnt the same about a transaction produces the same key +// and ChIP or the Blockchain Finality Service can dedup on it. A node whose receipt lookup failed +// produces a different key than its peers, so its basic event does not shadow their receipt +// detail; the finality service dedups by (chain selector, hash) regardless. func IdempotencyKey(ev *ChainTransactionObserved) string { - return fmt.Sprintf("%d:%s:%s", ev.GetChainSelector(), ev.GetTxHash(), ev.GetTxStatus().String()) + return fmt.Sprintf("%d:%s:%s", ev.GetChainSelector(), ev.GetTxHash(), ev.GetDetail().String()) } // Subject identifies the transaction itself and is set as the CloudEvent subject. @@ -79,6 +81,7 @@ func LogKVs(ev *ChainTransactionObserved) []any { return []any{ "txHash", ev.GetTxHash(), "chainSelector", ev.GetChainSelector(), + "detail", ev.GetDetail().String(), "txStatus", ev.GetTxStatus().String(), "receiverStatus", ev.GetReceiverStatus().String(), "transmitter", ev.GetTransmitter(), @@ -184,6 +187,7 @@ func (m *emitterMetrics) recordError(ctx context.Context, ev *ChainTransactionOb func eventAttrs(ev *ChainTransactionObserved) []attribute.KeyValue { return []attribute.KeyValue{ attribute.String("chain_selector", strconv.FormatUint(ev.GetChainSelector(), 10)), + attribute.String("detail", ev.GetDetail().String()), attribute.String("tx_status", ev.GetTxStatus().String()), attribute.String("receiver_status", ev.GetReceiverStatus().String()), attribute.Bool("transmitter", ev.GetTransmitter()), diff --git a/chain_capabilities/evm/txevents/emitter_test.go b/chain_capabilities/evm/txevents/emitter_test.go index ebda60e6b..87aa8c498 100644 --- a/chain_capabilities/evm/txevents/emitter_test.go +++ b/chain_capabilities/evm/txevents/emitter_test.go @@ -16,6 +16,7 @@ func testEvent() *ChainTransactionObserved { WorkflowExecutionId: "exec-1", ChainSelector: 16015286601757825753, TxHash: "0xabc", + Detail: TxDetail_TX_DETAIL_RECEIPT, TxStatus: TxStatus_TX_STATUS_REVERTED, ReceiverStatus: ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, Transmitter: true, @@ -29,15 +30,22 @@ func TestEntityType(t *testing.T) { func TestIdempotencyKeyAndSubject(t *testing.T) { ev := testEvent() - require.Equal(t, "16015286601757825753:0xabc:TX_STATUS_REVERTED", IdempotencyKey(ev)) + require.Equal(t, "16015286601757825753:0xabc:TX_DETAIL_RECEIPT", IdempotencyKey(ev)) require.Equal(t, "16015286601757825753:0xabc", Subject(ev)) - // The key does not depend on who observed the transaction. + // The key does not depend on who observed the transaction or on the values it reported. other := proto.Clone(ev).(*ChainTransactionObserved) other.Transmitter = false other.Duplicate = false other.ObservedAtMs = 42 + other.TxStatus = TxStatus_TX_STATUS_SUCCESS + other.TransactionFee = "2000" require.Equal(t, IdempotencyKey(ev), IdempotencyKey(other)) + + // A basic event from a node that could not resolve the receipt is a distinct delivery. + other.Detail = TxDetail_TX_DETAIL_BASIC + require.NotEqual(t, IdempotencyKey(ev), IdempotencyKey(other)) + require.Equal(t, Subject(ev), Subject(other)) } type sinkFunc func(ctx context.Context, body []byte, attrKVs ...any) error From d96fba669dd13decacbbb1b712d9d1e4afc1da92 Mon Sep 17 00:00:00 2001 From: ilija42 Date: Thu, 22 Oct 2026 18:23:58 +0200 Subject: [PATCH 4/4] EVM chain capability emits a minimal ChainTransactionObserved the moment a WriteReport transaction is known to be mined --- chain_capabilities/evm/actions/actions.go | 12 +- chain_capabilities/evm/actions/tx_events.go | 273 ++-------- .../evm/actions/tx_events_test.go | 491 ++++-------------- .../evm/actions/write_report.go | 66 +-- .../evm/actions/write_report_test.go | 4 +- chain_capabilities/evm/config/config.go | 3 - chain_capabilities/evm/main.go | 36 +- .../txevents/chain_transaction_observed.pb.go | 345 ++---------- .../txevents/chain_transaction_observed.proto | 90 +--- chain_capabilities/evm/txevents/emitter.go | 95 ++-- .../evm/txevents/emitter_test.go | 52 +- 11 files changed, 313 insertions(+), 1154 deletions(-) diff --git a/chain_capabilities/evm/actions/actions.go b/chain_capabilities/evm/actions/actions.go index c09a682ac..2ab576fc6 100644 --- a/chain_capabilities/evm/actions/actions.go +++ b/chain_capabilities/evm/actions/actions.go @@ -65,15 +65,17 @@ type EVM struct { transmissionScheduler ts.TransmissionScheduler - txEvents TxEventsOptions + // txEvents publishes ChainTransactionObserved events for the Transactions Viewer. + txEvents txevents.Emitter } // Option configures optional behaviour of the EVM capability. type Option func(*EVM) -// WithTxEvents enables emission of ChainTransactionObserved events for the Transactions Viewer. -func WithTxEvents(opts TxEventsOptions) Option { - return func(e *EVM) { e.txEvents = opts } +// WithTxEvents sets the emitter for ChainTransactionObserved events. A nil or NopEmitter +// disables emission. +func WithTxEvents(emitter txevents.Emitter) Option { + return func(e *EVM) { e.txEvents = emitter } } func NewEVM(cfg config.Config, evmService types.EVMService, lggr logger.Logger, beholderProcessor beholder.ProtoProcessor, @@ -99,7 +101,7 @@ func NewEVM(cfg config.Config, evmService types.EVMService, lggr logger.Logger, consensusHandler: handler, chainSelector: chainSelector, transmissionScheduler: transmissionScheduler, - txEvents: TxEventsOptions{Emitter: txevents.NopEmitter{}}, + txEvents: txevents.NopEmitter{}, } for _, opt := range opts { opt(e) diff --git a/chain_capabilities/evm/actions/tx_events.go b/chain_capabilities/evm/actions/tx_events.go index a2555162a..f469f7838 100644 --- a/chain_capabilities/evm/actions/tx_events.go +++ b/chain_capabilities/evm/actions/tx_events.go @@ -4,204 +4,82 @@ import ( "context" "encoding/hex" "errors" - "math/big" "time" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" "github.com/smartcontractkit/chainlink-common/pkg/logger" evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" ) -// TxEventsOptions wires ChainTransactionObserved emission into the EVM capability. The events -// feed the CRE Transactions Viewer through ChIP and the Blockchain Finality Service. -type TxEventsOptions struct { - // Emitter publishes the events. Nil or NopEmitter disables emission. - Emitter txevents.Emitter - // TransmitterOnly emits only the transactions this node sent. Otherwise every node that - // observed a transaction emits it and the consumer dedups by (chain selector, tx hash). - TransmitterOnly bool - // CapabilityID is stamped on every event. - CapabilityID string -} - -func (o TxEventsOptions) enabled() bool { - if o.Emitter == nil { +// txEventsEnabled reports whether emitter actually publishes events. +func txEventsEnabled(emitter txevents.Emitter) bool { + if emitter == nil { return false } - _, nop := o.Emitter.(txevents.NopEmitter) + _, nop := emitter.(txevents.NopEmitter) return !nop } -// txObservation is what a WriteReport request knows about one transaction. -type txObservation struct { - hash evmtypes.Hash - txStatus txevents.TxStatus - receiverStatus txevents.ReceiverStatus - gasUsed uint64 - effectiveGasPrice *big.Int - fee *big.Int - blockNumber uint64 - errorMessage string - // duplicate is true when this node's transaction reverted because another node's - // transmission of the same report had already landed. - duplicate bool -} - -// merge fills the fields unset in o from in and keeps the duplicate flag once set. -func (o *txObservation) merge(in txObservation) { - if o.txStatus == txevents.TxStatus_TX_STATUS_UNSPECIFIED { - o.txStatus = in.txStatus - } - if o.receiverStatus == txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED { - o.receiverStatus = in.receiverStatus - } - if o.gasUsed == 0 { - o.gasUsed = in.gasUsed - } - if o.effectiveGasPrice == nil { - o.effectiveGasPrice = in.effectiveGasPrice - } - if o.fee == nil { - o.fee = in.fee - } - if o.blockNumber == 0 { - o.blockNumber = in.blockNumber - } - if o.errorMessage == "" { - o.errorMessage = in.errorMessage - } - o.duplicate = o.duplicate || in.duplicate -} - -// txEventReporter emits exactly one ChainTransactionObserved per transaction a WriteReport -// request saw on chain. Transactions are recorded the moment their hash is known (record, -// ownTransaction). The event carries receipt detail when the receipt is resolved (resolved); -// otherwise it carries the basic information recorded so far and is emitted when the node's own -// transaction turns out to be a duplicate (duplicateOf) or when the request ends (flush). -// Emission never changes the outcome of the write: failures are counted by the emitter and -// logged here. A nil reporter is a no-op. +// txEventReporter emits one ChainTransactionObserved for every transaction a WriteReport request +// learns was mined, at the moment it learns it: the node's own transaction when the transaction +// manager returns (it only returns once the transaction is mined), and transactions sent by +// other nodes when the forwarder's ReportProcessed log reveals their hash. Each hash is emitted +// once per request; every node emits what it saw and consumers dedup by txevents.Key. Emission +// never changes the outcome of the write: failures are counted by the emitter and logged here. +// A nil reporter is a no-op. type txEventReporter struct { - emitter txevents.Emitter - transmitterOnly bool - capabilityID string - chainSelector uint64 - metadata capabilities.RequestMetadata - lggr logger.SugaredLogger - now func() time.Time + emitter txevents.Emitter + chainSelector uint64 + metadata capabilities.RequestMetadata + lggr logger.SugaredLogger + now func() time.Time - // ownTx is the transaction this node sent in a new transmission attempt, nil otherwise. - ownTx *evmtypes.TransactionResult - // known lists the transactions recorded so far, in first-seen order. - known []*txObservation emitted map[evmtypes.Hash]struct{} } func (e *EVM) newTxEventReporter(lggr logger.SugaredLogger, md capabilities.RequestMetadata) *txEventReporter { - if !e.txEvents.enabled() { + if !txEventsEnabled(e.txEvents) { return nil } return &txEventReporter{ - emitter: e.txEvents.Emitter, - transmitterOnly: e.txEvents.TransmitterOnly, - capabilityID: e.txEvents.CapabilityID, - chainSelector: e.chainSelector, - metadata: md, - lggr: lggr, - now: time.Now, - emitted: map[evmtypes.Hash]struct{}{}, - } -} - -func (r *txEventReporter) find(hash evmtypes.Hash) *txObservation { - for _, obs := range r.known { - if obs.hash == hash { - return obs - } + emitter: e.txEvents, + chainSelector: e.chainSelector, + metadata: md, + lggr: lggr, + now: time.Now, + emitted: map[evmtypes.Hash]struct{}{}, } - return nil } -// record merges obs into the entry for its hash, creating the entry on first sight. Nothing is -// emitted: the transaction is reported once its receipt is resolved or when the request ends. -func (r *txEventReporter) record(obs txObservation) { - if r == nil || obs.hash == (evmtypes.Hash{}) { - return - } - if cur := r.find(obs.hash); cur != nil { - cur.merge(obs) +// sent reports the transaction this node just sent, with the status the transaction manager +// returned. Fatal results carry no usable hash and are skipped. +func (r *txEventReporter) sent(ctx context.Context, result *evmtypes.TransactionResult) { + if r == nil || result == nil || result.TxStatus == evmtypes.TxFatal { return } - c := obs - r.known = append(r.known, &c) + r.observed(ctx, result.TxHash, txStatusFromTxmgr(result.TxStatus)) } -// ownTransaction records the transaction this node just sent with the status the transaction -// manager reported. The manager only returns once the transaction is mined, so this is the -// earliest the hash exists. Fatal results carry no usable hash. -func (r *txEventReporter) ownTransaction(result *evmtypes.TransactionResult) { - if r == nil || result == nil || result.TxStatus == evmtypes.TxFatal || result.TxHash == (evmtypes.Hash{}) { - return - } - r.ownTx = result - r.record(txObservation{hash: result.TxHash, txStatus: txStatusFromTxmgr(result.TxStatus)}) +// landed reports a transaction found through the forwarder's ReportProcessed log. Such a log +// only exists for a transaction that succeeded on chain, so its status is known without the +// receipt. +func (r *txEventReporter) landed(ctx context.Context, hash evmtypes.Hash) { + r.observed(ctx, hash, txevents.TxStatus_TX_STATUS_SUCCESS) } -// resolved emits the transaction with receipt detail, completed with whatever was recorded -// about it before. -func (r *txEventReporter) resolved(ctx context.Context, obs txObservation) { - if r == nil { +// observed emits the event for hash unless this request already did. +func (r *txEventReporter) observed(ctx context.Context, hash evmtypes.Hash, status txevents.TxStatus) { + if r == nil || hash == (evmtypes.Hash{}) { return } - if cur := r.find(obs.hash); cur != nil { - obs.merge(*cur) - } - r.emit(ctx, txevents.TxDetail_TX_DETAIL_RECEIPT, obs) -} - -// duplicateOf emits this node's own transaction as a duplicate when the transmission that landed -// is a different transaction: ours reverted because the report had already been delivered, yet it -// cost gas and must be visible to the user. Its receipt is not fetched, so the detail is basic. -func (r *txEventReporter) duplicateOf(ctx context.Context, landed evmtypes.Hash) { - if r == nil || r.ownTx == nil || r.ownTx.TxHash == landed { + if _, done := r.emitted[hash]; done { return } - r.record(txObservation{hash: r.ownTx.TxHash, duplicate: true}) - r.emit(ctx, txevents.TxDetail_TX_DETAIL_BASIC, *r.find(r.ownTx.TxHash)) -} - -// flush emits, with basic detail, every recorded transaction not emitted yet. It runs when the -// request ends, so a transaction whose receipt lookup failed, or that was sent by a request that -// failed afterwards, is still reported. -func (r *txEventReporter) flush(ctx context.Context) { - if r == nil { - return - } - for _, obs := range r.known { - r.emit(ctx, txevents.TxDetail_TX_DETAIL_BASIC, *obs) - } -} + r.emitted[hash] = struct{}{} -func (r *txEventReporter) isOwn(hash evmtypes.Hash) bool { - return r.ownTx != nil && r.ownTx.TxHash == hash -} - -func (r *txEventReporter) emit(ctx context.Context, detail txevents.TxDetail, obs txObservation) { - if r == nil || obs.hash == (evmtypes.Hash{}) { - return - } - transmitter := r.isOwn(obs.hash) - if r.transmitterOnly && !transmitter { - return - } - if _, done := r.emitted[obs.hash]; done { - return - } - r.emitted[obs.hash] = struct{}{} - - ev := buildTxEvent(detail, obs, transmitter, r.metadata, r.chainSelector, r.capabilityID, r.now()) + ev := newTxEvent(hash, status, r.metadata, r.chainSelector, r.now()) // The request context may already be done (the write failed or timed out); the event must // still go out, bounded by the emitter's own timeout. if err := r.emitter.Emit(context.WithoutCancel(ctx), ev); err != nil { @@ -212,20 +90,6 @@ func (r *txEventReporter) emit(ctx context.Context, detail txevents.TxDetail, ob } } -// observeLandedTx records a transaction found through the forwarder's ReportProcessed log, which -// is the earliest point a node that did not send it learns its hash. Such a log can only exist -// for a transaction that succeeded on chain, so the status is known before the receipt is -// fetched; the receiver outcome comes from the forwarder state. -func (e *WriteReport) observeLandedTx(retriever *TxHashRetriever, txHash evmtypes.Hash, receiverStatus evm.ReceiverContractExecutionStatus, errorMessage string) { - e.txReporter.record(txObservation{ - hash: txHash, - txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, - receiverStatus: receiverStatusFromReply(receiverStatus), - blockNumber: retriever.BlockNumber(txHash), - errorMessage: errorMessage, - }) -} - func txStatusFromTxmgr(s evmtypes.TransactionStatus) txevents.TxStatus { switch s { case evmtypes.TxSuccess: @@ -237,67 +101,18 @@ func txStatusFromTxmgr(s evmtypes.TransactionStatus) txevents.TxStatus { } } -func txStatusFromReceipt(receiptStatus uint64) txevents.TxStatus { - if receiptStatus == 0 { - return txevents.TxStatus_TX_STATUS_REVERTED - } - return txevents.TxStatus_TX_STATUS_SUCCESS -} - -func receiverStatusFromReply(s evm.ReceiverContractExecutionStatus) txevents.ReceiverStatus { - switch s { - case evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS: - return txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS - case evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED: - return txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED - default: - return txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED - } -} - -func blockNumberFromReceipt(bn *big.Int) uint64 { - if bn == nil || !bn.IsUint64() { - return 0 - } - return bn.Uint64() -} - // formatTxHash renders an EVM hash the way explorers and the Transactions Viewer expect. func formatTxHash(h evmtypes.Hash) string { return "0x" + hex.EncodeToString(h[:]) } -func bigIntString(v *big.Int) string { - if v == nil { - return "" - } - return v.String() -} - -func buildTxEvent(detail txevents.TxDetail, obs txObservation, transmitter bool, md capabilities.RequestMetadata, chainSelector uint64, capabilityID string, at time.Time) *txevents.ChainTransactionObserved { +func newTxEvent(hash evmtypes.Hash, status txevents.TxStatus, md capabilities.RequestMetadata, chainSelector uint64, at time.Time) *txevents.ChainTransactionObserved { return &txevents.ChainTransactionObserved{ WorkflowExecutionId: md.WorkflowExecutionID, WorkflowId: md.WorkflowID, - WorkflowOwner: md.WorkflowOwner, - ReferenceId: md.ReferenceID, - WorkflowDonId: md.WorkflowDonID, - OrgId: md.OrgID, - - ChainSelector: chainSelector, - TxHash: formatTxHash(obs.hash), - CapabilityId: capabilityID, - - Detail: detail, - TxStatus: obs.txStatus, - ReceiverStatus: obs.receiverStatus, - TransactionFee: bigIntString(obs.fee), - GasUsed: obs.gasUsed, - EffectiveGasPrice: bigIntString(obs.effectiveGasPrice), - BlockNumber: obs.blockNumber, - ErrorMessage: obs.errorMessage, - - ObservedAtMs: at.UnixMilli(), - Transmitter: transmitter, - Duplicate: obs.duplicate, + ChainSelector: chainSelector, + TxHash: formatTxHash(hash), + TxStatus: status, + ObservedAtMs: at.UnixMilli(), } } diff --git a/chain_capabilities/evm/actions/tx_events_test.go b/chain_capabilities/evm/actions/tx_events_test.go index 2d38fef99..e9f8ad9f9 100644 --- a/chain_capabilities/evm/actions/tx_events_test.go +++ b/chain_capabilities/evm/actions/tx_events_test.go @@ -10,18 +10,18 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/logger" evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" + commonmocks "github.com/smartcontractkit/chainlink-common/pkg/types/mocks" "github.com/smartcontractkit/capabilities/chain_capabilities/common/test" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/internal/contracts" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents" ) -const testCapabilityID = "evm:ChainSelector:1@1.0.0" - // recordingTxEventEmitter captures emitted events so tests can assert on them. type recordingTxEventEmitter struct { mu sync.Mutex @@ -44,7 +44,7 @@ func (r *recordingTxEventEmitter) recorded() []*txevents.ChainTransactionObserve func recordingEmitter(t *testing.T, service *EVM) *recordingTxEventEmitter { t.Helper() - rec, ok := service.txEvents.Emitter.(*recordingTxEventEmitter) + rec, ok := service.txEvents.(*recordingTxEventEmitter) require.True(t, ok, "service must use the recording emitter") return rec } @@ -64,16 +64,13 @@ func testRequestMetadata() capabilities.RequestMetadata { return capabilities.RequestMetadata{ WorkflowID: "wf-id", WorkflowOwner: "0xowner", - OrgID: "org-1", WorkflowExecutionID: "exec-1", - WorkflowDonID: 7, - ReferenceID: "write-step", } } -func newTestReporter(t *testing.T, emitter txevents.Emitter, transmitterOnly bool) *txEventReporter { +func newTestReporter(t *testing.T, emitter txevents.Emitter) *txEventReporter { t.Helper() - svc := &EVM{chainSelector: 1, txEvents: TxEventsOptions{Emitter: emitter, TransmitterOnly: transmitterOnly, CapabilityID: testCapabilityID}} + svc := &EVM{chainSelector: 1, txEvents: emitter} r := svc.newTxEventReporter(logger.Sugared(logger.Test(t)), testRequestMetadata()) require.NotNil(t, r) return r @@ -82,143 +79,64 @@ func newTestReporter(t *testing.T, emitter txevents.Emitter, transmitterOnly boo func TestTxEventReporter(t *testing.T) { t.Parallel() ctx := t.Context() - ownHash := evmtypes.Hash(test.RandomBytes(32)) - landedHash := evmtypes.Hash(test.RandomBytes(32)) + hash := evmtypes.Hash(test.RandomBytes(32)) + other := evmtypes.Hash(test.RandomBytes(32)) t.Run("disabled emission yields a nil reporter whose methods are no-ops", func(t *testing.T) { lggr := logger.Sugared(logger.Test(t)) require.Nil(t, (&EVM{}).newTxEventReporter(lggr, testRequestMetadata())) - r := (&EVM{txEvents: TxEventsOptions{Emitter: txevents.NopEmitter{}}}).newTxEventReporter(lggr, testRequestMetadata()) + r := (&EVM{txEvents: txevents.NopEmitter{}}).newTxEventReporter(lggr, testRequestMetadata()) require.Nil(t, r) - r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxSuccess}) - r.record(txObservation{hash: ownHash}) - r.resolved(ctx, txObservation{hash: ownHash}) - r.duplicateOf(ctx, landedHash) - r.flush(ctx) + r.sent(ctx, &evmtypes.TransactionResult{TxHash: hash, TxStatus: evmtypes.TxSuccess}) + r.landed(ctx, hash) + r.observed(ctx, hash, txevents.TxStatus_TX_STATUS_SUCCESS) }) - t.Run("record emits nothing and flush emits one basic event per transaction, merged, in first-seen order", func(t *testing.T) { + t.Run("emits one event per transaction, the first time it is observed", func(t *testing.T) { rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, false) - r.record(txObservation{hash: landedHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, blockNumber: 100}) - r.record(txObservation{hash: landedHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver", blockNumber: 101}) - r.record(txObservation{hash: ownHash}) - require.Empty(t, rec.recorded()) + r := newTestReporter(t, rec) + r.now = func() time.Time { return time.UnixMilli(1_700_000_000_000) } - r.flush(ctx) - r.flush(ctx) - events := rec.recorded() - require.Len(t, events, 2) - first := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, first.Detail) - require.Equal(t, formatTxHash(landedHash), first.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, first.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, first.ReceiverStatus) - require.Equal(t, "invalid receiver", first.ErrorMessage) - require.Equal(t, uint64(100), first.BlockNumber, "the first recorded value wins") - require.Empty(t, first.TransactionFee) - require.Zero(t, first.GasUsed) - require.False(t, first.Transmitter) - require.Equal(t, formatTxHash(ownHash), events[1].TxHash) - }) - - t.Run("resolved emits receipt detail once, completed from the record, and suppresses the basic event", func(t *testing.T) { - rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, false) - r.record(txObservation{hash: landedHash, receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, errorMessage: "invalid receiver"}) - r.resolved(ctx, txObservation{hash: landedHash, txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, gasUsed: 1000, effectiveGasPrice: big.NewInt(2), fee: big.NewInt(2000), blockNumber: 1234}) - r.resolved(ctx, txObservation{hash: landedHash}) - r.flush(ctx) + r.landed(ctx, hash) + r.observed(ctx, hash, txevents.TxStatus_TX_STATUS_REVERTED) // same transaction again: ignored + r.sent(ctx, &evmtypes.TransactionResult{TxHash: other, TxStatus: evmtypes.TxReverted}) + r.landed(ctx, other) events := rec.recorded() - require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) - require.Equal(t, "invalid receiver", ev.ErrorMessage) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, "2", ev.EffectiveGasPrice) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(1234), ev.BlockNumber) - require.False(t, ev.Transmitter) - }) - - t.Run("ownTransaction records the transaction manager status and flags the transmitter", func(t *testing.T) { - rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, false) - r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted, TxIdempotencyKey: "key"}) - require.Empty(t, rec.recorded()) - r.flush(ctx) - - events := rec.recorded() - require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) - require.Equal(t, formatTxHash(ownHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, ev.TxStatus) - require.True(t, ev.Transmitter) - require.False(t, ev.Duplicate) - }) - - t.Run("ownTransaction ignores nil, fatal and zero-hash results", func(t *testing.T) { - rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, false) - r.ownTransaction(nil) - r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxFatal}) - r.ownTransaction(&evmtypes.TransactionResult{TxStatus: evmtypes.TxSuccess}) - r.duplicateOf(ctx, landedHash) - r.flush(ctx) - require.Empty(t, rec.recorded()) - require.Nil(t, r.ownTx) - }) - - t.Run("duplicateOf emits our transaction as a basic duplicate only when another one landed", func(t *testing.T) { - rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, false) - r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxReverted}) - r.duplicateOf(ctx, ownHash) // ours is the transaction that landed - require.Empty(t, rec.recorded()) - - r.duplicateOf(ctx, landedHash) - r.resolved(ctx, txObservation{hash: ownHash, gasUsed: 21000}) - r.flush(ctx) - events := rec.recorded() - require.Len(t, events, 1) - dup := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, dup.Detail) - require.Equal(t, formatTxHash(ownHash), dup.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.TxStatus) - require.True(t, dup.Transmitter) - require.True(t, dup.Duplicate) - require.Empty(t, dup.TransactionFee) + require.Len(t, events, 2) + want := &txevents.ChainTransactionObserved{ + WorkflowExecutionId: "exec-1", + WorkflowId: "wf-id", + ChainSelector: 1, + TxHash: formatTxHash(hash), + TxStatus: txevents.TxStatus_TX_STATUS_SUCCESS, + ObservedAtMs: 1_700_000_000_000, + } + require.True(t, proto.Equal(want, events[0]), "got %v", events[0]) + require.Equal(t, formatTxHash(other), events[1].TxHash) + require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, events[1].TxStatus) }) - t.Run("transmitter-only mode emits only our own transaction", func(t *testing.T) { + t.Run("sent ignores nil, fatal and zero-hash results", func(t *testing.T) { rec := &recordingTxEventEmitter{} - r := newTestReporter(t, rec, true) - r.record(txObservation{hash: landedHash}) - r.resolved(ctx, txObservation{hash: landedHash}) - r.flush(ctx) + r := newTestReporter(t, rec) + r.sent(ctx, nil) + r.sent(ctx, &evmtypes.TransactionResult{TxHash: hash, TxStatus: evmtypes.TxFatal}) + r.sent(ctx, &evmtypes.TransactionResult{TxStatus: evmtypes.TxSuccess}) + r.landed(ctx, evmtypes.Hash{}) require.Empty(t, rec.recorded()) - - r.ownTransaction(&evmtypes.TransactionResult{TxHash: ownHash, TxStatus: evmtypes.TxSuccess}) - r.flush(ctx) - events := rec.recorded() - require.Len(t, events, 1) - require.Equal(t, formatTxHash(ownHash), events[0].TxHash) - require.True(t, events[0].Transmitter) }) - t.Run("skips zero hashes and survives emitter errors", func(t *testing.T) { - rec := &recordingTxEventEmitter{err: errors.New("chip unreachable")} - r := newTestReporter(t, rec, false) - r.record(txObservation{}) - r.resolved(ctx, txObservation{}) - r.flush(ctx) - require.Empty(t, rec.recorded()) - r.resolved(ctx, txObservation{hash: landedHash}) + t.Run("survives emitter errors", func(t *testing.T) { + rec := &recordingTxEventEmitter{err: errors.New("chip down")} + r := newTestReporter(t, rec) + r.landed(ctx, hash) require.Len(t, rec.recorded(), 1) + + unavailable := newTestReporter(t, txEventEmitterFunc(func(context.Context, *txevents.ChainTransactionObserved) error { + return txevents.ErrEmitterUnavailable + })) + unavailable.landed(ctx, hash) }) t.Run("emits even after the request context is done", func(t *testing.T) { @@ -226,77 +144,20 @@ func TestTxEventReporter(t *testing.T) { r := newTestReporter(t, txEventEmitterFunc(func(ctx context.Context, _ *txevents.ChainTransactionObserved) error { gotCtx = ctx return nil - }), false) - cancelled, cancel := context.WithCancel(ctx) + })) + cancelled, cancel := context.WithCancel(t.Context()) cancel() - r.record(txObservation{hash: landedHash}) - r.flush(cancelled) + r.landed(cancelled, hash) require.NotNil(t, gotCtx) require.NoError(t, gotCtx.Err()) }) } -func TestBuildTxEvent(t *testing.T) { - t.Parallel() - hash := evmtypes.Hash(test.RandomBytes(32)) - md := testRequestMetadata() - now := time.UnixMilli(1_700_000_000_000) - - ev := buildTxEvent(txevents.TxDetail_TX_DETAIL_RECEIPT, txObservation{ - hash: hash, - txStatus: txevents.TxStatus_TX_STATUS_SUCCESS, - receiverStatus: txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, - gasUsed: 1000, - effectiveGasPrice: big.NewInt(2), - fee: big.NewInt(2000), - blockNumber: 99, - errorMessage: "receiver contract execution failure", - duplicate: false, - }, true, md, 1, testCapabilityID, now) - - require.Equal(t, "exec-1", ev.WorkflowExecutionId) - require.Equal(t, "wf-id", ev.WorkflowId) - require.Equal(t, "0xowner", ev.WorkflowOwner) - require.Equal(t, "write-step", ev.ReferenceId) - require.Equal(t, uint32(7), ev.WorkflowDonId) - require.Equal(t, "org-1", ev.OrgId) - require.Equal(t, uint64(1), ev.ChainSelector) - require.Equal(t, formatTxHash(hash), ev.TxHash) - require.Len(t, ev.TxHash, 66) - require.Equal(t, "0x", ev.TxHash[:2]) - require.Equal(t, testCapabilityID, ev.CapabilityId) - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, "2", ev.EffectiveGasPrice) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(99), ev.BlockNumber) - require.Equal(t, "receiver contract execution failure", ev.ErrorMessage) - require.Equal(t, now.UnixMilli(), ev.ObservedAtMs) - require.True(t, ev.Transmitter) - require.False(t, ev.Duplicate) - - bare := buildTxEvent(txevents.TxDetail_TX_DETAIL_BASIC, txObservation{hash: hash}, false, md, 1, testCapabilityID, now) - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, bare.Detail) - require.Empty(t, bare.TransactionFee) - require.Empty(t, bare.EffectiveGasPrice) - require.Zero(t, bare.GasUsed) - require.Equal(t, txevents.TxStatus_TX_STATUS_UNSPECIFIED, bare.TxStatus) - require.False(t, bare.Transmitter) -} - func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { t.Parallel() - t.Run("prior successful transmission emits one receipt-detailed event", func(t *testing.T) { - ctx := t.Context() - evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) - mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() - - txHash := evmtypes.Hash(test.RandomBytes(32)) - expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + expectReceipt := func(t *testing.T, evmServiceMock *commonmocks.EVMService, txHash evmtypes.Hash) { + t.Helper() receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(1234)} evmServiceMock.EXPECT(). GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). @@ -304,112 +165,58 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { evmServiceMock.EXPECT(). CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + } - fixture := newWriteReportTestFixture(t) - _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) - require.NoError(t, err) - - events := recordedTxEvents(t, service) - require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, ev.ReceiverStatus) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, "2", ev.EffectiveGasPrice) - require.Equal(t, uint64(1234), ev.BlockNumber) - require.Empty(t, ev.ErrorMessage) - require.False(t, ev.Transmitter) - require.False(t, ev.Duplicate) + assertEvent := func(t *testing.T, ev *txevents.ChainTransactionObserved, fixture writeReportTestFixture, txHash evmtypes.Hash, status txevents.TxStatus) { + t.Helper() require.Equal(t, fixture.metadata.WorkflowExecutionID, ev.WorkflowExecutionId) require.Equal(t, fixture.metadata.WorkflowID, ev.WorkflowId) - require.Equal(t, fixture.metadata.WorkflowOwner, ev.WorkflowOwner) - require.Equal(t, fixture.metadata.ReferenceID, ev.ReferenceId) - require.Equal(t, fixture.metadata.WorkflowDonID, ev.WorkflowDonId) require.Equal(t, uint64(1), ev.ChainSelector) - require.Equal(t, testCapabilityID, ev.CapabilityId) + require.Equal(t, formatTxHash(txHash), ev.TxHash) + require.Equal(t, status, ev.TxStatus) require.NotZero(t, ev.ObservedAtMs) - }) + } - t.Run("prior invalid receiver carries the revert reason, gas and fee", func(t *testing.T) { + t.Run("prior successful transmission is reported", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Return(contracts.TransmissionInfo{InvalidReceiver: true, State: contracts.TransmissionStateInvalidReceiver, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) - expectFailedTransmissionEvent(t, mockForwarderClient, txHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(55)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + expectReceipt(t, evmServiceMock, txHash) _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) events := recordedTxEvents(t, service) require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) - require.Equal(t, fixture.transmissionID.InvalidReceiverMessage(), ev.ErrorMessage) - require.Equal(t, uint64(55), ev.BlockNumber) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(1000), ev.GasUsed) - require.False(t, ev.Transmitter) - mockForwarderClient.AssertNotCalled(t, "InvokeOnReport", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + assertEvent(t, events[0], fixture, txHash, txevents.TxStatus_TX_STATUS_SUCCESS) }) - t.Run("own transmission emits one receipt-detailed event flagged transmitter", func(t *testing.T) { + t.Run("prior transmission with an invalid receiver is reported as a mined transaction", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() + Return(contracts.TransmissionInfo{InvalidReceiver: true, State: contracts.TransmissionStateInvalidReceiver, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) - emittedBeforeReceipt := -1 - mockForwarderClient. - On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). - Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess, TxIdempotencyKey: "key"}, nil).Once() - mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Run(func(mock.Arguments) { emittedBeforeReceipt = len(recordedTxEvents(t, service)) }). - Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() - expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(77)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + expectFailedTransmissionEvent(t, mockForwarderClient, txHash) + expectReceipt(t, evmServiceMock, txHash) _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) - require.Zero(t, emittedBeforeReceipt, "nothing is emitted until the receipt is resolved") events := recordedTxEvents(t, service) require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.True(t, ev.Transmitter) - require.False(t, ev.Duplicate) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, uint64(77), ev.BlockNumber) + assertEvent(t, events[0], fixture, txHash, txevents.TxStatus_TX_STATUS_SUCCESS) + mockForwarderClient.AssertNotCalled(t, "InvokeOnReport", mock.Anything, mock.Anything, mock.Anything, mock.Anything) }) - t.Run("own transmission whose receiver reverted carries the reason and the gas spent", func(t *testing.T) { + t.Run("own transmission is reported as soon as the transaction manager returns, before the receipt", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) fixture := newWriteReportTestFixture(t) @@ -417,42 +224,28 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) + emittedBeforeReceipt := -1 mockForwarderClient. On("InvokeOnReport", mock.Anything, fixture.receiver, fixture.request.Report, nonNilPositiveGasCfgMatcher()). - Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess}, nil).Once() + Return(&evmtypes.TransactionResult{TxHash: txHash, TxStatus: evmtypes.TxSuccess, TxIdempotencyKey: "key"}, nil).Once() mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Return(contracts.TransmissionInfo{State: contracts.TransmissionStateFailed, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(66)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + Run(func(mock.Arguments) { emittedBeforeReceipt = len(recordedTxEvents(t, service)) }). + Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() + expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) + expectReceipt(t, evmServiceMock, txHash) _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) + require.Equal(t, 1, emittedBeforeReceipt, "the event goes out when the transaction manager returns the mined transaction") events := recordedTxEvents(t, service) - require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_REVERTED, ev.ReceiverStatus) - require.Equal(t, UnknownIssueExecutingReceiverContractMessage, ev.ErrorMessage) - require.Equal(t, "2000", ev.TransactionFee) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, uint64(66), ev.BlockNumber) - require.True(t, ev.Transmitter) - require.False(t, ev.Duplicate) + require.Len(t, events, 1, "seeing our own hash again in the forwarder log does not emit twice") + assertEvent(t, events[0], fixture, txHash, txevents.TxStatus_TX_STATUS_SUCCESS) }) - runDuplicate := func(t *testing.T, transmitterOnly bool) ([]*txevents.ChainTransactionObserved, evmtypes.Hash, evmtypes.Hash) { - t.Helper() + t.Run("duplicate transmission reports our reverted transaction and the one that landed", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) - service.txEvents.TransmitterOnly = transmitterOnly fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). Return(contracts.TransmissionInfo{State: contracts.TransmissionStateNotAttempted}, nil).Once() @@ -465,59 +258,23 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded, GasLimit: big.NewInt(EnoughReceiverGas)}, nil).Once() expectSuccessfulTransmissionEvent(t, mockForwarderClient, landedHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: landedHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(88)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: landedHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + expectReceipt(t, evmServiceMock, landedHash) _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) - return recordedTxEvents(t, service), landedHash, ownHash - } - t.Run("duplicate transmission emits our reverted duplicate with basic detail and the landed one with its receipt", func(t *testing.T) { - events, landedHash, ownHash := runDuplicate(t, false) + events := recordedTxEvents(t, service) require.Len(t, events, 2) - - dup := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, dup.Detail) - require.Equal(t, formatTxHash(ownHash), dup.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_REVERTED, dup.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, dup.ReceiverStatus) - require.Empty(t, dup.TransactionFee) - require.Zero(t, dup.GasUsed) - require.Zero(t, dup.BlockNumber) - require.True(t, dup.Transmitter) - require.True(t, dup.Duplicate) - - landed := events[1] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, landed.Detail) - require.Equal(t, formatTxHash(landedHash), landed.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, landed.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, landed.ReceiverStatus) - require.Equal(t, "2000", landed.TransactionFee) - require.Equal(t, uint64(1000), landed.GasUsed) - require.Equal(t, uint64(88), landed.BlockNumber) - require.False(t, landed.Transmitter) - require.False(t, landed.Duplicate) - require.NotEqual(t, txevents.IdempotencyKey(dup), txevents.IdempotencyKey(landed)) - }) - - t.Run("transmitter-only mode emits only the transaction this node sent", func(t *testing.T) { - events, _, ownHash := runDuplicate(t, true) - require.Len(t, events, 1) - require.Equal(t, formatTxHash(ownHash), events[0].TxHash) - require.True(t, events[0].Transmitter) - require.True(t, events[0].Duplicate) + assertEvent(t, events[0], fixture, ownHash, txevents.TxStatus_TX_STATUS_REVERTED) + assertEvent(t, events[1], fixture, landedHash, txevents.TxStatus_TX_STATUS_SUCCESS) + require.NotEqual(t, txevents.Key(events[0]), txevents.Key(events[1])) }) - t.Run("receipt lookup failure emits the basic event known from the log", func(t *testing.T) { + t.Run("receipt lookup failure does not lose the event", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) defer cancel() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) + fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil) @@ -527,25 +284,15 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). Return(nil, errors.New("rpc down")) - fixture := newWriteReportTestFixture(t) _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.Error(t, err) events := recordedTxEvents(t, service) require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.Equal(t, txevents.ReceiverStatus_RECEIVER_STATUS_SUCCESS, ev.ReceiverStatus) - require.Equal(t, uint64(100), ev.BlockNumber, "block number comes from the ReportProcessed log") - require.Empty(t, ev.TransactionFee) - require.Zero(t, ev.GasUsed) - require.Equal(t, fixture.metadata.WorkflowExecutionID, ev.WorkflowExecutionId) - require.Equal(t, fixture.metadata.WorkflowID, ev.WorkflowId) + assertEvent(t, events[0], fixture, txHash, txevents.TxStatus_TX_STATUS_SUCCESS) }) - t.Run("request failing after the transaction was sent emits the basic event for it", func(t *testing.T) { + t.Run("request failing after the transaction was sent still reports it", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) defer cancel() _, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) @@ -565,64 +312,21 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { events := recordedTxEvents(t, service) require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_BASIC, ev.Detail) - require.Equal(t, formatTxHash(txHash), ev.TxHash) - require.Equal(t, txevents.TxStatus_TX_STATUS_SUCCESS, ev.TxStatus) - require.True(t, ev.Transmitter) - require.False(t, ev.Duplicate) - require.Empty(t, ev.TransactionFee) - }) - - t.Run("fee calculation failure still emits the receipt detail with the gas spent", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) - mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). - Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil) - - txHash := evmtypes.Hash(test.RandomBytes(32)) - expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2), BlockNumber: big.NewInt(1234)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(nil, errors.New("fee oracle down")) - - fixture := newWriteReportTestFixture(t) - _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) - require.Error(t, err) - - events := recordedTxEvents(t, service) - require.Len(t, events, 1) - ev := events[0] - require.Equal(t, txevents.TxDetail_TX_DETAIL_RECEIPT, ev.Detail) - require.Equal(t, uint64(1000), ev.GasUsed) - require.Equal(t, "2", ev.EffectiveGasPrice) - require.Empty(t, ev.TransactionFee) - require.Equal(t, uint64(1234), ev.BlockNumber) + assertEvent(t, events[0], fixture, txHash, txevents.TxStatus_TX_STATUS_SUCCESS) }) t.Run("emitter failure does not fail the write", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) recordingEmitter(t, service).err = errors.New("chip unreachable") + fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + expectReceipt(t, evmServiceMock, txHash) - fixture := newWriteReportTestFixture(t) txResult, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) require.Equal(t, txHash[:], txResult.Response.TxHash) @@ -632,22 +336,17 @@ func TestWriteReport_EmitsChainTransactionObserved(t *testing.T) { t.Run("disabled emission leaves the write untouched", func(t *testing.T) { ctx := t.Context() evmServiceMock, mockForwarderClient, service := createMocksAndCapability(t, logger.Test(t)) - service.txEvents.Emitter = txevents.NopEmitter{} + service.txEvents = txevents.NopEmitter{} + fixture := newWriteReportTestFixture(t) mockForwarderClient.On("GetTransmissionInfo", mock.Anything, mock.Anything). Return(contracts.TransmissionInfo{Success: true, State: contracts.TransmissionStateSucceeded}, nil).Once() txHash := evmtypes.Hash(test.RandomBytes(32)) expectSuccessfulTransmissionEvent(t, mockForwarderClient, txHash) - receipt := evmtypes.Receipt{Status: 1, TxHash: txHash, GasUsed: 1000, EffectiveGasPrice: big.NewInt(2)} - evmServiceMock.EXPECT(). - GetTransactionReceipt(mock.Anything, evmtypes.GeTransactionReceiptRequest{Hash: txHash, IsExternal: false}). - Return(&receipt, nil) - evmServiceMock.EXPECT(). - CalculateTransactionFee(mock.Anything, toReceiptGasInfo(receipt)). - Return(&evmtypes.TransactionFee{TransactionFee: big.NewInt(2000)}, nil) + expectReceipt(t, evmServiceMock, txHash) - fixture := newWriteReportTestFixture(t) - _, err := service.WriteReport(ctx, fixture.metadata, fixture.request) + txResult, err := service.WriteReport(ctx, fixture.metadata, fixture.request) require.NoError(t, err) + require.Equal(t, txHash[:], txResult.Response.TxHash) }) } diff --git a/chain_capabilities/evm/actions/write_report.go b/chain_capabilities/evm/actions/write_report.go index 137aaadcc..5e4e38788 100644 --- a/chain_capabilities/evm/actions/write_report.go +++ b/chain_capabilities/evm/actions/write_report.go @@ -56,8 +56,8 @@ type WriteReport struct { gasMeter *gasmeter.Meter - // txReporter emits ChainTransactionObserved the moment each transaction hash, and later its - // receipt, becomes known. Nil when transaction events are disabled. + // txReporter emits ChainTransactionObserved the moment a transaction is known to be mined. + // Nil when transaction events are disabled. txReporter *txEventReporter } @@ -109,11 +109,7 @@ func (e *EVM) executeWriteReport(ctx context.Context, request *evm.WriteReportRe txReporter: e.newTxEventReporter(lggr, metadata), } - reply, responseMetadata, err := wr.executeWriteReport(ctx, request, metadata, telemetryContext) - // Transactions whose receipt was not resolved (the lookup failed, or the request failed after - // the hash was known) are reported with basic detail now that the outcome is decided. - wr.txReporter.flush(ctx) - return reply, responseMetadata, err + return wr.executeWriteReport(ctx, request, metadata, telemetryContext) } func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.WriteReportRequest, metadata capabilities.RequestMetadata, telemetryContext monitoring.TelemetryContext) (*evm.WriteReportReply, capabilities.ResponseMetadata, error) { @@ -157,7 +153,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write } e.lggr.Infow("Returning without a transmission attempt - prior transmission succeeded", "txHash", common.Bytes2Hex(txHash[:])) - e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS, "") + e.txReporter.landed(ctx, *txHash) reply, err := e.buildSuccessReply(ctx, *txHash) if err != nil { return nil, capabilities.ResponseMetadata{}, err @@ -175,7 +171,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write } e.lggr.Infow("Returning without a transmission attempt - prior transmission marked receiver invalid", "txHash", common.Bytes2Hex(txHash[:])) - e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(transmissionInfo, transmissionID)) + e.txReporter.landed(ctx, *txHash) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, transmissionInfo, transmissionID) if err != nil { // Surface the known revert reason so the user learns their receiver is invalid even @@ -201,7 +197,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write "receiverGasBudget", calculatedReceiverGasBudget, "transmissionReceiverGasBudget", transmissionInfo.GasLimit, ) - e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(transmissionInfo, transmissionID)) + e.txReporter.landed(ctx, *txHash) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, transmissionInfo, transmissionID) if err != nil { // The receiver reverted despite sufficient gas (user contract fault); surface the @@ -229,7 +225,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write e.lggr.Errorw("Transaction failed", "error", err.Error()) return nil, capabilities.ResponseMetadata{}, err } - e.txReporter.ownTransaction(transactionResult) + e.txReporter.sent(ctx, transactionResult) newTransmissionInfo, err := capcommon.WithPollingRetry(ctx, e.lggr, func(ctx context.Context) (contracts.TransmissionInfo, error) { readTransmissionInfo, readTransmissionErr := e.forwarderClient.GetTransmissionInfo(ctx, transmissionID) @@ -254,8 +250,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write if err != nil { return nil, capabilities.ResponseMetadata{}, err } - e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS, "") - e.txReporter.duplicateOf(ctx, *txHash) + e.txReporter.landed(ctx, *txHash) switch transactionResult.TxStatus { case evmtypes.TxReverted: // Report for this transaction has already been submitted and we sent a duplicate tx onchain which is fine, but wastes ethereum gas @@ -287,7 +282,7 @@ func (e *WriteReport) executeWriteReport(ctx context.Context, request *evm.Write txHash = originalTxHash } e.lggr.Errorw("Made a new transmission attempt - transmission failed", "txHash", common.Bytes2Hex(txHash[:]), "transmissionState", newTransmissionInfo.State.String()) - e.observeLandedTx(&txHashRetriever, *txHash, evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, revertReason(newTransmissionInfo, transmissionID)) + e.txReporter.landed(ctx, *txHash) reply, err := e.buildRevertReplyFromTx(ctx, *txHash, newTransmissionInfo, transmissionID) if err != nil { @@ -492,10 +487,6 @@ func revertReplyBuildError(transmissionInfo contracts.TransmissionInfo, transmis } func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash, receiverStatus evm.ReceiverContractExecutionStatus, errorMessage *string) (*evm.WriteReportReply, error) { - // Callers record the hash as soon as they learn it; this is a safety net so the transaction is - // still reported with basic detail if the lookups below fail. - e.txReporter.record(txObservation{hash: txHash, receiverStatus: receiverStatusFromReply(receiverStatus), errorMessage: derefString(errorMessage)}) - txReceipt, err := capcommon.WithQuickRetry(ctx, e.lggr, func(ctx context.Context) (*evmtypes.Receipt, error) { return e.GetTransactionReceipt(ctx, evmtypes.GeTransactionReceiptRequest{ Hash: txHash, @@ -508,15 +499,6 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash // filter logs per node by hash. Callers add the user-facing reason (e.g. invalid receiver). return nil, fmt.Errorf("failed to get transaction receipt for tx hash 0x%x: %w", txHash[:], err) } - resolvedTx := txObservation{ - hash: txHash, - txStatus: txStatusFromReceipt(txReceipt.Status), - receiverStatus: receiverStatusFromReply(receiverStatus), - gasUsed: txReceipt.GasUsed, - effectiveGasPrice: txReceipt.EffectiveGasPrice, - blockNumber: blockNumberFromReceipt(txReceipt.BlockNumber), - errorMessage: derefString(errorMessage), - } receiptGasInfo := evmtypes.ReceiptGasInfo{ GasUsed: txReceipt.GasUsed, EffectiveGasPrice: txReceipt.EffectiveGasPrice, @@ -531,8 +513,6 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash return e.CalculateTransactionFee(ctx, receiptGasInfo) }) if err != nil { - // The gas spent is known from the receipt even though it could not be priced. - e.txReporter.resolved(ctx, resolvedTx) return nil, fmt.Errorf("failed to calculate transaction fee for tx hash 0x%x: %w", txHash[:], err) } message := errorMessage @@ -544,10 +524,6 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash if txReceipt.Status == 0 { txStatus = evm.TxStatus_TX_STATUS_REVERTED } - resolvedTx.fee = transactionFee.TransactionFee - resolvedTx.errorMessage = derefString(message) - e.txReporter.resolved(ctx, resolvedTx) - e.lggr.Infow("Successfully fetched tx receipt", "txHash", hex.EncodeToString(txHash[:]), "txStatus", txReceipt.Status, @@ -564,13 +540,6 @@ func (e *WriteReport) replyFromReceipt(ctx context.Context, txHash evmtypes.Hash }, nil } -func derefString(s *string) string { - if s == nil { - return "" - } - return *s -} - // meteringFromReply returns billing metadata carrying the on-chain transaction fee contained // in the reply, so that every node reports the gas spent on chain, regardless of which node // transmitted. Deriving it from the reply guarantees the metered fee matches the reply's @@ -625,21 +594,6 @@ type TxHashRetriever struct { transmissionID contracts.TransmissionID keystoneForwarderClient contracts.CREForwarderClient lggr logger.Logger - - // logs caches the ReportProcessed logs of the last fetch so a hash can be reported with its - // block number as soon as it is known, before its receipt is fetched. - logs logDetailsList -} - -// BlockNumber returns the block holding the ReportProcessed log for txHash in the last fetched -// logs, or 0 when unknown. -func (thr *TxHashRetriever) BlockNumber(txHash evmtypes.Hash) uint64 { - for _, d := range thr.logs { - if d.TxHash == txHash { - return blockNumberFromReceipt(d.BlockNumber) - } - } - return 0 } func NewTxHashRetriever(forwarderClient contracts.CREForwarderClient, lggr logger.Logger, transmissionID contracts.TransmissionID) TxHashRetriever { @@ -741,8 +695,6 @@ func (thr *TxHashRetriever) fetchAndParseLogs(ctx context.Context) (logDetailsLi if err != nil { return nil, fmt.Errorf("malformed log data for transmission %s: %w", thr.transmissionID.String(), err) } - thr.logs = details - return details, nil } diff --git a/chain_capabilities/evm/actions/write_report_test.go b/chain_capabilities/evm/actions/write_report_test.go index 76a63175a..a6b61b8c0 100644 --- a/chain_capabilities/evm/actions/write_report_test.go +++ b/chain_capabilities/evm/actions/write_report_test.go @@ -2249,7 +2249,7 @@ func createMocksAndCapability(t *testing.T, lggr logger.Logger) (*mocks2.EVMServ beholderProcessor: test.NopBeholderProcessor{}, messageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), transmissionScheduler: transmissionScheduler, - txEvents: TxEventsOptions{Emitter: &recordingTxEventEmitter{}, CapabilityID: testCapabilityID}, + txEvents: &recordingTxEventEmitter{}, } require.NoError(t, service.initLimiters(limits.Factory{Logger: lggr})) t.Cleanup(func() { assert.NoError(t, service.Close()) }) @@ -2276,7 +2276,7 @@ func createMocksAndCapabilityWithScheduler( beholderProcessor: test.NopBeholderProcessor{}, messageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), transmissionScheduler: scheduler, - txEvents: TxEventsOptions{Emitter: &recordingTxEventEmitter{}, CapabilityID: testCapabilityID}, + txEvents: &recordingTxEventEmitter{}, } require.NoError(t, service.initLimiters(limits.Factory{Logger: lggr})) t.Cleanup(func() { assert.NoError(t, service.Close()) }) diff --git a/chain_capabilities/evm/config/config.go b/chain_capabilities/evm/config/config.go index 75debc5b6..9f6bbbc18 100644 --- a/chain_capabilities/evm/config/config.go +++ b/chain_capabilities/evm/config/config.go @@ -32,9 +32,6 @@ type TxEventsConfig struct { // Enabled turns emission on. The node must also run with CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED // for the events to leave the process; otherwise they are counted as dropped. Enabled bool `json:"enabled"` - // TransmitterOnly restricts emission to transactions this node sent. By default every node - // that observed a transaction emits it and the consumer dedups by (chain selector, tx hash). - TransmitterOnly bool `json:"transmitterOnly"` // Domain is the ChIP source domain the events are published to. Defaults to txevents.DefaultDomain. Domain string `json:"domain"` } diff --git a/chain_capabilities/evm/main.go b/chain_capabilities/evm/main.go index c53cd4051..7550ec179 100644 --- a/chain_capabilities/evm/main.go +++ b/chain_capabilities/evm/main.go @@ -35,6 +35,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities" evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" evmcapserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/durableemitter" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/loop" "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" @@ -232,30 +233,23 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor return nil } -// txEventsOption wires ChainTransactionObserved emission. Events go to ChIP through the LOOP's -// durable emitter, the same handle the gas meter uses (injected via MeteringConfig rather than -// the process-global); when the LOOP has none they are dropped and counted. +// txEventsOption wires ChainTransactionObserved emission. Events go to ChIP through the +// process-global durable emitter (txevents.GlobalSink), the path the platform uses for its own +// ChIP events; the LOOP server sets it up when the node runs with +// CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED. Without it the events are dropped and counted. func (c *capabilityGRPCService) txEventsOption(cfg *config.Config) (actions.Option, error) { - opts := actions.TxEventsOptions{ - Emitter: txevents.NopEmitter{}, - TransmitterOnly: cfg.TxEvents.TransmitterOnly, - CapabilityID: c.id, + if !cfg.TxEvents.Enabled { + return actions.WithTxEvents(txevents.NopEmitter{}), nil } - if cfg.TxEvents.Enabled { - var sink txevents.Sink - if rmEmitter := c.meteringCfg.ResourceManagerConfig.Emitter; rmEmitter != nil { - sink = rmEmitter - } else { - c.lggr.Errorw("Transaction events enabled but this LOOP has no durable emitter; events will not be delivered") - } - emitter, err := txevents.NewDurableEmitter(c.lggr, cfg.TxEvents.Domain, sink) - if err != nil { - return nil, fmt.Errorf("failed to create tx events emitter: %w", err) - } - opts.Emitter = emitter - c.lggr.Infow("Transaction events enabled", "domain", cfg.TxEvents.Domain, "transmitterOnly", cfg.TxEvents.TransmitterOnly) + if durableemitter.GetGlobalEmitter() == nil { + c.lggr.Errorw("Transaction events enabled but this LOOP has no durable emitter; events will not be delivered", "domain", cfg.TxEvents.Domain) + } + emitter, err := txevents.NewDurableEmitter(c.lggr, cfg.TxEvents.Domain, txevents.GlobalSink()) + if err != nil { + return nil, fmt.Errorf("failed to create tx events emitter: %w", err) } - return actions.WithTxEvents(opts), nil + c.lggr.Infow("Transaction events enabled", "domain", cfg.TxEvents.Domain) + return actions.WithTxEvents(emitter), nil } func (c *capabilityGRPCService) unmarshalConfig(configStr string) (*config.Config, error) { diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go index 9bf2255c5..3e5cf2537 100644 --- a/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.pb.go @@ -4,10 +4,10 @@ // protoc v5.29.3 // source: chain_capabilities/evm/txevents/chain_transaction_observed.proto -// Package txevents defines the chain-agnostic transaction event every chain capability emits -// for the CRE Transactions Viewer. The package name is intentionally free of the chain family so -// Aptos, Solana and Stellar can emit the same schema; the file lives in the EVM module only until -// the ChIP domain and schema registration are settled with Data Platform (PLEX-3409). +// Package txevents defines the transaction event every chain capability emits for the CRE +// Transactions Viewer. The package name is intentionally free of the chain family so Aptos, +// Solana and Stellar can emit the same schema; the file lives in the EVM module only until the +// ChIP domain and schema registration are settled with Data Platform (PLEX-3409). package txevents @@ -26,68 +26,13 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// TxDetail says how much the emitting node learnt about the transaction. A node emits exactly -// one event per transaction it saw land while serving a request: RECEIPT once it resolved the -// receipt, BASIC otherwise (the receipt lookup failed, the request failed after the hash was -// known, or the transaction is the node's own duplicate, whose receipt is not fetched). -type TxDetail int32 - -const ( - TxDetail_TX_DETAIL_UNSPECIFIED TxDetail = 0 - // Hash, attribution and the statuses known from the transaction manager or the forwarder. - // Receipt-derived fields (gas, fee) are not set. - TxDetail_TX_DETAIL_BASIC TxDetail = 1 - // Receipt-derived status, gas used, effective gas price, fee and block number are set. - TxDetail_TX_DETAIL_RECEIPT TxDetail = 2 -) - -// Enum value maps for TxDetail. -var ( - TxDetail_name = map[int32]string{ - 0: "TX_DETAIL_UNSPECIFIED", - 1: "TX_DETAIL_BASIC", - 2: "TX_DETAIL_RECEIPT", - } - TxDetail_value = map[string]int32{ - "TX_DETAIL_UNSPECIFIED": 0, - "TX_DETAIL_BASIC": 1, - "TX_DETAIL_RECEIPT": 2, - } -) - -func (x TxDetail) Enum() *TxDetail { - p := new(TxDetail) - *p = x - return p -} - -func (x TxDetail) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (TxDetail) Descriptor() protoreflect.EnumDescriptor { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0].Descriptor() -} - -func (TxDetail) Type() protoreflect.EnumType { - return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0] -} - -func (x TxDetail) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use TxDetail.Descriptor instead. -func (TxDetail) EnumDescriptor() ([]byte, []int) { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} -} - -// TxStatus is the on-chain outcome of the transaction itself, as reported by the transaction -// manager or the forwarder log at BASIC detail and by the receipt at RECEIPT detail. +// TxStatus is the on-chain outcome of the transaction as known at the moment it was observed: +// from the transaction manager for the node's own transaction, or implied by the forwarder's +// ReportProcessed log (which only exists for successful transactions) for transactions sent by +// other nodes. Consumers fetch the receipt for anything beyond that. type TxStatus int32 const ( - // Not known when the event was emitted. Consumers fetch the receipt by hash. TxStatus_TX_STATUS_UNSPECIFIED TxStatus = 0 TxStatus_TX_STATUS_SUCCESS TxStatus = 1 TxStatus_TX_STATUS_REVERTED TxStatus = 2 @@ -118,11 +63,11 @@ func (x TxStatus) String() string { } func (TxStatus) Descriptor() protoreflect.EnumDescriptor { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1].Descriptor() + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0].Descriptor() } func (TxStatus) Type() protoreflect.EnumType { - return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[1] + return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[0] } func (x TxStatus) Number() protoreflect.EnumNumber { @@ -131,107 +76,26 @@ func (x TxStatus) Number() protoreflect.EnumNumber { // Deprecated: Use TxStatus.Descriptor instead. func (TxStatus) EnumDescriptor() ([]byte, []int) { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{1} -} - -// ReceiverStatus is the outcome of the receiver (user) contract call made by the forwarder. A -// transaction can succeed on chain while the receiver call reverted ("internally reverted"). -type ReceiverStatus int32 - -const ( - ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED ReceiverStatus = 0 - ReceiverStatus_RECEIVER_STATUS_SUCCESS ReceiverStatus = 1 - ReceiverStatus_RECEIVER_STATUS_REVERTED ReceiverStatus = 2 -) - -// Enum value maps for ReceiverStatus. -var ( - ReceiverStatus_name = map[int32]string{ - 0: "RECEIVER_STATUS_UNSPECIFIED", - 1: "RECEIVER_STATUS_SUCCESS", - 2: "RECEIVER_STATUS_REVERTED", - } - ReceiverStatus_value = map[string]int32{ - "RECEIVER_STATUS_UNSPECIFIED": 0, - "RECEIVER_STATUS_SUCCESS": 1, - "RECEIVER_STATUS_REVERTED": 2, - } -) - -func (x ReceiverStatus) Enum() *ReceiverStatus { - p := new(ReceiverStatus) - *p = x - return p -} - -func (x ReceiverStatus) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ReceiverStatus) Descriptor() protoreflect.EnumDescriptor { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[2].Descriptor() -} - -func (ReceiverStatus) Type() protoreflect.EnumType { - return &file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes[2] -} - -func (x ReceiverStatus) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ReceiverStatus.Descriptor instead. -func (ReceiverStatus) EnumDescriptor() ([]byte, []int) { - return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{2} + return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescGZIP(), []int{0} } -// ChainTransactionObserved records one transaction a chain capability saw land on chain while -// serving a WriteReport, including reverted and duplicate ones. Each node emits it once per -// transaction, see TxDetail. The Blockchain Finality Service dedups events by -// (chain_selector, tx_hash), fetches the receipt and tracks finality, so the body is kept -// minimal: no report payload, signatures or request data are included. +// ChainTransactionObserved is emitted by a chain capability as soon as it learns that a +// transaction belonging to a workflow execution was mined, including reverted and duplicate +// ones. It carries only what is needed to attribute the transaction to the execution; the +// Blockchain Finality Service dedups events by (chain_selector, tx_hash) and resolves the +// receipt, fee and finality itself. type ChainTransactionObserved struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Attribution, copied from the capability request metadata. - WorkflowExecutionId string `protobuf:"bytes,1,opt,name=workflow_execution_id,json=workflowExecutionId,proto3" json:"workflow_execution_id,omitempty"` - WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` - WorkflowOwner string `protobuf:"bytes,3,opt,name=workflow_owner,json=workflowOwner,proto3" json:"workflow_owner,omitempty"` - // Workflow step reference that issued the write. - ReferenceId string `protobuf:"bytes,4,opt,name=reference_id,json=referenceId,proto3" json:"reference_id,omitempty"` - WorkflowDonId uint32 `protobuf:"varint,5,opt,name=workflow_don_id,json=workflowDonId,proto3" json:"workflow_don_id,omitempty"` - // Informational only. Consumers must resolve the organization from the execution record. - OrgId string `protobuf:"bytes,6,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` - // Transaction identity. - ChainSelector uint64 `protobuf:"varint,7,opt,name=chain_selector,json=chainSelector,proto3" json:"chain_selector,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowExecutionId string `protobuf:"bytes,1,opt,name=workflow_execution_id,json=workflowExecutionId,proto3" json:"workflow_execution_id,omitempty"` + WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + ChainSelector uint64 `protobuf:"varint,3,opt,name=chain_selector,json=chainSelector,proto3" json:"chain_selector,omitempty"` // Chain-native encoding. EVM: 0x-prefixed lowercase hex. - TxHash string `protobuf:"bytes,8,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` - CapabilityId string `protobuf:"bytes,9,opt,name=capability_id,json=capabilityId,proto3" json:"capability_id,omitempty"` - // Outcome as observed by the emitting node. - TxStatus TxStatus `protobuf:"varint,10,opt,name=tx_status,json=txStatus,proto3,enum=chain_capabilities.txevents.v1.TxStatus" json:"tx_status,omitempty"` - ReceiverStatus ReceiverStatus `protobuf:"varint,11,opt,name=receiver_status,json=receiverStatus,proto3,enum=chain_capabilities.txevents.v1.ReceiverStatus" json:"receiver_status,omitempty"` - // Total cost in the chain's base unit (wei) as a decimal string, including the L1 data fee on - // rollups when the capability accounts for it. Empty when unknown. - TransactionFee string `protobuf:"bytes,12,opt,name=transaction_fee,json=transactionFee,proto3" json:"transaction_fee,omitempty"` - // 0 when unknown. - BlockNumber uint64 `protobuf:"varint,13,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` - // User-facing revert reason, when the receiver reverted. - ErrorMessage string `protobuf:"bytes,14,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` - // Emission context. - // Unix milliseconds at which the node emitted the event. - ObservedAtMs int64 `protobuf:"varint,15,opt,name=observed_at_ms,json=observedAtMs,proto3" json:"observed_at_ms,omitempty"` - // True when the emitting node sent this transaction. - Transmitter bool `protobuf:"varint,16,opt,name=transmitter,proto3" json:"transmitter,omitempty"` - // True when this transaction reverted because another node's transmission of the same report - // had already landed. Such transactions still cost gas and must be visible to the user. - Duplicate bool `protobuf:"varint,17,opt,name=duplicate,proto3" json:"duplicate,omitempty"` - // How much the node learnt about the transaction; see TxDetail. - Detail TxDetail `protobuf:"varint,18,opt,name=detail,proto3,enum=chain_capabilities.txevents.v1.TxDetail" json:"detail,omitempty"` - // Gas spent, from the receipt. 0 when unknown. - GasUsed uint64 `protobuf:"varint,19,opt,name=gas_used,json=gasUsed,proto3" json:"gas_used,omitempty"` - // Price paid per gas unit in wei as a decimal string, from the receipt. Empty when unknown. - EffectiveGasPrice string `protobuf:"bytes,20,opt,name=effective_gas_price,json=effectiveGasPrice,proto3" json:"effective_gas_price,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + TxHash string `protobuf:"bytes,4,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + TxStatus TxStatus `protobuf:"varint,5,opt,name=tx_status,json=txStatus,proto3,enum=chain_capabilities.txevents.v1.TxStatus" json:"tx_status,omitempty"` + // Unix milliseconds at which the node observed the transaction. + ObservedAtMs int64 `protobuf:"varint,6,opt,name=observed_at_ms,json=observedAtMs,proto3" json:"observed_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChainTransactionObserved) Reset() { @@ -278,34 +142,6 @@ func (x *ChainTransactionObserved) GetWorkflowId() string { return "" } -func (x *ChainTransactionObserved) GetWorkflowOwner() string { - if x != nil { - return x.WorkflowOwner - } - return "" -} - -func (x *ChainTransactionObserved) GetReferenceId() string { - if x != nil { - return x.ReferenceId - } - return "" -} - -func (x *ChainTransactionObserved) GetWorkflowDonId() uint32 { - if x != nil { - return x.WorkflowDonId - } - return 0 -} - -func (x *ChainTransactionObserved) GetOrgId() string { - if x != nil { - return x.OrgId - } - return "" -} - func (x *ChainTransactionObserved) GetChainSelector() uint64 { if x != nil { return x.ChainSelector @@ -320,13 +156,6 @@ func (x *ChainTransactionObserved) GetTxHash() string { return "" } -func (x *ChainTransactionObserved) GetCapabilityId() string { - if x != nil { - return x.CapabilityId - } - return "" -} - func (x *ChainTransactionObserved) GetTxStatus() TxStatus { if x != nil { return x.TxStatus @@ -334,34 +163,6 @@ func (x *ChainTransactionObserved) GetTxStatus() TxStatus { return TxStatus_TX_STATUS_UNSPECIFIED } -func (x *ChainTransactionObserved) GetReceiverStatus() ReceiverStatus { - if x != nil { - return x.ReceiverStatus - } - return ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED -} - -func (x *ChainTransactionObserved) GetTransactionFee() string { - if x != nil { - return x.TransactionFee - } - return "" -} - -func (x *ChainTransactionObserved) GetBlockNumber() uint64 { - if x != nil { - return x.BlockNumber - } - return 0 -} - -func (x *ChainTransactionObserved) GetErrorMessage() string { - if x != nil { - return x.ErrorMessage - } - return "" -} - func (x *ChainTransactionObserved) GetObservedAtMs() int64 { if x != nil { return x.ObservedAtMs @@ -369,81 +170,23 @@ func (x *ChainTransactionObserved) GetObservedAtMs() int64 { return 0 } -func (x *ChainTransactionObserved) GetTransmitter() bool { - if x != nil { - return x.Transmitter - } - return false -} - -func (x *ChainTransactionObserved) GetDuplicate() bool { - if x != nil { - return x.Duplicate - } - return false -} - -func (x *ChainTransactionObserved) GetDetail() TxDetail { - if x != nil { - return x.Detail - } - return TxDetail_TX_DETAIL_UNSPECIFIED -} - -func (x *ChainTransactionObserved) GetGasUsed() uint64 { - if x != nil { - return x.GasUsed - } - return 0 -} - -func (x *ChainTransactionObserved) GetEffectiveGasPrice() string { - if x != nil { - return x.EffectiveGasPrice - } - return "" -} - var File_chain_capabilities_evm_txevents_chain_transaction_observed_proto protoreflect.FileDescriptor const file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc = "" + "\n" + - "@chain_capabilities/evm/txevents/chain_transaction_observed.proto\x12\x1echain_capabilities.txevents.v1\"\xe1\x06\n" + + "@chain_capabilities/evm/txevents/chain_transaction_observed.proto\x12\x1echain_capabilities.txevents.v1\"\x9c\x02\n" + "\x18ChainTransactionObserved\x122\n" + "\x15workflow_execution_id\x18\x01 \x01(\tR\x13workflowExecutionId\x12\x1f\n" + "\vworkflow_id\x18\x02 \x01(\tR\n" + "workflowId\x12%\n" + - "\x0eworkflow_owner\x18\x03 \x01(\tR\rworkflowOwner\x12!\n" + - "\freference_id\x18\x04 \x01(\tR\vreferenceId\x12&\n" + - "\x0fworkflow_don_id\x18\x05 \x01(\rR\rworkflowDonId\x12\x15\n" + - "\x06org_id\x18\x06 \x01(\tR\x05orgId\x12%\n" + - "\x0echain_selector\x18\a \x01(\x04R\rchainSelector\x12\x17\n" + - "\atx_hash\x18\b \x01(\tR\x06txHash\x12#\n" + - "\rcapability_id\x18\t \x01(\tR\fcapabilityId\x12E\n" + - "\ttx_status\x18\n" + - " \x01(\x0e2(.chain_capabilities.txevents.v1.TxStatusR\btxStatus\x12W\n" + - "\x0freceiver_status\x18\v \x01(\x0e2..chain_capabilities.txevents.v1.ReceiverStatusR\x0ereceiverStatus\x12'\n" + - "\x0ftransaction_fee\x18\f \x01(\tR\x0etransactionFee\x12!\n" + - "\fblock_number\x18\r \x01(\x04R\vblockNumber\x12#\n" + - "\rerror_message\x18\x0e \x01(\tR\ferrorMessage\x12$\n" + - "\x0eobserved_at_ms\x18\x0f \x01(\x03R\fobservedAtMs\x12 \n" + - "\vtransmitter\x18\x10 \x01(\bR\vtransmitter\x12\x1c\n" + - "\tduplicate\x18\x11 \x01(\bR\tduplicate\x12@\n" + - "\x06detail\x18\x12 \x01(\x0e2(.chain_capabilities.txevents.v1.TxDetailR\x06detail\x12\x19\n" + - "\bgas_used\x18\x13 \x01(\x04R\agasUsed\x12.\n" + - "\x13effective_gas_price\x18\x14 \x01(\tR\x11effectiveGasPrice*Q\n" + - "\bTxDetail\x12\x19\n" + - "\x15TX_DETAIL_UNSPECIFIED\x10\x00\x12\x13\n" + - "\x0fTX_DETAIL_BASIC\x10\x01\x12\x15\n" + - "\x11TX_DETAIL_RECEIPT\x10\x02*T\n" + + "\x0echain_selector\x18\x03 \x01(\x04R\rchainSelector\x12\x17\n" + + "\atx_hash\x18\x04 \x01(\tR\x06txHash\x12E\n" + + "\ttx_status\x18\x05 \x01(\x0e2(.chain_capabilities.txevents.v1.TxStatusR\btxStatus\x12$\n" + + "\x0eobserved_at_ms\x18\x06 \x01(\x03R\fobservedAtMs*T\n" + "\bTxStatus\x12\x19\n" + "\x15TX_STATUS_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11TX_STATUS_SUCCESS\x10\x01\x12\x16\n" + - "\x12TX_STATUS_REVERTED\x10\x02*l\n" + - "\x0eReceiverStatus\x12\x1f\n" + - "\x1bRECEIVER_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + - "\x17RECEIVER_STATUS_SUCCESS\x10\x01\x12\x1c\n" + - "\x18RECEIVER_STATUS_REVERTED\x10\x02BSZQgithub.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txeventsb\x06proto3" + "\x12TX_STATUS_REVERTED\x10\x02BSZQgithub.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txeventsb\x06proto3" var ( file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescOnce sync.Once @@ -457,23 +200,19 @@ func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDe return file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDescData } -var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_goTypes = []any{ - (TxDetail)(0), // 0: chain_capabilities.txevents.v1.TxDetail - (TxStatus)(0), // 1: chain_capabilities.txevents.v1.TxStatus - (ReceiverStatus)(0), // 2: chain_capabilities.txevents.v1.ReceiverStatus - (*ChainTransactionObserved)(nil), // 3: chain_capabilities.txevents.v1.ChainTransactionObserved + (TxStatus)(0), // 0: chain_capabilities.txevents.v1.TxStatus + (*ChainTransactionObserved)(nil), // 1: chain_capabilities.txevents.v1.ChainTransactionObserved } var file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_depIdxs = []int32{ - 1, // 0: chain_capabilities.txevents.v1.ChainTransactionObserved.tx_status:type_name -> chain_capabilities.txevents.v1.TxStatus - 2, // 1: chain_capabilities.txevents.v1.ChainTransactionObserved.receiver_status:type_name -> chain_capabilities.txevents.v1.ReceiverStatus - 0, // 2: chain_capabilities.txevents.v1.ChainTransactionObserved.detail:type_name -> chain_capabilities.txevents.v1.TxDetail - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 0, // 0: chain_capabilities.txevents.v1.ChainTransactionObserved.tx_status:type_name -> chain_capabilities.txevents.v1.TxStatus + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init() } @@ -486,7 +225,7 @@ func file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_init( File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc), len(file_chain_capabilities_evm_txevents_chain_transaction_observed_proto_rawDesc)), - NumEnums: 3, + NumEnums: 1, NumMessages: 1, NumExtensions: 0, NumServices: 0, diff --git a/chain_capabilities/evm/txevents/chain_transaction_observed.proto b/chain_capabilities/evm/txevents/chain_transaction_observed.proto index 03bc7f843..b1bc5e7b3 100644 --- a/chain_capabilities/evm/txevents/chain_transaction_observed.proto +++ b/chain_capabilities/evm/txevents/chain_transaction_observed.proto @@ -1,89 +1,35 @@ syntax = "proto3"; -// Package txevents defines the chain-agnostic transaction event every chain capability emits -// for the CRE Transactions Viewer. The package name is intentionally free of the chain family so -// Aptos, Solana and Stellar can emit the same schema; the file lives in the EVM module only until -// the ChIP domain and schema registration are settled with Data Platform (PLEX-3409). +// Package txevents defines the transaction event every chain capability emits for the CRE +// Transactions Viewer. The package name is intentionally free of the chain family so Aptos, +// Solana and Stellar can emit the same schema; the file lives in the EVM module only until the +// ChIP domain and schema registration are settled with Data Platform (PLEX-3409). package chain_capabilities.txevents.v1; option go_package = "github.com/smartcontractkit/capabilities/chain_capabilities/evm/txevents;txevents"; -// TxDetail says how much the emitting node learnt about the transaction. A node emits exactly -// one event per transaction it saw land while serving a request: RECEIPT once it resolved the -// receipt, BASIC otherwise (the receipt lookup failed, the request failed after the hash was -// known, or the transaction is the node's own duplicate, whose receipt is not fetched). -enum TxDetail { - TX_DETAIL_UNSPECIFIED = 0; - // Hash, attribution and the statuses known from the transaction manager or the forwarder. - // Receipt-derived fields (gas, fee) are not set. - TX_DETAIL_BASIC = 1; - // Receipt-derived status, gas used, effective gas price, fee and block number are set. - TX_DETAIL_RECEIPT = 2; -} - -// TxStatus is the on-chain outcome of the transaction itself, as reported by the transaction -// manager or the forwarder log at BASIC detail and by the receipt at RECEIPT detail. +// TxStatus is the on-chain outcome of the transaction as known at the moment it was observed: +// from the transaction manager for the node's own transaction, or implied by the forwarder's +// ReportProcessed log (which only exists for successful transactions) for transactions sent by +// other nodes. Consumers fetch the receipt for anything beyond that. enum TxStatus { - // Not known when the event was emitted. Consumers fetch the receipt by hash. TX_STATUS_UNSPECIFIED = 0; TX_STATUS_SUCCESS = 1; TX_STATUS_REVERTED = 2; } -// ReceiverStatus is the outcome of the receiver (user) contract call made by the forwarder. A -// transaction can succeed on chain while the receiver call reverted ("internally reverted"). -enum ReceiverStatus { - RECEIVER_STATUS_UNSPECIFIED = 0; - RECEIVER_STATUS_SUCCESS = 1; - RECEIVER_STATUS_REVERTED = 2; -} - -// ChainTransactionObserved records one transaction a chain capability saw land on chain while -// serving a WriteReport, including reverted and duplicate ones. Each node emits it once per -// transaction, see TxDetail. The Blockchain Finality Service dedups events by -// (chain_selector, tx_hash), fetches the receipt and tracks finality, so the body is kept -// minimal: no report payload, signatures or request data are included. +// ChainTransactionObserved is emitted by a chain capability as soon as it learns that a +// transaction belonging to a workflow execution was mined, including reverted and duplicate +// ones. It carries only what is needed to attribute the transaction to the execution; the +// Blockchain Finality Service dedups events by (chain_selector, tx_hash) and resolves the +// receipt, fee and finality itself. message ChainTransactionObserved { - // Attribution, copied from the capability request metadata. string workflow_execution_id = 1; string workflow_id = 2; - string workflow_owner = 3; - // Workflow step reference that issued the write. - string reference_id = 4; - uint32 workflow_don_id = 5; - // Informational only. Consumers must resolve the organization from the execution record. - string org_id = 6; - - // Transaction identity. - uint64 chain_selector = 7; + uint64 chain_selector = 3; // Chain-native encoding. EVM: 0x-prefixed lowercase hex. - string tx_hash = 8; - string capability_id = 9; - - // Outcome as observed by the emitting node. - TxStatus tx_status = 10; - ReceiverStatus receiver_status = 11; - // Total cost in the chain's base unit (wei) as a decimal string, including the L1 data fee on - // rollups when the capability accounts for it. Empty when unknown. - string transaction_fee = 12; - // 0 when unknown. - uint64 block_number = 13; - // User-facing revert reason, when the receiver reverted. - string error_message = 14; - - // Emission context. - // Unix milliseconds at which the node emitted the event. - int64 observed_at_ms = 15; - // True when the emitting node sent this transaction. - bool transmitter = 16; - // True when this transaction reverted because another node's transmission of the same report - // had already landed. Such transactions still cost gas and must be visible to the user. - bool duplicate = 17; - - // How much the node learnt about the transaction; see TxDetail. - TxDetail detail = 18; - // Gas spent, from the receipt. 0 when unknown. - uint64 gas_used = 19; - // Price paid per gas unit in wei as a decimal string, from the receipt. Empty when unknown. - string effective_gas_price = 20; + string tx_hash = 4; + TxStatus tx_status = 5; + // Unix milliseconds at which the node observed the transaction. + int64 observed_at_ms = 6; } diff --git a/chain_capabilities/evm/txevents/emitter.go b/chain_capabilities/evm/txevents/emitter.go index d6c4a715d..78026edf2 100644 --- a/chain_capabilities/evm/txevents/emitter.go +++ b/chain_capabilities/evm/txevents/emitter.go @@ -13,6 +13,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/durableemitter" "github.com/smartcontractkit/chainlink-common/pkg/logger" ) @@ -39,18 +40,42 @@ func EntityType() string { return string(proto.MessageName(&ChainTransactionObserved{})) } -// ErrEmitterUnavailable is returned when the LOOP has no durable emitter, which is the case when -// the node runs without CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED. Events are dropped and counted. +// ErrEmitterUnavailable is returned when the process has no durable emitter, which is the case +// when the node runs without CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED. Events are dropped and +// counted. var ErrEmitterUnavailable = errors.New("durable emitter not available in this process") -// Sink delivers an encoded event with its routing attributes. chainlink-common's -// durableemitter.DurableEmitter satisfies it; the LOOP server injects that instance through -// resourcemanager.Config (loop.Server.MeteringConfig) so capabilities never reach for the -// process-global emitter. +// Sink delivers an encoded event with its routing attributes. Production uses GlobalSink; tests +// substitute a fake. type Sink interface { Emit(ctx context.Context, body []byte, attrKVs ...any) error } +// SinkFunc adapts a function to Sink. +type SinkFunc func(ctx context.Context, body []byte, attrKVs ...any) error + +// Emit implements Sink. +func (f SinkFunc) Emit(ctx context.Context, body []byte, attrKVs ...any) error { + return f(ctx, body, attrKVs...) +} + +// GlobalSink delivers events through the process-global durable emitter, the same path the +// platform uses for its own ChIP events (workflow trigger events, relayer config). In a LOOP +// that emitter is set up by loop.Server when the node runs with +// CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED; it persists events in the node database and the host +// delivers them to ChIP Ingress at least once. The emitter is resolved on every call, so the +// sink can be built before the LOOP has finished starting. Without a durable emitter it returns +// ErrEmitterUnavailable. +func GlobalSink() Sink { + return SinkFunc(func(ctx context.Context, body []byte, attrKVs ...any) error { + err := durableemitter.GlobalEmit(ctx, body, attrKVs...) + if errors.Is(err, durableemitter.ErrNotInitialized) { + return ErrEmitterUnavailable + } + return err + }) +} + // Emitter publishes ChainTransactionObserved events. type Emitter interface { Emit(ctx context.Context, event *ChainTransactionObserved) error @@ -62,17 +87,10 @@ type NopEmitter struct{} // Emit implements Emitter. func (NopEmitter) Emit(context.Context, *ChainTransactionObserved) error { return nil } -// IdempotencyKey identifies a transaction at a detail level independently of the node that -// observed it, so that every node that learnt the same about a transaction produces the same key -// and ChIP or the Blockchain Finality Service can dedup on it. A node whose receipt lookup failed -// produces a different key than its peers, so its basic event does not shadow their receipt -// detail; the finality service dedups by (chain selector, hash) regardless. -func IdempotencyKey(ev *ChainTransactionObserved) string { - return fmt.Sprintf("%d:%s:%s", ev.GetChainSelector(), ev.GetTxHash(), ev.GetDetail().String()) -} - -// Subject identifies the transaction itself and is set as the CloudEvent subject. -func Subject(ev *ChainTransactionObserved) string { +// Key identifies the transaction independently of the node that observed it. Every node that +// saw the same transaction produces the same key, so ChIP and the Blockchain Finality Service +// can dedup on it. It is used both as the CloudEvent subject and as the idempotency key. +func Key(ev *ChainTransactionObserved) string { return fmt.Sprintf("%d:%s", ev.GetChainSelector(), ev.GetTxHash()) } @@ -81,18 +99,14 @@ func LogKVs(ev *ChainTransactionObserved) []any { return []any{ "txHash", ev.GetTxHash(), "chainSelector", ev.GetChainSelector(), - "detail", ev.GetDetail().String(), "txStatus", ev.GetTxStatus().String(), - "receiverStatus", ev.GetReceiverStatus().String(), - "transmitter", ev.GetTransmitter(), - "duplicate", ev.GetDuplicate(), "workflowExecutionID", ev.GetWorkflowExecutionId(), - "referenceID", ev.GetReferenceId(), + "workflowID", ev.GetWorkflowId(), } } -// DurableEmitter publishes events through the LOOP's durable emitter, which persists them in the -// node database and delivers them to ChIP Ingress at least once. +// DurableEmitter encodes events as ChIP CloudEvents and hands them to a Sink, counting the +// outcome. type DurableEmitter struct { lggr logger.Logger domain string @@ -103,9 +117,10 @@ type DurableEmitter struct { } // NewDurableEmitter returns an emitter publishing to the given ChIP source domain through sink. -// A nil sink means the LOOP has no durable emitter: events are dropped, counted and the -// condition is logged once. func NewDurableEmitter(lggr logger.Logger, domain string, sink Sink) (*DurableEmitter, error) { + if sink == nil { + return nil, errors.New("nil tx events sink") + } if domain == "" { domain = DefaultDomain } @@ -121,18 +136,12 @@ func NewDurableEmitter(lggr logger.Logger, domain string, sink Sink) (*DurableEm }, nil } -// Emit implements Emitter. +// Emit implements Emitter. When the sink reports ErrEmitterUnavailable the event is counted as +// dropped and the condition is logged once per process. func (d *DurableEmitter) Emit(ctx context.Context, ev *ChainTransactionObserved) error { if ev == nil { return errors.New("nil ChainTransactionObserved") } - if d.sink == nil { - d.metrics.recordError(ctx, ev, "unavailable") - if d.unavailableLogged.CompareAndSwap(false, true) { - d.lggr.Warnw("Transaction events are enabled but this LOOP has no durable emitter; events are dropped", "domain", d.domain) - } - return ErrEmitterUnavailable - } body, err := proto.Marshal(ev) if err != nil { d.metrics.recordError(ctx, ev, "marshal") @@ -141,13 +150,21 @@ func (d *DurableEmitter) Emit(ctx context.Context, ev *ChainTransactionObserved) emitCtx, cancel := context.WithTimeout(ctx, emitTimeout) defer cancel() + key := Key(ev) err = d.sink.Emit(emitCtx, body, attrSource, d.domain, attrType, EntityType(), - attrIdempotencyKey, IdempotencyKey(ev), - attrSubject, Subject(ev), + attrIdempotencyKey, key, + attrSubject, key, ) - if err != nil { + switch { + case errors.Is(err, ErrEmitterUnavailable): + d.metrics.recordError(ctx, ev, "unavailable") + if d.unavailableLogged.CompareAndSwap(false, true) { + d.lggr.Warnw("Transaction events are enabled but this process has no durable emitter; events are dropped", "domain", d.domain) + } + return ErrEmitterUnavailable + case err != nil: d.metrics.recordError(ctx, ev, "emit") return fmt.Errorf("emit ChainTransactionObserved: %w", err) } @@ -187,10 +204,6 @@ func (m *emitterMetrics) recordError(ctx context.Context, ev *ChainTransactionOb func eventAttrs(ev *ChainTransactionObserved) []attribute.KeyValue { return []attribute.KeyValue{ attribute.String("chain_selector", strconv.FormatUint(ev.GetChainSelector(), 10)), - attribute.String("detail", ev.GetDetail().String()), attribute.String("tx_status", ev.GetTxStatus().String()), - attribute.String("receiver_status", ev.GetReceiverStatus().String()), - attribute.Bool("transmitter", ev.GetTransmitter()), - attribute.Bool("duplicate", ev.GetDuplicate()), } } diff --git a/chain_capabilities/evm/txevents/emitter_test.go b/chain_capabilities/evm/txevents/emitter_test.go index 87aa8c498..d93ab9aa3 100644 --- a/chain_capabilities/evm/txevents/emitter_test.go +++ b/chain_capabilities/evm/txevents/emitter_test.go @@ -8,19 +8,18 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "github.com/smartcontractkit/chainlink-common/pkg/durableemitter" "github.com/smartcontractkit/chainlink-common/pkg/logger" ) func testEvent() *ChainTransactionObserved { return &ChainTransactionObserved{ WorkflowExecutionId: "exec-1", + WorkflowId: "wf-1", ChainSelector: 16015286601757825753, TxHash: "0xabc", - Detail: TxDetail_TX_DETAIL_RECEIPT, TxStatus: TxStatus_TX_STATUS_REVERTED, - ReceiverStatus: ReceiverStatus_RECEIVER_STATUS_UNSPECIFIED, - Transmitter: true, - Duplicate: true, + ObservedAtMs: 1, } } @@ -28,34 +27,31 @@ func TestEntityType(t *testing.T) { require.Equal(t, "chain_capabilities.txevents.v1.ChainTransactionObserved", EntityType()) } -func TestIdempotencyKeyAndSubject(t *testing.T) { +func TestKey(t *testing.T) { ev := testEvent() - require.Equal(t, "16015286601757825753:0xabc:TX_DETAIL_RECEIPT", IdempotencyKey(ev)) - require.Equal(t, "16015286601757825753:0xabc", Subject(ev)) + require.Equal(t, "16015286601757825753:0xabc", Key(ev)) - // The key does not depend on who observed the transaction or on the values it reported. + // The key depends only on the transaction, not on who observed it, when, or what it reported. other := proto.Clone(ev).(*ChainTransactionObserved) - other.Transmitter = false - other.Duplicate = false other.ObservedAtMs = 42 other.TxStatus = TxStatus_TX_STATUS_SUCCESS - other.TransactionFee = "2000" - require.Equal(t, IdempotencyKey(ev), IdempotencyKey(other)) + other.WorkflowExecutionId = "exec-2" + require.Equal(t, Key(ev), Key(other)) - // A basic event from a node that could not resolve the receipt is a distinct delivery. - other.Detail = TxDetail_TX_DETAIL_BASIC - require.NotEqual(t, IdempotencyKey(ev), IdempotencyKey(other)) - require.Equal(t, Subject(ev), Subject(other)) + other.TxHash = "0xdef" + require.NotEqual(t, Key(ev), Key(other)) } -type sinkFunc func(ctx context.Context, body []byte, attrKVs ...any) error - -func (f sinkFunc) Emit(ctx context.Context, body []byte, attrKVs ...any) error { - return f(ctx, body, attrKVs...) +func TestGlobalSink(t *testing.T) { + // The test process never sets up a durable emitter, which is exactly the situation of a node + // running without CL_CHIP_INGRESS_DURABLE_EMITTER_ENABLED. + require.Nil(t, durableemitter.GetGlobalEmitter()) + err := GlobalSink().Emit(t.Context(), []byte("body"), "source", DefaultDomain) + require.ErrorIs(t, err, ErrEmitterUnavailable) } func TestDurableEmitter_Emit(t *testing.T) { - newEmitter := func(t *testing.T, emit sinkFunc) *DurableEmitter { + newEmitter := func(t *testing.T, emit SinkFunc) *DurableEmitter { t.Helper() d, err := NewDurableEmitter(logger.Test(t), "", emit) require.NoError(t, err) @@ -63,6 +59,11 @@ func TestDurableEmitter_Emit(t *testing.T) { return d } + t.Run("requires a sink", func(t *testing.T) { + _, err := NewDurableEmitter(logger.Test(t), "", nil) + require.Error(t, err) + }) + t.Run("passes source, type, keys and the marshalled body", func(t *testing.T) { var gotBody []byte var gotAttrs []any @@ -79,8 +80,8 @@ func TestDurableEmitter_Emit(t *testing.T) { require.Equal(t, []any{ "source", DefaultDomain, "type", EntityType(), - "idempotencykey", IdempotencyKey(ev), - "subject", Subject(ev), + "idempotencykey", Key(ev), + "subject", Key(ev), }, gotAttrs) }) @@ -93,8 +94,8 @@ func TestDurableEmitter_Emit(t *testing.T) { require.NoError(t, d.Emit(t.Context(), testEvent())) }) - t.Run("reports ErrEmitterUnavailable when the LOOP has no durable emitter", func(t *testing.T) { - d, err := NewDurableEmitter(logger.Test(t), "custom-domain", nil) + t.Run("reports ErrEmitterUnavailable when the process has no durable emitter", func(t *testing.T) { + d, err := NewDurableEmitter(logger.Test(t), "custom-domain", GlobalSink()) require.NoError(t, err) require.Equal(t, "custom-domain", d.domain) require.ErrorIs(t, d.Emit(t.Context(), testEvent()), ErrEmitterUnavailable) @@ -108,6 +109,7 @@ func TestDurableEmitter_Emit(t *testing.T) { err := d.Emit(t.Context(), testEvent()) require.ErrorIs(t, err, cause) require.NotErrorIs(t, err, ErrEmitterUnavailable) + require.False(t, d.unavailableLogged.Load()) }) t.Run("rejects a nil event", func(t *testing.T) {