diff --git a/.golangci.yml b/.golangci.yml index e61c1b6e..6ea30227 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -62,11 +62,11 @@ linters-settings: # Default: true skipRecvDeref: false - gomnd: - # List of function regex patterns to exclude from analysis. - # Default: [] - ignored-functions: - - + # gomnd: + # # List of function regex patterns to exclude from analysis. + # # Default: [] + # ignored-functions: + # - gomodguard: blocked: # List of blocked modules. @@ -144,7 +144,7 @@ linters: - gocritic # provides diagnostics that check for bugs, performance and style issues - gocyclo # computes and checks the cyclomatic complexity of functions - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - - gomnd # detects magic numbers + # - gomnd # detects magic numbers - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems @@ -207,6 +207,7 @@ issues: linters: - gocritic - unparam + - path: "_test\\.go" linters: - gocognit diff --git a/internal/common/common_test.go b/internal/common/common_test.go index 4318e6ec..453a9f39 100644 --- a/internal/common/common_test.go +++ b/internal/common/common_test.go @@ -62,3 +62,32 @@ func Test_DLQ(t *testing.T) { assert.Equal(t, len(entries), 4) assert.True(t, dlq.Empty(), true) } + +// Test_SorensonDice ... Tests Sorenson Dice similarity +func Test_SorensonDice(t *testing.T) { + var tests = []struct { + name string + function func(t *testing.T, a, b string, expected float64) + }{ + { + name: "Equal Strings", + function: func(t *testing.T, a, b string, expected float64) { + assert.Equal(t, common.SorensonDice(a, b), expected) + }, + }, + { + name: "Unequal Strings", + function: func(t *testing.T, a, b string, expected float64) { + assert.Equal(t, common.SorensonDice(a, b), expected) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.function(t, "0x123", "0x123", 1) + test.function(t, "0x123", "0x124", 0.75) + }) + } + +} diff --git a/internal/common/math.go b/internal/common/math.go index b1e888b3..1c44fb21 100644 --- a/internal/common/math.go +++ b/internal/common/math.go @@ -2,9 +2,38 @@ package common import "math/big" -// PercentOf - calculate what percent [number1] is of [number2]. -// ex. 300 is 12.5% of 2400 +// PercentOf - calculate what percent x0 is of x1. func PercentOf(part, total *big.Float) *big.Float { - x0 := new(big.Float).Mul(part, big.NewFloat(100)) + whole := 100.0 + x0 := new(big.Float).Mul(part, big.NewFloat(whole)) return new(big.Float).Quo(x0, total) } + +// SorensonDice - calculate the Sorenson-Dice coefficient between two strings. +func SorensonDice(s1, s2 string) float64 { + // https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient + // 2 * |X & Y| / (|X| + |Y|) + + var ( + n1 = len(s1) + n2 = len(s2) + ) + + if n1 == 0 || n2 == 0 { + return 0 + } + + intersects := 0 + for i := 0; i < n1-1; i++ { + a := s1[i : i+2] + for j := 0; j < n2-1; j++ { + b := s2[j : j+2] + if a == b { + intersects++ + break + } + } + } + + return float64(2*intersects) / float64(n1+n2-2) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 86e687b9..f685b7b5 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -102,12 +102,12 @@ func (hce *hardCodedEngine) EventLoop(ctx context.Context) { var actSet *heuristic.ActivationSet retryStrategy := &retry.ExponentialStrategy{Min: 1000, Max: 20_000, MaxJitter: 250} - if _, err := retry.Do[interface{}](ctx, 10, retryStrategy, func() (interface{}, error) { + if _, err := retry.Do[any](ctx, 10, retryStrategy, func() (any, error) { actSet = hce.Execute(ctx, execInput.hi.Input, execInput.h) metrics.WithContext(ctx).RecordHeuristicRun(execInput.h) metrics.WithContext(ctx).RecordInvExecutionTime(execInput.h, float64(time.Since(start).Nanoseconds())) // a-ok! - return nil, nil + return 0, nil }); err != nil { logger.Error("Failed to execute heuristic", zap.Error(err)) metrics.WithContext(ctx).RecordAssessmentError(execInput.h) diff --git a/internal/engine/heuristic/heuristic.go b/internal/engine/heuristic/heuristic.go index 5491a264..103780cb 100644 --- a/internal/engine/heuristic/heuristic.go +++ b/internal/engine/heuristic/heuristic.go @@ -64,8 +64,8 @@ func (bi *BaseHeuristic) InputType() core.RegisterType { } // Assess ... Determines if a heuristic activation has occurred; defaults to no-op -func (bi *BaseHeuristic) Assess(td core.TransitData) (*ActivationSet, error) { - return nil, nil +func (bi *BaseHeuristic) Assess(_ core.TransitData) (*ActivationSet, error) { + return NoActivations(), nil } // SetSUUID ... Sets the heuristic session UUID diff --git a/internal/engine/registry/fault_detector.go b/internal/engine/registry/fault_detector.go index 29491321..5578c841 100644 --- a/internal/engine/registry/fault_detector.go +++ b/internal/engine/registry/fault_detector.go @@ -17,7 +17,6 @@ import ( "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "go.uber.org/zap" ) @@ -59,8 +58,7 @@ func blockToInfo(b *types.Block) blockInfo { // faultDetectorInv ... faultDetectorInv implementation type faultDetectorInv struct { - eventHash common.Hash - cfg *FaultDetectorCfg + cfg *FaultDetectorCfg l2tol1MessagePasser common.Address l2OutputOracleFilter *bindings.L2OutputOracleFilterer @@ -79,7 +77,6 @@ func NewFaultDetector(ctx context.Context, cfg *FaultDetectorCfg) (heuristic.Heu return nil, err } - outputSig := crypto.Keccak256Hash([]byte(OutputProposedEvent)) addr := common.HexToAddress(cfg.L2ToL1Address) outputOracle, err := bindings.NewL2OutputOracleFilterer(addr, bundle.L1Client) @@ -90,7 +87,6 @@ func NewFaultDetector(ctx context.Context, cfg *FaultDetectorCfg) (heuristic.Heu return &faultDetectorInv{ cfg: cfg, - eventHash: outputSig, l2OutputOracleFilter: outputOracle, l2tol1MessagePasser: addr, stats: metrics.WithContext(ctx), diff --git a/internal/engine/registry/withdrawal_safety.go b/internal/engine/registry/withdrawal_safety.go index fc4c3841..33cbde67 100644 --- a/internal/engine/registry/withdrawal_safety.go +++ b/internal/engine/registry/withdrawal_safety.go @@ -20,7 +20,7 @@ import ( ) const ( - largeWithdrawal = "Large withdrawal has been proven on L1" + greaterThanThreshold = "A withdraw was proven that is >= %f of the Optimism Portal balance" uncorrelatedWithdraw = "Withdrawal proven on L1 does not exist in L2ToL1MessagePasser storage" greaterThanPortal = "Withdrawal amount is greater than the Optimism Portal balance" ) @@ -38,6 +38,7 @@ const unsafeWithdrawalMsg = ` // UnsafeWithdrawalCfg ... Configuration for the balance heuristic type UnsafeWithdrawalCfg struct { + // % of OptimismPortal balance that is considered a large withdrawal Threshold float64 `json:"threshold"` L1PortalAddress string `json:"l1_portal_address"` @@ -64,7 +65,6 @@ func (cfg *UnsafeWithdrawalCfg) Unmarshal(isp *core.SessionParams) error { // NewWithdrawalSafetyHeuristic ... Initializer func NewWithdrawalSafetyHeuristic(ctx context.Context, cfg *UnsafeWithdrawalCfg) (heuristic.Heuristic, error) { - // Validate that threshold is on exclusive range (0, 100) if cfg.Threshold >= 100 || cfg.Threshold < 0 { return nil, fmt.Errorf("invalid threshold supplied for withdrawal safety heuristic") @@ -84,6 +84,9 @@ func NewWithdrawalSafetyHeuristic(ctx context.Context, cfg *UnsafeWithdrawalCfg) } l2ToL1MsgPasser, err := bindings.NewL2ToL1MessagePasserCaller(l2ToL1Addr, clients.L2Client) + if err != nil { + return nil, err + } return &WithdrawalSafetyHeuristic{ ctx: ctx, @@ -101,20 +104,15 @@ func NewWithdrawalSafetyHeuristic(ctx context.Context, cfg *UnsafeWithdrawalCfg) // Assess ... Verifies than an L1 WithdrawalProven has a correlating hash // to the withdrawal storage of the L2ToL1MessagePasser +// TODO - Segment this into composite functions func (wi *WithdrawalSafetyHeuristic) Assess(td core.TransitData) (*heuristic.ActivationSet, error) { // TODO - Support running from withdrawal initiated or withdrawal proven events - clients, err := client.FromContext(wi.ctx) - if err != nil { - return nil, err - } - logging.NoContext().Debug("Checking activation for withdrawal enforcement heuristic", zap.String("data", fmt.Sprintf("%v", td))) - // 1. Validate and extract data input - if td.Type != wi.InputType() { - return nil, fmt.Errorf("invalid type supplied") + if wi.ValidateInput(td) != nil { + return nil, fmt.Errorf("invalid input supplied") } if td.Address.String() != wi.cfg.L1PortalAddress { @@ -133,7 +131,7 @@ func (wi *WithdrawalSafetyHeuristic) Assess(td core.TransitData) (*heuristic.Act } // 3. Get withdrawal metadata from OP Indexer API - withdrawals, err := clients.IndexerClient.GetAllWithdrawalsByAddress(provenWithdrawal.From) + withdrawals, err := wi.indexerClient.GetAllWithdrawalsByAddress(provenWithdrawal.From) if err != nil { return nil, err } @@ -145,41 +143,77 @@ func (wi *WithdrawalSafetyHeuristic) Assess(td core.TransitData) (*heuristic.Act // TODO - Update withdrawal decoding to convert to big.Int instead of string corrWithdrawal := withdrawals[0] + // 4. Fetch the OptimismPortal balance at the time which the withdrawal was proven portalWEI, err := wi.l1Client.BalanceAt(context.Background(), common.HexToAddress(wi.cfg.L1PortalAddress), big.NewInt(int64(log.BlockNumber))) if err != nil { return nil, err } - portalETH := p_common.WeiToEther(portalWEI) - withdrawalWEI := big.NewInt(0).SetBytes([]byte(corrWithdrawal.Amount)) - withdrawalETH := p_common.WeiToEther(withdrawalWEI) + correlated, err := wi.l2ToL1MsgPasser.SentMessages(nil, provenWithdrawal.WithdrawalHash) if err != nil { return nil, err } - // 4. Perform invariant analysis using the withdrawal metadata + maxAddr := common.HexToAddress("0xffffffffffffffffffffffffffffffffffffffff") + minAddr := common.HexToAddress("0x0") + + portalETH := p_common.WeiToEther(portalWEI) + withdrawalETH := p_common.WeiToEther(withdrawalWEI) + + // 5. Perform invariant analysis using the fetched withdrawal metadata invariants := []func() (bool, string){ - // 4.1 + // 5.1 // Check if the proven withdrawal amount is greater than the OptimismPortal value func() (bool, string) { return withdrawalWEI.Cmp(portalWEI) >= 0, greaterThanPortal }, - // 4.2 - // Check if the proven withdrawal amount is greater than 5% of the OptimismPortal value + // 5.2 + // Check if the proven withdrawal amount is greater than threshold % of the OptimismPortal value func() (bool, string) { - return p_common.PercentOf(withdrawalETH, portalETH).Cmp(big.NewFloat(5)) == 1, ` - A withdraw was proven that is >= 5% of the Optimism Portal balance` + return p_common.PercentOf(withdrawalETH, portalETH).Cmp(big.NewFloat(wi.cfg.Threshold)) == 1, + fmt.Sprintf(greaterThanThreshold, wi.cfg.Threshold) }, - // 4.3 + // 5.3 // Ensure the proven withdrawal exists in the L2ToL1MessagePasser storage func() (bool, string) { return !correlated, uncorrelatedWithdraw }, + // 5.4 + // Ensure message_hash != 0x0 (0x0 is the default value for bytes32) + func() (bool, string) { + if corrWithdrawal.MessageHash == minAddr.String() { + return true, "Withdrawal message hash is 0x0000000000000000000000000000000000000000" + } + + if corrWithdrawal.MessageHash == maxAddr.String() { + return true, "Withdrawal message hash is 0xffffffffffffffffffffffffffffffffffffffff" + } + + return false, "" + }, + // 5.5 + // Ensure that zero address and max address aren't super similar to Sorenson-Dice coefficient + func() (bool, string) { + c0 := p_common.SorensonDice(corrWithdrawal.MessageHash, minAddr.String()) + c1 := p_common.SorensonDice(corrWithdrawal.MessageHash, maxAddr.String()) + threshold := 0.95 + + if c0 >= threshold { + return true, "Zero address is too similar to message hash" + } + + if c1 >= threshold { + return true, "Max address is too similar to message hash" + } + + return false, "" + }, } + // 6. Process activation set from invariant analysis as := heuristic.NewActivationSet() for _, inv := range invariants { if success, msg := inv(); success {