Skip to content
This repository was archived by the owner on Mar 14, 2025. It is now read-only.

Commit f8f9513

Browse files
huangzhen1997amit-momin
authored andcommitted
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
1 parent d323f98 commit f8f9513

File tree

13 files changed

+208
-13
lines changed

13 files changed

+208
-13
lines changed

.changeset/orange-humans-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink": minor
3+
---
4+
5+
Support Zircuit fraud transactions detection and zk overflow detection #added

core/chains/evm/config/chaintype/chaintype.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323
ChainXLayer ChainType = "xlayer"
2424
ChainZkEvm ChainType = "zkevm"
2525
ChainZkSync ChainType = "zksync"
26+
ChainZircuit ChainType = "zircuit"
2627
)
2728

2829
// IsL2 returns true if this chain is a Layer 2 chain. Notably:
@@ -39,7 +40,7 @@ func (c ChainType) IsL2() bool {
3940

4041
func (c ChainType) IsValid() bool {
4142
switch c {
42-
case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainSei, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync:
43+
case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainSei, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync, ChainZircuit:
4344
return true
4445
}
4546
return false
@@ -77,6 +78,8 @@ func FromSlug(slug string) ChainType {
7778
return ChainZkEvm
7879
case "zksync":
7980
return ChainZkSync
81+
case "zircuit":
82+
return ChainZircuit
8083
default:
8184
return ChainType(slug)
8285
}
@@ -144,4 +147,5 @@ var ErrInvalid = fmt.Errorf("must be one of %s or omitted", strings.Join([]strin
144147
string(ChainXLayer),
145148
string(ChainZkEvm),
146149
string(ChainZkSync),
150+
string(ChainZircuit),
147151
}, ", "))

core/chains/evm/config/toml/defaults/Zircuit_Mainnet.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ChainID = '48900'
2-
ChainType = 'optimismBedrock'
2+
ChainType = 'zircuit'
33
FinalityTagEnabled = true
44
FinalityDepth = 1000
55
LinkContractAddress = '0x5D6d033B4FbD2190D99D930719fAbAcB64d2439a'

core/chains/evm/config/toml/defaults/Zircuit_Sepolia.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ChainID = '48899'
2-
ChainType = 'optimismBedrock'
2+
ChainType = 'zircuit'
33
FinalityTagEnabled = true
44
FinalityDepth = 1000
55
LinkContractAddress = '0xDEE94506570cA186BC1e3516fCf4fd719C312cCD'

core/chains/evm/gas/chain_specific.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func chainSpecificIsUsable(tx evmtypes.Transaction, baseFee *assets.Wei, chainTy
1919
return false
2020
}
2121
}
22-
if chainType == chaintype.ChainOptimismBedrock || chainType == chaintype.ChainKroma || chainType == chaintype.ChainScroll {
22+
if chainType == chaintype.ChainOptimismBedrock || chainType == chaintype.ChainKroma || chainType == chaintype.ChainScroll || chainType == chaintype.ChainZircuit {
2323
// This is a special deposit transaction type introduced in Bedrock upgrade.
2424
// This is a system transaction that it will occur at least one time per block.
2525
// We should discard this type before even processing it to avoid flooding the

core/chains/evm/gas/rollups/l1_oracle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func NewL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chai
5757
var l1Oracle L1Oracle
5858
var err error
5959
switch chainType {
60-
case chaintype.ChainOptimismBedrock, chaintype.ChainKroma, chaintype.ChainScroll, chaintype.ChainMantle:
60+
case chaintype.ChainOptimismBedrock, chaintype.ChainKroma, chaintype.ChainScroll, chaintype.ChainMantle, chaintype.ChainZircuit:
6161
l1Oracle, err = NewOpStackL1GasOracle(lggr, ethClient, chainType)
6262
case chaintype.ChainArbitrum:
6363
l1Oracle, err = NewArbitrumL1GasOracle(lggr, ethClient)

core/chains/evm/gas/rollups/op_l1_oracle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const (
101101
func NewOpStackL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chaintype.ChainType) (*optimismL1Oracle, error) {
102102
var precompileAddress string
103103
switch chainType {
104-
case chaintype.ChainOptimismBedrock, chaintype.ChainMantle:
104+
case chaintype.ChainOptimismBedrock, chaintype.ChainMantle, chaintype.ChainZircuit:
105105
precompileAddress = OPGasOracleAddress
106106
case chaintype.ChainKroma:
107107
precompileAddress = KromaGasOracleAddress

core/chains/evm/txmgr/stuck_tx_detector.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ func (d *stuckTxDetector) DetectStuckTransactions(ctx context.Context, enabledAd
130130
return d.detectStuckTransactionsScroll(ctx, txs)
131131
case chaintype.ChainZkEvm, chaintype.ChainXLayer:
132132
return d.detectStuckTransactionsZkEVM(ctx, txs)
133+
case chaintype.ChainZircuit:
134+
return d.detectStuckTransactionsZircuit(ctx, txs, blockNum)
133135
default:
134136
return d.detectStuckTransactionsHeuristic(ctx, txs, blockNum)
135137
}
@@ -270,6 +272,10 @@ type scrollResponse struct {
270272
Data map[string]int `json:"data"`
271273
}
272274

275+
type zircuitResponse struct {
276+
IsQuarantined bool `json:"isQuarantined"`
277+
}
278+
273279
// Uses the custom Scroll skipped endpoint to determine an overflow transaction
274280
func (d *stuckTxDetector) detectStuckTransactionsScroll(ctx context.Context, txs []Tx) ([]Tx, error) {
275281
if d.cfg.DetectionApiUrl() == nil {
@@ -336,6 +342,84 @@ func (d *stuckTxDetector) detectStuckTransactionsScroll(ctx context.Context, txs
336342
return stuckTx, nil
337343
}
338344

345+
// return fraud and overflow transactions
346+
func (d *stuckTxDetector) detectStuckTransactionsZircuit(ctx context.Context, txs []Tx, blockNum int64) ([]Tx, error) {
347+
var err error
348+
var fraudTxs, stuckTxs []Tx
349+
fraudTxs, err = d.detectFraudTransactionsZircuit(ctx, txs)
350+
if err != nil {
351+
d.lggr.Errorf("Failed to detect zircuit fraud transactions: %v", err)
352+
}
353+
354+
stuckTxs, err = d.detectStuckTransactionsHeuristic(ctx, txs, blockNum)
355+
if err != nil {
356+
return txs, err
357+
}
358+
359+
// prevent duplicate transactions from the fraudTxs and stuckTxs with a map
360+
uniqueTxs := make(map[int64]Tx)
361+
for _, tx := range fraudTxs {
362+
uniqueTxs[tx.ID] = tx
363+
}
364+
365+
for _, tx := range stuckTxs {
366+
uniqueTxs[tx.ID] = tx
367+
}
368+
369+
var combinedStuckTxs []Tx
370+
for _, tx := range uniqueTxs {
371+
combinedStuckTxs = append(combinedStuckTxs, tx)
372+
}
373+
374+
return combinedStuckTxs, nil
375+
}
376+
377+
// Uses zirc_isQuarantined to check whether the transactions are considered as malicious by the sequencer and
378+
// preventing their inclusion into a block
379+
func (d *stuckTxDetector) detectFraudTransactionsZircuit(ctx context.Context, txs []Tx) ([]Tx, error) {
380+
txReqs := make([]rpc.BatchElem, len(txs))
381+
txHashMap := make(map[common.Hash]Tx)
382+
txRes := make([]*zircuitResponse, len(txs))
383+
384+
// Build batch request elems to perform
385+
for i, tx := range txs {
386+
latestAttemptHash := tx.TxAttempts[0].Hash
387+
var result zircuitResponse
388+
txReqs[i] = rpc.BatchElem{
389+
Method: "zirc_isQuarantined",
390+
Args: []interface{}{
391+
latestAttemptHash,
392+
},
393+
Result: &result,
394+
}
395+
txHashMap[latestAttemptHash] = tx
396+
txRes[i] = &result
397+
}
398+
399+
// Send batch request
400+
err := d.chainClient.BatchCallContext(ctx, txReqs)
401+
if err != nil {
402+
return nil, fmt.Errorf("failed to check Quarantine transactions in batch: %w", err)
403+
}
404+
405+
// If the result is not nil, the fraud transaction is flagged as quarantined
406+
var fraudTxs []Tx
407+
for i, req := range txReqs {
408+
txHash := req.Args[0].(common.Hash)
409+
if req.Error != nil {
410+
d.lggr.Errorf("failed to check fraud transaction by hash (%s): %v", txHash.String(), req.Error)
411+
continue
412+
}
413+
414+
result := txRes[i]
415+
if result != nil && result.IsQuarantined {
416+
tx := txHashMap[txHash]
417+
fraudTxs = append(fraudTxs, tx)
418+
}
419+
}
420+
return fraudTxs, nil
421+
}
422+
339423
// Uses eth_getTransactionByHash to detect that a transaction has been discarded due to overflow
340424
// Currently only used by zkEVM but if other chains follow the same behavior in the future
341425
func (d *stuckTxDetector) detectStuckTransactionsZkEVM(ctx context.Context, txs []Tx) ([]Tx, error) {
@@ -390,7 +474,7 @@ func (d *stuckTxDetector) detectStuckTransactionsZkEVM(ctx context.Context, txs
390474
for i, req := range txReqs {
391475
txHash := req.Args[0].(common.Hash)
392476
if req.Error != nil {
393-
d.lggr.Debugf("failed to get transaction by hash (%s): %v", txHash.String(), req.Error)
477+
d.lggr.Errorf("failed to get transaction by hash (%s): %v", txHash.String(), req.Error)
394478
continue
395479
}
396480
result := *txRes[i]

core/chains/evm/txmgr/stuck_tx_detector_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,108 @@ func TestStuckTxDetector_DetectStuckTransactionsHeuristic(t *testing.T) {
280280
})
281281
}
282282

283+
func TestStuckTxDetector_DetectStuckTransactionsZircuit(t *testing.T) {
284+
t.Parallel()
285+
286+
db := pgtest.NewSqlxDB(t)
287+
txStore := cltest.NewTestTxStore(t, db)
288+
ethKeyStore := cltest.NewKeyStore(t, db).Eth()
289+
ctx := tests.Context(t)
290+
291+
lggr := logger.Test(t)
292+
feeEstimator := gasmocks.NewEvmFeeEstimator(t)
293+
// Return 10 gwei as market gas price
294+
marketGasPrice := tenGwei
295+
fee := gas.EvmFee{Legacy: marketGasPrice}
296+
feeEstimator.On("GetFee", mock.Anything, []byte{}, uint64(0), mock.Anything, mock.Anything, mock.Anything).Return(fee, uint64(0), nil)
297+
ethClient := testutils.NewEthClientMockWithDefaultChain(t)
298+
autoPurgeThreshold := uint32(5)
299+
autoPurgeMinAttempts := uint32(3)
300+
autoPurgeCfg := testAutoPurgeConfig{
301+
enabled: true, // Enable auto-purge feature for testing
302+
threshold: &autoPurgeThreshold,
303+
minAttempts: &autoPurgeMinAttempts,
304+
}
305+
blockNum := int64(100)
306+
stuckTxDetector := txmgr.NewStuckTxDetector(lggr, testutils.FixtureChainID, chaintype.ChainZircuit, assets.NewWei(assets.NewEth(100).ToInt()), autoPurgeCfg, feeEstimator, txStore, ethClient)
307+
308+
t.Run("returns empty list if no fraud or stuck transactions identified", func(t *testing.T) {
309+
_, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore)
310+
tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, 1, blockNum, tenGwei)
311+
attempts := tx.TxAttempts[0]
312+
// Request still returns transaction by hash, transaction not discarded by network and not considered stuck
313+
ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool {
314+
return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined")
315+
})).Return(nil).Run(func(args mock.Arguments) {
316+
elems := args.Get(1).([]rpc.BatchElem)
317+
resp, err := json.Marshal(struct {
318+
IsQuarantined bool `json:"isQuarantined"`
319+
}{IsQuarantined: false})
320+
require.NoError(t, err)
321+
elems[0].Error = json.Unmarshal(resp, elems[0].Result)
322+
}).Once()
323+
324+
txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum)
325+
require.NoError(t, err)
326+
require.Len(t, txs, 0)
327+
})
328+
329+
t.Run("returns fraud transactions identified", func(t *testing.T) {
330+
_, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore)
331+
tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, 1, blockNum, tenGwei)
332+
attempts := tx.TxAttempts[0]
333+
// Request still returns transaction by hash, transaction not discarded by network and not considered stuck
334+
ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool {
335+
return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined")
336+
})).Return(nil).Run(func(args mock.Arguments) {
337+
elems := args.Get(1).([]rpc.BatchElem)
338+
resp, err := json.Marshal(struct {
339+
IsQuarantined bool `json:"isQuarantined"`
340+
}{IsQuarantined: true})
341+
require.NoError(t, err)
342+
elems[0].Error = json.Unmarshal(resp, elems[0].Result)
343+
}).Once()
344+
345+
txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum)
346+
require.NoError(t, err)
347+
require.Len(t, txs, 1)
348+
})
349+
350+
t.Run("returns the transaction only once if it's identified as both fraud and stuck", func(t *testing.T) {
351+
_, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore)
352+
tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, autoPurgeMinAttempts, blockNum-int64(autoPurgeThreshold)+int64(autoPurgeMinAttempts-1), marketGasPrice.Add(oneGwei))
353+
attempts := tx.TxAttempts[0]
354+
355+
ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool {
356+
return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined")
357+
})).Return(nil).Run(func(args mock.Arguments) {
358+
elems := args.Get(1).([]rpc.BatchElem)
359+
resp, err := json.Marshal(struct {
360+
IsQuarantined bool `json:"isQuarantined"`
361+
}{IsQuarantined: true})
362+
require.NoError(t, err)
363+
elems[0].Error = json.Unmarshal(resp, elems[0].Result)
364+
}).Once()
365+
366+
txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum)
367+
require.NoError(t, err)
368+
require.Len(t, txs, 1)
369+
})
370+
t.Run("returns the stuck tx even if failed to detect fraud tx", func(t *testing.T) {
371+
_, fromAddress := cltest.MustInsertRandomKey(t, ethKeyStore)
372+
tx := mustInsertUnconfirmedTxWithBroadcastAttempts(t, txStore, 0, fromAddress, autoPurgeMinAttempts, blockNum-int64(autoPurgeThreshold)+int64(autoPurgeMinAttempts-1), marketGasPrice.Add(oneGwei))
373+
attempts := tx.TxAttempts[0]
374+
375+
ethClient.On("BatchCallContext", mock.Anything, mock.MatchedBy(func(b []rpc.BatchElem) bool {
376+
return len(b) == 1 && cltest.BatchElemMatchesParams(b[0], attempts.Hash, "zirc_isQuarantined")
377+
})).Return(fmt.Errorf("failed to fetch rpc"))
378+
379+
txs, err := stuckTxDetector.DetectStuckTransactions(ctx, []common.Address{fromAddress}, blockNum)
380+
require.NoError(t, err)
381+
require.Len(t, txs, 1)
382+
})
383+
}
384+
283385
func TestStuckTxDetector_DetectStuckTransactionsZkEVM(t *testing.T) {
284386
t.Parallel()
285387

core/services/chainlink/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,7 +1357,7 @@ func TestConfig_Validate(t *testing.T) {
13571357
- 1: 10 errors:
13581358
- ChainType: invalid value (Foo): must not be set with this chain id
13591359
- Nodes: missing: must have at least one node
1360-
- ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, sei, scroll, wemix, xlayer, zkevm, zksync or omitted
1360+
- ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, sei, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted
13611361
- HeadTracker.HistoryDepth: invalid value (30): must be greater than or equal to FinalizedBlockOffset
13621362
- GasEstimator.BumpThreshold: invalid value (0): cannot be 0 if auto-purge feature is enabled for Foo
13631363
- Transactions.AutoPurge.Threshold: missing: needs to be set if auto-purge feature is enabled for Foo
@@ -1370,7 +1370,7 @@ func TestConfig_Validate(t *testing.T) {
13701370
- 2: 5 errors:
13711371
- ChainType: invalid value (Arbitrum): only "optimismBedrock" can be used with this chain id
13721372
- Nodes: missing: must have at least one node
1373-
- ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, sei, scroll, wemix, xlayer, zkevm, zksync or omitted
1373+
- ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, sei, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted
13741374
- FinalityDepth: invalid value (0): must be greater than or equal to 1
13751375
- MinIncomingConfirmations: invalid value (0): must be greater than or equal to 1
13761376
- 3: 3 errors:

0 commit comments

Comments
 (0)