diff --git a/chain/gen/slashfilter/slashsvc/slashservice.go b/chain/gen/slashfilter/slashsvc/slashservice.go new file mode 100644 index 000000000..7a6622880 --- /dev/null +++ b/chain/gen/slashfilter/slashsvc/slashservice.go @@ -0,0 +1,179 @@ +package slashsvc + +import ( + "context" + "time" + + "github.com/ipfs/go-cid" + levelds "github.com/ipfs/go-ds-leveldb" + logging "github.com/ipfs/go-log/v2" + ldbopts "github.com/syndtr/goleveldb/leveldb/opt" + "golang.org/x/xerrors" + + "github.com/filecoin-project/go-address" + cborutil "github.com/filecoin-project/go-cbor-util" + "github.com/filecoin-project/go-state-types/builtin" + "github.com/filecoin-project/specs-actors/actors/builtin/miner" + + lapi "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/chain/actors" + "github.com/filecoin-project/lotus/chain/gen/slashfilter" + "github.com/filecoin-project/lotus/chain/types" +) + +var log = logging.Logger("slashsvc") + +type ConsensusSlasherApi interface { + ChainHead(context.Context) (*types.TipSet, error) + ChainGetBlock(context.Context, cid.Cid) (*types.BlockHeader, error) + MpoolPushMessage(ctx context.Context, msg *types.Message, spec *lapi.MessageSendSpec) (*types.SignedMessage, error) + SyncIncomingBlocks(context.Context) (<-chan *types.BlockHeader, error) + WalletDefaultAddress(context.Context) (address.Address, error) +} + +func SlashConsensus(ctx context.Context, a ConsensusSlasherApi, p string, from string) error { + var fromAddr address.Address + + ds, err := levelds.NewDatastore(p, &levelds.Options{ + Compression: ldbopts.NoCompression, + NoSync: false, + Strict: ldbopts.StrictAll, + ReadOnly: false, + }) + if err != nil { + return xerrors.Errorf("open leveldb: %w", err) + } + sf := slashfilter.New(ds) + if from == "" { + defaddr, err := a.WalletDefaultAddress(ctx) + if err != nil { + return err + } + fromAddr = defaddr + } else { + addr, err := address.NewFromString(from) + if err != nil { + return err + } + + fromAddr = addr + } + + blocks, err := a.SyncIncomingBlocks(ctx) + if err != nil { + return xerrors.Errorf("sync incoming blocks failed: %w", err) + } + + log.Infow("consensus fault reporter", "from", fromAddr) + go func() { + for block := range blocks { + otherBlock, extraBlock, fault, err := slashFilterMinedBlock(ctx, sf, a, block) + if err != nil { + log.Errorf("slash detector errored: %s", err) + continue + } + if fault { + log.Errorf(" SLASH FILTER DETECTED FAULT DUE TO BLOCKS %s and %s", otherBlock.Cid(), block.Cid()) + bh1, err := cborutil.Dump(otherBlock) + if err != nil { + log.Errorf("could not dump otherblock:%s, err:%s", otherBlock.Cid(), err) + continue + } + + bh2, err := cborutil.Dump(block) + if err != nil { + log.Errorf("could not dump block:%s, err:%s", block.Cid(), err) + continue + } + + params := miner.ReportConsensusFaultParams{ + BlockHeader1: bh1, + BlockHeader2: bh2, + } + if extraBlock != nil { + be, err := cborutil.Dump(extraBlock) + if err != nil { + log.Errorf("could not dump block:%s, err:%s", block.Cid(), err) + continue + } + params.BlockHeaderExtra = be + } + + enc, err := actors.SerializeParams(¶ms) + if err != nil { + log.Errorf("could not serialize declare faults parameters: %s", err) + continue + } + for { + head, err := a.ChainHead(ctx) + if err != nil || head.Height() > block.Height { + break + } + time.Sleep(time.Second * 10) + } + message, err := a.MpoolPushMessage(ctx, &types.Message{ + To: block.Miner, + From: fromAddr, + Value: types.NewInt(0), + Method: builtin.MethodsMiner.ReportConsensusFault, + Params: enc, + }, nil) + if err != nil { + log.Errorf("ReportConsensusFault to messagepool error:%s", err) + continue + } + log.Infof("ReportConsensusFault message CID:%s", message.Cid()) + + } + } + }() + + return nil +} + +func slashFilterMinedBlock(ctx context.Context, sf *slashfilter.SlashFilter, a ConsensusSlasherApi, blockB *types.BlockHeader) (*types.BlockHeader, *types.BlockHeader, bool, error) { + blockC, err := a.ChainGetBlock(ctx, blockB.Parents[0]) + if err != nil { + return nil, nil, false, xerrors.Errorf("chain get block error:%s", err) + } + + blockACid, fault, err := sf.MinedBlock(ctx, blockB, blockC.Height) + if err != nil { + return nil, nil, false, xerrors.Errorf("slash filter check block error:%s", err) + } + + if !fault { + return nil, nil, false, nil + } + + blockA, err := a.ChainGetBlock(ctx, blockACid) + if err != nil { + return nil, nil, false, xerrors.Errorf("failed to get blockA: %w", err) + } + + // (a) double-fork mining (2 blocks at one epoch) + if blockA.Height == blockB.Height { + return blockA, nil, true, nil + } + + // (b) time-offset mining faults (2 blocks with the same parents) + if types.CidArrsEqual(blockB.Parents, blockA.Parents) { + return blockA, nil, true, nil + } + + // (c) parent-grinding fault + // Here extra is the "witness", a third block that shows the connection between A and B as + // A's sibling and B's parent. + // Specifically, since A is of lower height, it must be that B was mined omitting A from its tipset + // + // B + // | + // [A, C] + if types.CidArrsEqual(blockA.Parents, blockC.Parents) && blockA.Height == blockC.Height && + types.CidArrsContains(blockB.Parents, blockC.Cid()) && !types.CidArrsContains(blockB.Parents, blockA.Cid()) { + return blockA, blockC, true, nil + } + + log.Error("unexpectedly reached end of slashFilterMinedBlock despite fault being reported!") + return nil, nil, false, nil +} diff --git a/cmd/lotus/daemon.go b/cmd/lotus/daemon.go index 8327fe60e..7271a6e53 100644 --- a/cmd/lotus/daemon.go +++ b/cmd/lotus/daemon.go @@ -15,14 +15,11 @@ import ( "path/filepath" "runtime/pprof" "strings" - "time" "github.com/DataDog/zstd" - levelds "github.com/ipfs/go-ds-leveldb" metricsprom "github.com/ipfs/go-metrics-prometheus" "github.com/mitchellh/go-homedir" "github.com/multiformats/go-multiaddr" - ldbopts "github.com/syndtr/goleveldb/leveldb/opt" "github.com/urfave/cli/v2" "go.opencensus.io/plugin/runmetrics" "go.opencensus.io/stats" @@ -31,20 +28,14 @@ import ( "golang.org/x/xerrors" "gopkg.in/cheggaaa/pb.v1" - "github.com/filecoin-project/go-address" - cborutil "github.com/filecoin-project/go-cbor-util" "github.com/filecoin-project/go-jsonrpc" "github.com/filecoin-project/go-paramfetch" - "github.com/filecoin-project/go-state-types/builtin" - "github.com/filecoin-project/specs-actors/actors/builtin/miner" lapi "github.com/filecoin-project/lotus/api" "github.com/filecoin-project/lotus/build" - "github.com/filecoin-project/lotus/chain/actors" "github.com/filecoin-project/lotus/chain/beacon/drand" "github.com/filecoin-project/lotus/chain/consensus" "github.com/filecoin-project/lotus/chain/consensus/filcns" - "github.com/filecoin-project/lotus/chain/gen/slashfilter" "github.com/filecoin-project/lotus/chain/index" "github.com/filecoin-project/lotus/chain/stmgr" "github.com/filecoin-project/lotus/chain/store" @@ -175,19 +166,6 @@ var DaemonCmd = &cli.Command{ Name: "restore-config", Usage: "config file to use when restoring from backup", }, - &cli.BoolFlag{ - Name: "slash-consensus", - Usage: "Report consensus fault", - Value: false, - }, - &cli.StringFlag{ - Name: "slasher-sender", - Usage: "optionally specify the account to report consensus from", - }, - &cli.StringFlag{ - Name: "slashdb-dir", - Value: "slash watch db dir path", - }, }, Action: func(cctx *cli.Context) error { isLite := cctx.Bool("lite") @@ -428,19 +406,6 @@ var DaemonCmd = &cli.Command{ if err != nil { return fmt.Errorf("failed to start json-rpc endpoint: %s", err) } - - if cctx.Bool("slash-consensus") { - if !cctx.IsSet("slashdb-dir") { - return fmt.Errorf("must supply path for slasher database with --slashdb-dir") - } - - go func() { - err := slashConsensus(api, cctx.String("slashdb-dir"), cctx.String("slasher-sender")) - if err != nil { - panic("slashConsensus error: " + err.Error()) - } - }() - } // Monitor for shutdown. finishCh := node.MonitorShutdown(shutdownChan, node.ShutdownHandler{Component: "rpc server", StopFunc: rpcStopper}, @@ -639,149 +604,6 @@ func ImportChain(ctx context.Context, r repo.Repo, fname string, snapshot bool) return nil } -func slashConsensus(a lapi.FullNode, p string, from string) error { - ctx := context.Background() - var fromAddr address.Address - - ds, err := levelds.NewDatastore(p, &levelds.Options{ - Compression: ldbopts.NoCompression, - NoSync: false, - Strict: ldbopts.StrictAll, - ReadOnly: false, - }) - if err != nil { - return xerrors.Errorf("open leveldb: %w", err) - } - sf := slashfilter.New(ds) - if from == "" { - defaddr, err := a.WalletDefaultAddress(ctx) - if err != nil { - return err - } - fromAddr = defaddr - } else { - addr, err := address.NewFromString(from) - if err != nil { - return err - } - - fromAddr = addr - } - - blocks, err := a.SyncIncomingBlocks(ctx) - if err != nil { - return xerrors.Errorf("sync incoming blocks failed: %w", err) - } - for block := range blocks { - otherBlock, extraBlock, fault, err := slashFilterMinedBlock(ctx, sf, a, block) - if err != nil { - log.Errorf("slash detector errored: %s", err) - continue - } - if fault { - log.Errorf(" SLASH FILTER DETECTED FAULT DUE TO BLOCKS %s and %s", otherBlock.Cid(), block.Cid()) - bh1, err := cborutil.Dump(otherBlock) - if err != nil { - log.Errorf("could not dump otherblock:%s, err:%s", otherBlock.Cid(), err) - continue - } - - bh2, err := cborutil.Dump(block) - if err != nil { - log.Errorf("could not dump block:%s, err:%s", block.Cid(), err) - continue - } - - params := miner.ReportConsensusFaultParams{ - BlockHeader1: bh1, - BlockHeader2: bh2, - } - if extraBlock != nil { - be, err := cborutil.Dump(extraBlock) - if err != nil { - log.Errorf("could not dump block:%s, err:%s", block.Cid(), err) - continue - } - params.BlockHeaderExtra = be - } - - enc, err := actors.SerializeParams(¶ms) - if err != nil { - log.Errorf("could not serialize declare faults parameters: %s", err) - continue - } - for { - head, err := a.ChainHead(ctx) - if err != nil || head.Height() > block.Height { - break - } - time.Sleep(time.Second * 10) - } - message, err := a.MpoolPushMessage(ctx, &types.Message{ - To: block.Miner, - From: fromAddr, - Value: types.NewInt(0), - Method: builtin.MethodsMiner.ReportConsensusFault, - Params: enc, - }, nil) - if err != nil { - log.Errorf("ReportConsensusFault to messagepool error:%s", err) - continue - } - log.Infof("ReportConsensusFault message CID:%s", message.Cid()) - - } - } - return err -} - -func slashFilterMinedBlock(ctx context.Context, sf *slashfilter.SlashFilter, a lapi.FullNode, blockB *types.BlockHeader) (*types.BlockHeader, *types.BlockHeader, bool, error) { - blockC, err := a.ChainGetBlock(ctx, blockB.Parents[0]) - if err != nil { - return nil, nil, false, xerrors.Errorf("chain get block error:%s", err) - } - - blockACid, fault, err := sf.MinedBlock(ctx, blockB, blockC.Height) - if err != nil { - return nil, nil, false, xerrors.Errorf("slash filter check block error:%s", err) - } - - if !fault { - return nil, nil, false, nil - } - - blockA, err := a.ChainGetBlock(ctx, blockACid) - if err != nil { - return nil, nil, false, xerrors.Errorf("failed to get blockA: %w", err) - } - - // (a) double-fork mining (2 blocks at one epoch) - if blockA.Height == blockB.Height { - return blockA, nil, true, nil - } - - // (b) time-offset mining faults (2 blocks with the same parents) - if types.CidArrsEqual(blockB.Parents, blockA.Parents) { - return blockA, nil, true, nil - } - - // (c) parent-grinding fault - // Here extra is the "witness", a third block that shows the connection between A and B as - // A's sibling and B's parent. - // Specifically, since A is of lower height, it must be that B was mined omitting A from its tipset - // - // B - // | - // [A, C] - if types.CidArrsEqual(blockA.Parents, blockC.Parents) && blockA.Height == blockC.Height && - types.CidArrsContains(blockB.Parents, blockC.Cid()) && !types.CidArrsContains(blockB.Parents, blockA.Cid()) { - return blockA, blockC, true, nil - } - - log.Error("unexpectedly reached end of slashFilterMinedBlock despite fault being reported!") - return nil, nil, false, nil -} - func removeExistingChain(cctx *cli.Context, lr repo.Repo) error { lockedRepo, err := lr.Lock(repo.FullNode) if err != nil { diff --git a/documentation/en/cli-lotus.md b/documentation/en/cli-lotus.md index 1bda42126..ef43ba022 100644 --- a/documentation/en/cli-lotus.md +++ b/documentation/en/cli-lotus.md @@ -75,9 +75,6 @@ OPTIONS: --api-max-req-size value maximum API request size accepted by the JSON RPC server (default: 0) --restore value restore from backup file --restore-config value config file to use when restoring from backup - --slash-consensus Report consensus fault (default: false) - --slasher-sender value optionally specify the account to report consensus from - --slashdb-dir value (default: "slash watch db dir path") --help, -h show help ``` diff --git a/documentation/en/default-lotus-config.toml b/documentation/en/default-lotus-config.toml index 8e99869a5..c37e40f74 100644 --- a/documentation/en/default-lotus-config.toml +++ b/documentation/en/default-lotus-config.toml @@ -399,3 +399,32 @@ #EnableMsgIndex = false +[FaultReporter] + # EnableConsensusFaultReporter controls whether the node will monitor and + # report consensus faults. When enabled, the node will watch for malicious + # behaviors like double-mining and parent grinding, and submit reports to the + # network. This can earn reporter rewards, but is not guaranteed. Nodes should + # enable fault reporting with care, as it may increase resource usage, and may + # generate gas fees without earning rewards. + # + # type: bool + # env var: LOTUS_FAULTREPORTER_ENABLECONSENSUSFAULTREPORTER + #EnableConsensusFaultReporter = false + + # ConsensusFaultReporterDataDir is the path where fault reporter state will be + # persisted. This directory should have adequate space and permissions for the + # node process. + # + # type: string + # env var: LOTUS_FAULTREPORTER_CONSENSUSFAULTREPORTERDATADIR + #ConsensusFaultReporterDataDir = "" + + # ConsensusFaultReporterAddress is the wallet address used for submitting + # ReportConsensusFault messages. It will pay for gas fees, and receive any + # rewards. This address should have adequate funds to cover gas fees. + # + # type: string + # env var: LOTUS_FAULTREPORTER_CONSENSUSFAULTREPORTERADDRESS + #ConsensusFaultReporterAddress = "" + + diff --git a/node/builder.go b/node/builder.go index 10d366d56..f1a825be9 100644 --- a/node/builder.go +++ b/node/builder.go @@ -128,6 +128,8 @@ const ( SetupFallbackBlockstoresKey GoRPCServer + ConsensusReporterKey + SetApiEndpointKey StoreEventsKey diff --git a/node/builder_chain.go b/node/builder_chain.go index fcdb26162..267659f00 100644 --- a/node/builder_chain.go +++ b/node/builder_chain.go @@ -280,6 +280,11 @@ func ConfigFullNode(c interface{}) Option { // enable message index for full node when configured by the user, otherwise use dummy. If(cfg.Index.EnableMsgIndex, Override(new(index.MsgIndex), modules.MsgIndex)), If(!cfg.Index.EnableMsgIndex, Override(new(index.MsgIndex), modules.DummyMsgIndex)), + + // enable fault reporter when configured by the user + If(cfg.FaultReporter.EnableConsensusFaultReporter, + Override(ConsensusReporterKey, modules.RunConsensusFaultReporter(cfg.FaultReporter)), + ), ) } diff --git a/node/config/doc_gen.go b/node/config/doc_gen.go index 5361b2d6c..28f713fc5 100644 --- a/node/config/doc_gen.go +++ b/node/config/doc_gen.go @@ -394,6 +394,35 @@ the database must already exist and be writeable. If a relative path is provided relative to the CWD (current working directory).`, }, }, + "FaultReporterConfig": []DocField{ + { + Name: "EnableConsensusFaultReporter", + Type: "bool", + + Comment: `EnableConsensusFaultReporter controls whether the node will monitor and +report consensus faults. When enabled, the node will watch for malicious +behaviors like double-mining and parent grinding, and submit reports to the +network. This can earn reporter rewards, but is not guaranteed. Nodes should +enable fault reporting with care, as it may increase resource usage, and may +generate gas fees without earning rewards.`, + }, + { + Name: "ConsensusFaultReporterDataDir", + Type: "string", + + Comment: `ConsensusFaultReporterDataDir is the path where fault reporter state will be +persisted. This directory should have adequate space and permissions for the +node process.`, + }, + { + Name: "ConsensusFaultReporterAddress", + Type: "string", + + Comment: `ConsensusFaultReporterAddress is the wallet address used for submitting +ReportConsensusFault messages. It will pay for gas fees, and receive any +rewards. This address should have adequate funds to cover gas fees.`, + }, + }, "FeeConfig": []DocField{ { Name: "DefaultMaxFee", @@ -465,6 +494,12 @@ Set to 0 to keep all mappings`, Name: "Index", Type: "IndexConfig", + Comment: ``, + }, + { + Name: "FaultReporter", + Type: "FaultReporterConfig", + Comment: ``, }, }, diff --git a/node/config/types.go b/node/config/types.go index c89e8f70b..cfd7cf084 100644 --- a/node/config/types.go +++ b/node/config/types.go @@ -22,13 +22,14 @@ type Common struct { // FullNode is a full node config type FullNode struct { Common - Client Client - Wallet Wallet - Fees FeeConfig - Chainstore Chainstore - Cluster UserRaftConfig - Fevm FevmConfig - Index IndexConfig + Client Client + Wallet Wallet + Fees FeeConfig + Chainstore Chainstore + Cluster UserRaftConfig + Fevm FevmConfig + Index IndexConfig + FaultReporter FaultReporterConfig } // // Common @@ -732,3 +733,23 @@ type IndexConfig struct { // EnableMsgIndex enables indexing of messages on chain. EnableMsgIndex bool } + +type FaultReporterConfig struct { + // EnableConsensusFaultReporter controls whether the node will monitor and + // report consensus faults. When enabled, the node will watch for malicious + // behaviors like double-mining and parent grinding, and submit reports to the + // network. This can earn reporter rewards, but is not guaranteed. Nodes should + // enable fault reporting with care, as it may increase resource usage, and may + // generate gas fees without earning rewards. + EnableConsensusFaultReporter bool + + // ConsensusFaultReporterDataDir is the path where fault reporter state will be + // persisted. This directory should have adequate space and permissions for the + // node process. + ConsensusFaultReporterDataDir string + + // ConsensusFaultReporterAddress is the wallet address used for submitting + // ReportConsensusFault messages. It will pay for gas fees, and receive any + // rewards. This address should have adequate funds to cover gas fees. + ConsensusFaultReporterAddress string +} diff --git a/node/modules/faultreport.go b/node/modules/faultreport.go new file mode 100644 index 000000000..c42602d7e --- /dev/null +++ b/node/modules/faultreport.go @@ -0,0 +1,27 @@ +package modules + +import ( + "go.uber.org/fx" + + "github.com/filecoin-project/lotus/chain/gen/slashfilter/slashsvc" + "github.com/filecoin-project/lotus/node/config" + "github.com/filecoin-project/lotus/node/impl/full" + "github.com/filecoin-project/lotus/node/modules/helpers" +) + +type consensusReporterModules struct { + fx.In + + full.WalletAPI + full.ChainAPI + full.MpoolAPI + full.SyncAPI +} + +func RunConsensusFaultReporter(config config.FaultReporterConfig) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, mod consensusReporterModules) error { + return func(mctx helpers.MetricsCtx, lc fx.Lifecycle, mod consensusReporterModules) error { + ctx := helpers.LifecycleCtx(mctx, lc) + + return slashsvc.SlashConsensus(ctx, &mod, config.ConsensusFaultReporterDataDir, config.ConsensusFaultReporterAddress) + } +}