Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Zircuit fraud transactions and zk overflow detection #14629

Merged
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
8 changes: 6 additions & 2 deletions core/chains/evm/config/chaintype/chaintype.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ 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:
// - the block numbers used for log searching are different from calling block.number
// - gas bumping is not supported, since there is no tx mempool
func (c ChainType) IsL2() bool {
switch c {
case ChainArbitrum, ChainMetis:
case ChainArbitrum, ChainMetis, ChainZircuit:
amit-momin marked this conversation as resolved.
Show resolved Hide resolved
return true
default:
return false
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),
}, ", "))
84 changes: 84 additions & 0 deletions 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 {
return txs, err
}
amit-momin marked this conversation as resolved.
Show resolved Hide resolved

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[common.Hash]Tx)
for _, tx := range fraudTxs {
uniqueTxs[tx.TxAttempts[0].Hash] = tx
}

for _, tx := range stuckTxs {
uniqueTxs[tx.TxAttempts[0].Hash] = tx
}
amit-momin marked this conversation as resolved.
Show resolved Hide resolved

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.Debugf("failed to check fraud transaction by hash (%s): %v", txHash.String(), req.Error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any context on the use case for this feature, but... if the node is unable to verify whether transactions are fraudulent, shouldn't it flag an error, or at least warn about it? (Rather than just logging at level="debug"?)

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
89 changes: 89 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,95 @@ 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]
// 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)
})
amit-momin marked this conversation as resolved.
Show resolved Hide resolved
}

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 @@ -1365,7 +1365,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 @@ -1378,7 +1378,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.Nodes: 5 errors:
Expand Down
Loading