From 4928e60ddfe375e4a0c644cb210802b4c4db5dbd Mon Sep 17 00:00:00 2001 From: Joe Huang Date: Fri, 4 Oct 2024 09:51:50 -0400 Subject: [PATCH] Support Zircuit fraud transactions and zk overflow detection (#14629) * Support Zircuit fraud transactions detection and zk overflow detection, need dedup and unit test * add dedup * fix test * add unit tests * rm * update chaintype * update for testnet chainType * address comments * update log level --- .changeset/orange-humans-laugh.md | 5 + core/chains/evm/config/chaintype/chaintype.go | 6 +- .../config/toml/defaults/Zircuit_Mainnet.toml | 2 +- .../config/toml/defaults/Zircuit_Sepolia.toml | 2 +- core/chains/evm/gas/chain_specific.go | 2 +- core/chains/evm/gas/rollups/l1_oracle.go | 2 +- core/chains/evm/gas/rollups/op_l1_oracle.go | 2 +- core/chains/evm/txmgr/stuck_tx_detector.go | 86 ++++++++++++++- .../evm/txmgr/stuck_tx_detector_test.go | 102 ++++++++++++++++++ core/services/chainlink/config_test.go | 4 +- core/services/ocr/contract_tracker.go | 2 +- core/services/ocrcommon/block_translator.go | 2 +- docs/CONFIG.md | 4 +- 13 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 .changeset/orange-humans-laugh.md diff --git a/.changeset/orange-humans-laugh.md b/.changeset/orange-humans-laugh.md new file mode 100644 index 00000000000..b9f8fa74f54 --- /dev/null +++ b/.changeset/orange-humans-laugh.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +Support Zircuit fraud transactions detection and zk overflow detection #added diff --git a/core/chains/evm/config/chaintype/chaintype.go b/core/chains/evm/config/chaintype/chaintype.go index f6b84e46555..b2eff02834b 100644 --- a/core/chains/evm/config/chaintype/chaintype.go +++ b/core/chains/evm/config/chaintype/chaintype.go @@ -22,6 +22,7 @@ const ( ChainXLayer ChainType = "xlayer" ChainZkEvm ChainType = "zkevm" ChainZkSync ChainType = "zksync" + ChainZircuit ChainType = "zircuit" ) // IsL2 returns true if this chain is a Layer 2 chain. Notably: @@ -38,7 +39,7 @@ func (c ChainType) IsL2() bool { func (c ChainType) IsValid() bool { switch c { - case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync: + case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync, ChainZircuit: return true } return false @@ -74,6 +75,8 @@ func FromSlug(slug string) ChainType { return ChainZkEvm case "zksync": return ChainZkSync + case "zircuit": + return ChainZircuit default: return ChainType(slug) } @@ -140,4 +143,5 @@ var ErrInvalid = fmt.Errorf("must be one of %s or omitted", strings.Join([]strin string(ChainXLayer), string(ChainZkEvm), string(ChainZkSync), + string(ChainZircuit), }, ", ")) diff --git a/core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml b/core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml index ec336d3efed..885166fe8eb 100644 --- a/core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml +++ b/core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml @@ -1,5 +1,5 @@ ChainID = '48900' -ChainType = 'optimismBedrock' +ChainType = 'zircuit' FinalityTagEnabled = true FinalityDepth = 1000 LinkContractAddress = '0x5D6d033B4FbD2190D99D930719fAbAcB64d2439a' diff --git a/core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml b/core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml index d6934d533dc..40493a9dab3 100644 --- a/core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml +++ b/core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml @@ -1,5 +1,5 @@ ChainID = '48899' -ChainType = 'optimismBedrock' +ChainType = 'zircuit' FinalityTagEnabled = true FinalityDepth = 1000 LinkContractAddress = '0xDEE94506570cA186BC1e3516fCf4fd719C312cCD' diff --git a/core/chains/evm/gas/chain_specific.go b/core/chains/evm/gas/chain_specific.go index 4be7d1972f8..d1add00758b 100644 --- a/core/chains/evm/gas/chain_specific.go +++ b/core/chains/evm/gas/chain_specific.go @@ -19,7 +19,7 @@ func chainSpecificIsUsable(tx evmtypes.Transaction, baseFee *assets.Wei, chainTy return false } } - if chainType == chaintype.ChainOptimismBedrock || chainType == chaintype.ChainKroma || chainType == chaintype.ChainScroll { + if chainType == chaintype.ChainOptimismBedrock || chainType == chaintype.ChainKroma || chainType == chaintype.ChainScroll || chainType == chaintype.ChainZircuit { // This is a special deposit transaction type introduced in Bedrock upgrade. // This is a system transaction that it will occur at least one time per block. // We should discard this type before even processing it to avoid flooding the diff --git a/core/chains/evm/gas/rollups/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go index 772dc542ed1..07cecac3cb9 100644 --- a/core/chains/evm/gas/rollups/l1_oracle.go +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -54,7 +54,7 @@ func NewL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chai var l1Oracle L1Oracle var err error switch chainType { - case chaintype.ChainOptimismBedrock, chaintype.ChainKroma, chaintype.ChainScroll, chaintype.ChainMantle: + case chaintype.ChainOptimismBedrock, chaintype.ChainKroma, chaintype.ChainScroll, chaintype.ChainMantle, chaintype.ChainZircuit: l1Oracle, err = NewOpStackL1GasOracle(lggr, ethClient, chainType) case chaintype.ChainArbitrum: l1Oracle, err = NewArbitrumL1GasOracle(lggr, ethClient) diff --git a/core/chains/evm/gas/rollups/op_l1_oracle.go b/core/chains/evm/gas/rollups/op_l1_oracle.go index 9aa20f4f880..6984fe1cd60 100644 --- a/core/chains/evm/gas/rollups/op_l1_oracle.go +++ b/core/chains/evm/gas/rollups/op_l1_oracle.go @@ -95,7 +95,7 @@ const ( func NewOpStackL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chaintype.ChainType) (*optimismL1Oracle, error) { var precompileAddress string switch chainType { - case chaintype.ChainOptimismBedrock, chaintype.ChainMantle: + case chaintype.ChainOptimismBedrock, chaintype.ChainMantle, chaintype.ChainZircuit: precompileAddress = OPGasOracleAddress case chaintype.ChainKroma: precompileAddress = KromaGasOracleAddress diff --git a/core/chains/evm/txmgr/stuck_tx_detector.go b/core/chains/evm/txmgr/stuck_tx_detector.go index 26d7643c15a..962be8afead 100644 --- a/core/chains/evm/txmgr/stuck_tx_detector.go +++ b/core/chains/evm/txmgr/stuck_tx_detector.go @@ -130,6 +130,8 @@ func (d *stuckTxDetector) DetectStuckTransactions(ctx context.Context, enabledAd return d.detectStuckTransactionsScroll(ctx, txs) case chaintype.ChainZkEvm, chaintype.ChainXLayer: return d.detectStuckTransactionsZkEVM(ctx, txs) + case chaintype.ChainZircuit: + return d.detectStuckTransactionsZircuit(ctx, txs, blockNum) default: return d.detectStuckTransactionsHeuristic(ctx, txs, blockNum) } @@ -270,6 +272,10 @@ type scrollResponse struct { Data map[string]int `json:"data"` } +type zircuitResponse struct { + IsQuarantined bool `json:"isQuarantined"` +} + // Uses the custom Scroll skipped endpoint to determine an overflow transaction func (d *stuckTxDetector) detectStuckTransactionsScroll(ctx context.Context, txs []Tx) ([]Tx, error) { if d.cfg.DetectionApiUrl() == nil { @@ -336,6 +342,84 @@ func (d *stuckTxDetector) detectStuckTransactionsScroll(ctx context.Context, txs return stuckTx, nil } +// return fraud and overflow transactions +func (d *stuckTxDetector) detectStuckTransactionsZircuit(ctx context.Context, txs []Tx, blockNum int64) ([]Tx, error) { + var err error + var fraudTxs, stuckTxs []Tx + fraudTxs, err = d.detectFraudTransactionsZircuit(ctx, txs) + if err != nil { + d.lggr.Errorf("Failed to detect zircuit fraud transactions: %v", err) + } + + stuckTxs, err = d.detectStuckTransactionsHeuristic(ctx, txs, blockNum) + if err != nil { + return txs, err + } + + // prevent duplicate transactions from the fraudTxs and stuckTxs with a map + uniqueTxs := make(map[int64]Tx) + for _, tx := range fraudTxs { + uniqueTxs[tx.ID] = tx + } + + for _, tx := range stuckTxs { + uniqueTxs[tx.ID] = tx + } + + var combinedStuckTxs []Tx + for _, tx := range uniqueTxs { + combinedStuckTxs = append(combinedStuckTxs, tx) + } + + return combinedStuckTxs, nil +} + +// Uses zirc_isQuarantined to check whether the transactions are considered as malicious by the sequencer and +// preventing their inclusion into a block +func (d *stuckTxDetector) detectFraudTransactionsZircuit(ctx context.Context, txs []Tx) ([]Tx, error) { + txReqs := make([]rpc.BatchElem, len(txs)) + txHashMap := make(map[common.Hash]Tx) + txRes := make([]*zircuitResponse, len(txs)) + + // Build batch request elems to perform + for i, tx := range txs { + latestAttemptHash := tx.TxAttempts[0].Hash + var result zircuitResponse + txReqs[i] = rpc.BatchElem{ + Method: "zirc_isQuarantined", + Args: []interface{}{ + latestAttemptHash, + }, + Result: &result, + } + txHashMap[latestAttemptHash] = tx + txRes[i] = &result + } + + // Send batch request + err := d.chainClient.BatchCallContext(ctx, txReqs) + if err != nil { + return nil, fmt.Errorf("failed to check Quarantine transactions in batch: %w", err) + } + + // If the result is not nil, the fraud transaction is flagged as quarantined + var fraudTxs []Tx + for i, req := range txReqs { + txHash := req.Args[0].(common.Hash) + if req.Error != nil { + d.lggr.Errorf("failed to check fraud transaction by hash (%s): %v", txHash.String(), req.Error) + continue + } + + result := txRes[i] + if result != nil && result.IsQuarantined { + tx := txHashMap[txHash] + fraudTxs = append(fraudTxs, tx) + } + } + return fraudTxs, nil +} + // Uses eth_getTransactionByHash to detect that a transaction has been discarded due to overflow // Currently only used by zkEVM but if other chains follow the same behavior in the future func (d *stuckTxDetector) detectStuckTransactionsZkEVM(ctx context.Context, txs []Tx) ([]Tx, error) { @@ -390,7 +474,7 @@ func (d *stuckTxDetector) detectStuckTransactionsZkEVM(ctx context.Context, txs for i, req := range txReqs { txHash := req.Args[0].(common.Hash) if req.Error != nil { - d.lggr.Debugf("failed to get transaction by hash (%s): %v", txHash.String(), req.Error) + d.lggr.Errorf("failed to get transaction by hash (%s): %v", txHash.String(), req.Error) continue } result := *txRes[i] diff --git a/core/chains/evm/txmgr/stuck_tx_detector_test.go b/core/chains/evm/txmgr/stuck_tx_detector_test.go index eb22830ef35..d87e13059b3 100644 --- a/core/chains/evm/txmgr/stuck_tx_detector_test.go +++ b/core/chains/evm/txmgr/stuck_tx_detector_test.go @@ -280,6 +280,108 @@ func TestStuckTxDetector_DetectStuckTransactionsHeuristic(t *testing.T) { }) } +func TestStuckTxDetector_DetectStuckTransactionsZircuit(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + txStore := cltest.NewTestTxStore(t, db) + ethKeyStore := cltest.NewKeyStore(t, db).Eth() + ctx := tests.Context(t) + + lggr := logger.Test(t) + feeEstimator := gasmocks.NewEvmFeeEstimator(t) + // Return 10 gwei as market gas price + marketGasPrice := tenGwei + fee := gas.EvmFee{Legacy: marketGasPrice} + feeEstimator.On("GetFee", mock.Anything, []byte{}, uint64(0), mock.Anything, mock.Anything, mock.Anything).Return(fee, uint64(0), nil) + ethClient := testutils.NewEthClientMockWithDefaultChain(t) + autoPurgeThreshold := uint32(5) + autoPurgeMinAttempts := uint32(3) + autoPurgeCfg := testAutoPurgeConfig{ + enabled: true, // Enable auto-purge feature for testing + threshold: &autoPurgeThreshold, + minAttempts: &autoPurgeMinAttempts, + } + blockNum := int64(100) + stuckTxDetector := txmgr.NewStuckTxDetector(lggr, testutils.FixtureChainID, chaintype.ChainZircuit, assets.NewWei(assets.NewEth(100).ToInt()), autoPurgeCfg, feeEstimator, txStore, ethClient) + + t.Run("returns empty list if no fraud or stuck transactions identified", func(t *testing.T) { + _, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore) + tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, 1, blockNum, tenGwei) + attempts := tx.TxAttempts[0] + // Request still returns transaction by hash, transaction not discarded by network and not considered stuck + ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool { + return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined") + })).Return(nil).Run(func(args mock.Arguments) { + elems := args.Get(1).([]rpc.BatchElem) + resp, err := json.Marshal(struct { + IsQuarantined bool `json:"isQuarantined"` + }{IsQuarantined: false}) + require.NoError(t, err) + elems[0].Error = json.Unmarshal(resp, elems[0].Result) + }).Once() + + txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum) + require.NoError(t, err) + require.Len(t, txs, 0) + }) + + t.Run("returns fraud transactions identified", func(t *testing.T) { + _, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore) + tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, 1, blockNum, tenGwei) + attempts := tx.TxAttempts[0] + // Request still returns transaction by hash, transaction not discarded by network and not considered stuck + ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool { + return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined") + })).Return(nil).Run(func(args mock.Arguments) { + elems := args.Get(1).([]rpc.BatchElem) + resp, err := json.Marshal(struct { + IsQuarantined bool `json:"isQuarantined"` + }{IsQuarantined: true}) + require.NoError(t, err) + elems[0].Error = json.Unmarshal(resp, elems[0].Result) + }).Once() + + txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum) + require.NoError(t, err) + require.Len(t, txs, 1) + }) + + t.Run("returns the transaction only once if it's identified as both fraud and stuck", func(t *testing.T) { + _, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore) + tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, autoPurgeMinAttempts, blockNum-int64(autoPurgeThreshold)+int64(autoPurgeMinAttempts-1), marketGasPrice.Add(oneGwei)) + attempts := tx.TxAttempts[0] + + ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool { + return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined") + })).Return(nil).Run(func(args mock.Arguments) { + elems := args.Get(1).([]rpc.BatchElem) + resp, err := json.Marshal(struct { + IsQuarantined bool `json:"isQuarantined"` + }{IsQuarantined: true}) + require.NoError(t, err) + elems[0].Error = json.Unmarshal(resp, elems[0].Result) + }).Once() + + txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum) + require.NoError(t, err) + require.Len(t, txs, 1) + }) + t.Run("returns the stuck tx even if failed to detect fraud tx", func(t *testing.T) { + _, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore) + tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, autoPurgeMinAttempts, blockNum-int64(autoPurgeThreshold)+int64(autoPurgeMinAttempts-1), marketGasPrice.Add(oneGwei)) + attempts := tx.TxAttempts[0] + + ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool { + return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined") + })).Return(fmt.Errorf("failed to fetch rpc")) + + txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum) + require.NoError(t, err) + require.Len(t, txs, 1) + }) +} + func TestStuckTxDetector_DetectStuckTransactionsZkEVM(t *testing.T) { t.Parallel() diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 64cc58588a8..e09fe2ef6bf 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -1364,7 +1364,7 @@ func TestConfig_Validate(t *testing.T) { - 1: 10 errors: - ChainType: invalid value (Foo): must not be set with this chain id - Nodes: missing: must have at least one node - - ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync or omitted + - ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted - HeadTracker.HistoryDepth: invalid value (30): must be greater than or equal to FinalizedBlockOffset - GasEstimator.BumpThreshold: invalid value (0): cannot be 0 if auto-purge feature is enabled for Foo - Transactions.AutoPurge.Threshold: missing: needs to be set if auto-purge feature is enabled for Foo @@ -1377,7 +1377,7 @@ func TestConfig_Validate(t *testing.T) { - 2: 5 errors: - ChainType: invalid value (Arbitrum): only "optimismBedrock" can be used with this chain id - Nodes: missing: must have at least one node - - ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync or omitted + - ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted - FinalityDepth: invalid value (0): must be greater than or equal to 1 - MinIncomingConfirmations: invalid value (0): must be greater than or equal to 1 - 3: 3 errors: diff --git a/core/services/ocr/contract_tracker.go b/core/services/ocr/contract_tracker.go index d7199874a9f..618567f0bdb 100644 --- a/core/services/ocr/contract_tracker.go +++ b/core/services/ocr/contract_tracker.go @@ -399,7 +399,7 @@ func (t *OCRContractTracker) LatestBlockHeight(ctx context.Context) (blockheight // care about the block height; we have no way of getting the L1 block // height anyway return 0, nil - case "", chaintype.ChainArbitrum, chaintype.ChainAstar, chaintype.ChainCelo, chaintype.ChainGnosis, chaintype.ChainHedera, chaintype.ChainKroma, chaintype.ChainOptimismBedrock, chaintype.ChainScroll, chaintype.ChainWeMix, chaintype.ChainXLayer, chaintype.ChainZkEvm, chaintype.ChainZkSync: + case "", chaintype.ChainArbitrum, chaintype.ChainAstar, chaintype.ChainCelo, chaintype.ChainGnosis, chaintype.ChainHedera, chaintype.ChainKroma, chaintype.ChainOptimismBedrock, chaintype.ChainScroll, chaintype.ChainWeMix, chaintype.ChainXLayer, chaintype.ChainZkEvm, chaintype.ChainZkSync, chaintype.ChainZircuit: // continue } latestBlockHeight := t.getLatestBlockHeight() diff --git a/core/services/ocrcommon/block_translator.go b/core/services/ocrcommon/block_translator.go index fa44d79c2d2..b25d617e2ab 100644 --- a/core/services/ocrcommon/block_translator.go +++ b/core/services/ocrcommon/block_translator.go @@ -22,7 +22,7 @@ func NewBlockTranslator(cfg Config, client evmclient.Client, lggr logger.Logger) switch cfg.ChainType() { case chaintype.ChainArbitrum: return NewArbitrumBlockTranslator(client, lggr) - case "", chaintype.ChainCelo, chaintype.ChainGnosis, chaintype.ChainKroma, chaintype.ChainMetis, chaintype.ChainOptimismBedrock, chaintype.ChainScroll, chaintype.ChainWeMix, chaintype.ChainXLayer, chaintype.ChainZkEvm, chaintype.ChainZkSync: + case "", chaintype.ChainCelo, chaintype.ChainGnosis, chaintype.ChainKroma, chaintype.ChainMetis, chaintype.ChainOptimismBedrock, chaintype.ChainScroll, chaintype.ChainWeMix, chaintype.ChainXLayer, chaintype.ChainZkEvm, chaintype.ChainZkSync, chaintype.ChainZircuit: fallthrough default: return &l1BlockTranslator{} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 498e2ae44f6..21d43e09165 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -6752,7 +6752,7 @@ GasLimitDefault = 400000 AutoCreateKey = true BlockBackfillDepth = 10 BlockBackfillSkip = false -ChainType = 'optimismBedrock' +ChainType = 'zircuit' FinalityDepth = 1000 FinalityTagEnabled = true LinkContractAddress = '0xDEE94506570cA186BC1e3516fCf4fd719C312cCD' @@ -6859,7 +6859,7 @@ GasLimitDefault = 400000 AutoCreateKey = true BlockBackfillDepth = 10 BlockBackfillSkip = false -ChainType = 'optimismBedrock' +ChainType = 'zircuit' FinalityDepth = 1000 FinalityTagEnabled = true LinkContractAddress = '0x5D6d033B4FbD2190D99D930719fAbAcB64d2439a'