Skip to content

Commit

Permalink
Support Zircuit fraud transactions and zk overflow detection (#14629)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
huangzhen1997 authored Oct 4, 2024
1 parent f387696 commit 4928e60
Show file tree
Hide file tree
Showing 13 changed files with 208 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-humans-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": minor
---

Support Zircuit fraud transactions detection and zk overflow detection #added
6 changes: 5 additions & 1 deletion core/chains/evm/config/chaintype/chaintype.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -74,6 +75,8 @@ func FromSlug(slug string) ChainType {
return ChainZkEvm
case "zksync":
return ChainZkSync
case "zircuit":
return ChainZircuit
default:
return ChainType(slug)
}
Expand Down Expand Up @@ -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),
}, ", "))
2 changes: 1 addition & 1 deletion core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ChainID = '48900'
ChainType = 'optimismBedrock'
ChainType = 'zircuit'
FinalityTagEnabled = true
FinalityDepth = 1000
LinkContractAddress = '0x5D6d033B4FbD2190D99D930719fAbAcB64d2439a'
Expand Down
2 changes: 1 addition & 1 deletion core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ChainID = '48899'
ChainType = 'optimismBedrock'
ChainType = 'zircuit'
FinalityTagEnabled = true
FinalityDepth = 1000
LinkContractAddress = '0xDEE94506570cA186BC1e3516fCf4fd719C312cCD'
Expand Down
2 changes: 1 addition & 1 deletion core/chains/evm/gas/chain_specific.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/chains/evm/gas/rollups/l1_oracle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion core/chains/evm/gas/rollups/op_l1_oracle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 85 additions & 1 deletion core/chains/evm/txmgr/stuck_tx_detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand Down
102 changes: 102 additions & 0 deletions core/chains/evm/txmgr/stuck_tx_detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions core/services/chainlink/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion core/services/ocr/contract_tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion core/services/ocrcommon/block_translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
4 changes: 2 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6752,7 +6752,7 @@ GasLimitDefault = 400000
AutoCreateKey = true
BlockBackfillDepth = 10
BlockBackfillSkip = false
ChainType = 'optimismBedrock'
ChainType = 'zircuit'
FinalityDepth = 1000
FinalityTagEnabled = true
LinkContractAddress = '0xDEE94506570cA186BC1e3516fCf4fd719C312cCD'
Expand Down Expand Up @@ -6859,7 +6859,7 @@ GasLimitDefault = 400000
AutoCreateKey = true
BlockBackfillDepth = 10
BlockBackfillSkip = false
ChainType = 'optimismBedrock'
ChainType = 'zircuit'
FinalityDepth = 1000
FinalityTagEnabled = true
LinkContractAddress = '0x5D6d033B4FbD2190D99D930719fAbAcB64d2439a'
Expand Down

0 comments on commit 4928e60

Please sign in to comment.