From cd0f34fe9d33e682e5405881a131a261c43d975b Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 5 Dec 2024 19:09:23 +1100 Subject: [PATCH] feat(f3): resolve finality for eth APIs according to F3 --- CHANGELOG.md | 2 + chain/lf3/f3.go | 70 ++++++-- chain/lf3/mock/mock_f3.go | 97 ++++++++++ chain/tsresolver/tipset_resolver.go | 162 +++++++++++++++++ chain/tsresolver/tipset_resolver_test.go | 217 +++++++++++++++++++++++ itests/eth_api_test.go | 127 +++++++++---- itests/kit/node_opts.go | 5 + node/builder_chain.go | 10 +- node/impl/full/eth.go | 36 ++-- node/impl/full/eth_utils.go | 52 ------ node/impl/full/f3.go | 39 +--- node/modules/ethmodule.go | 42 ++++- 12 files changed, 710 insertions(+), 149 deletions(-) create mode 100644 chain/lf3/mock/mock_f3.go create mode 100644 chain/tsresolver/tipset_resolver.go create mode 100644 chain/tsresolver/tipset_resolver_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index feca331ea92..ddec9e95abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ # UNRELEASED +* **Ethereum APIs meet F3!** When F3 is enabled and running, Ethereum APIs that accept block descriptors `"finalized"` and `"safe"` will use F3 to determine the block to select instead of the default 30 block delay for `"safe"` and 900 block delay for `"finalized"`. ([filecoin-project/lotus#12760](https://github.com/filecoin-project/lotus/pull/12760)) + # UNRELEASED v1.32.0 See https://github.com/filecoin-project/lotus/blob/release/v1.32.0/CHANGELOG.md diff --git a/chain/lf3/f3.go b/chain/lf3/f3.go index db517331040..411c5269db7 100644 --- a/chain/lf3/f3.go +++ b/chain/lf3/f3.go @@ -29,6 +29,20 @@ import ( "github.com/filecoin-project/lotus/node/repo" ) +type F3API interface { + GetOrRenewParticipationTicket(ctx context.Context, minerID uint64, previous api.F3ParticipationTicket, instances uint64) (api.F3ParticipationTicket, error) + Participate(ctx context.Context, ticket api.F3ParticipationTicket) (api.F3ParticipationLease, error) + GetCert(ctx context.Context, instance uint64) (*certs.FinalityCertificate, error) + GetLatestCert(ctx context.Context) (*certs.FinalityCertificate, error) + GetManifest(ctx context.Context) (*manifest.Manifest, error) + GetPowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) + GetF3PowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) + IsEnabled() bool + IsRunning() (bool, error) + Progress() (gpbft.Instant, error) + ListParticipants() ([]api.F3Participant, error) +} + type F3 struct { inner *f3.F3 ec *ecWrapper @@ -37,6 +51,8 @@ type F3 struct { leaser *leaser } +var _ F3API = (*F3)(nil) + type F3Params struct { fx.In @@ -184,20 +200,20 @@ func (fff *F3) GetLatestCert(ctx context.Context) (*certs.FinalityCertificate, e return fff.inner.GetLatestCert(ctx) } -func (fff *F3) GetManifest(ctx context.Context) *manifest.Manifest { +func (fff *F3) GetManifest(ctx context.Context) (*manifest.Manifest, error) { m := fff.inner.Manifest() if m.InitialPowerTable.Defined() { - return m + return m, nil } cert0, err := fff.inner.GetCert(ctx, 0) if err != nil { - return m + return m, nil } var mCopy = *m m = &mCopy m.InitialPowerTable = cert0.ECChain.Base().PowerTable - return m + return m, nil } func (fff *F3) GetPowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) { @@ -208,15 +224,19 @@ func (fff *F3) GetF3PowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft. return fff.inner.GetPowerTable(ctx, tsk.Bytes()) } -func (fff *F3) IsRunning() bool { - return fff.inner.IsRunning() +func (fff *F3) IsEnabled() bool { + return true +} + +func (fff *F3) IsRunning() (bool, error) { + return fff.inner.IsRunning(), nil } -func (fff *F3) Progress() gpbft.Instant { - return fff.inner.Progress() +func (fff *F3) Progress() (gpbft.Instant, error) { + return fff.inner.Progress(), nil } -func (fff *F3) ListParticipants() []api.F3Participant { +func (fff *F3) ListParticipants() ([]api.F3Participant, error) { leases := fff.leaser.getValidLeases() participants := make([]api.F3Participant, len(leases)) for i, lease := range leases { @@ -226,5 +246,35 @@ func (fff *F3) ListParticipants() []api.F3Participant { ValidityTerm: lease.ValidityTerm, } } - return participants + return participants, nil +} + +type DisabledF3 struct{} + +var _ F3API = DisabledF3{} + +func (DisabledF3) GetOrRenewParticipationTicket(_ context.Context, _ uint64, _ api.F3ParticipationTicket, _ uint64) (api.F3ParticipationTicket, error) { + return api.F3ParticipationTicket{}, api.ErrF3Disabled +} +func (DisabledF3) Participate(_ context.Context, _ api.F3ParticipationTicket) (api.F3ParticipationLease, error) { + return api.F3ParticipationLease{}, api.ErrF3Disabled +} +func (DisabledF3) GetCert(_ context.Context, _ uint64) (*certs.FinalityCertificate, error) { + return nil, api.ErrF3Disabled +} +func (DisabledF3) GetLatestCert(_ context.Context) (*certs.FinalityCertificate, error) { + return nil, api.ErrF3Disabled +} +func (DisabledF3) GetManifest(_ context.Context) (*manifest.Manifest, error) { + return nil, api.ErrF3Disabled +} +func (DisabledF3) GetPowerTable(_ context.Context, _ types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, api.ErrF3Disabled +} +func (DisabledF3) GetF3PowerTable(_ context.Context, _ types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, api.ErrF3Disabled } +func (DisabledF3) IsEnabled() bool { return false } +func (DisabledF3) IsRunning() (bool, error) { return false, api.ErrF3Disabled } +func (DisabledF3) Progress() (gpbft.Instant, error) { return gpbft.Instant{}, api.ErrF3Disabled } +func (DisabledF3) ListParticipants() ([]api.F3Participant, error) { return nil, api.ErrF3Disabled } diff --git a/chain/lf3/mock/mock_f3.go b/chain/lf3/mock/mock_f3.go new file mode 100644 index 00000000000..a94f3c13211 --- /dev/null +++ b/chain/lf3/mock/mock_f3.go @@ -0,0 +1,97 @@ +package mock + +import ( + "context" + "sync" + + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-f3/gpbft" + "github.com/filecoin-project/go-f3/manifest" + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/chain/lf3" + "github.com/filecoin-project/lotus/chain/types" +) + +type MockF3API struct { + lk sync.Mutex + + latestCert *certs.FinalityCertificate + enabled bool + running bool +} + +func (m *MockF3API) GetOrRenewParticipationTicket(ctx context.Context, minerID uint64, previous api.F3ParticipationTicket, instances uint64) (api.F3ParticipationTicket, error) { + return api.F3ParticipationTicket{}, nil +} + +func (m *MockF3API) Participate(ctx context.Context, ticket api.F3ParticipationTicket) (api.F3ParticipationLease, error) { + return api.F3ParticipationLease{}, nil +} + +func (m *MockF3API) GetCert(ctx context.Context, instance uint64) (*certs.FinalityCertificate, error) { + return nil, nil +} + +func (m *MockF3API) SetLatestCert(cert *certs.FinalityCertificate) { + m.lk.Lock() + defer m.lk.Unlock() + + m.latestCert = cert +} + +func (m *MockF3API) GetLatestCert(ctx context.Context) (*certs.FinalityCertificate, error) { + m.lk.Lock() + defer m.lk.Unlock() + + return m.latestCert, nil +} + +func (m *MockF3API) GetManifest(ctx context.Context) (*manifest.Manifest, error) { + return nil, nil +} + +func (m *MockF3API) GetPowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, nil +} + +func (m *MockF3API) GetF3PowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, nil +} + +func (m *MockF3API) SetEnabled(enabled bool) { + m.lk.Lock() + defer m.lk.Unlock() + + m.enabled = enabled +} + +func (m *MockF3API) IsEnabled() bool { + m.lk.Lock() + defer m.lk.Unlock() + + return m.enabled +} + +func (m *MockF3API) SetRunning(running bool) { + m.lk.Lock() + defer m.lk.Unlock() + + m.running = running +} + +func (m *MockF3API) IsRunning() (bool, error) { + m.lk.Lock() + defer m.lk.Unlock() + + return m.running, nil +} + +func (m *MockF3API) Progress() (gpbft.Instant, error) { + return gpbft.Instant{}, nil +} + +func (m *MockF3API) ListParticipants() ([]api.F3Participant, error) { + return nil, nil +} + +var _ lf3.F3API = (*MockF3API)(nil) diff --git a/chain/tsresolver/tipset_resolver.go b/chain/tsresolver/tipset_resolver.go new file mode 100644 index 00000000000..599e0c89cc6 --- /dev/null +++ b/chain/tsresolver/tipset_resolver.go @@ -0,0 +1,162 @@ +package tsresolver + +import ( + "context" + "errors" + "fmt" + + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-state-types/abi" + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/chain/actors/policy" + "github.com/filecoin-project/lotus/chain/types" + "github.com/filecoin-project/lotus/chain/types/ethtypes" + logging "github.com/ipfs/go-log/v2" + "golang.org/x/xerrors" +) + +var log = logging.Logger("chain/tsresolver") + +const ( + EthBlockSelectorEarliest = "earliest" + EthBlockSelectorPending = "pending" + EthBlockSelectorLatest = "latest" + EthBlockSelectorSafe = "safe" + EthBlockSelectorFinalized = "finalized" +) + +type TipSetLoader interface { + GetHeaviestTipSet() (ts *types.TipSet) + LoadTipSet(ctx context.Context, tsk types.TipSetKey) (*types.TipSet, error) + GetTipsetByHeight(ctx context.Context, h abi.ChainEpoch, anchor *types.TipSet, prev bool) (*types.TipSet, error) +} + +type F3 interface { + GetLatestCert(ctx context.Context) (*certs.FinalityCertificate, error) + IsEnabled() bool + IsRunning() (bool, error) +} + +type TipSetResolver interface { + ResolveEthBlockSelector(ctx context.Context, selector string, strict bool) (*types.TipSet, error) +} + +type tipSetResolver struct { + loader TipSetLoader + f3 F3 +} + +var _ TipSetResolver = (*tipSetResolver)(nil) + +func NewTipSetResolver(loader TipSetLoader, f3 F3) TipSetResolver { + return &tipSetResolver{ + loader: loader, + f3: f3, + } +} + +// ResolveEthBlockSelector resolves an Ethereum block selector string to a TipSet. +// +// The selector can be one of: +// - "pending": the chain head +// - "latest": the TipSet with the latest executed messages (head - 1) +// - "safe": the TipSet with messages executed at least eth.SafeEpochDelay (30) epochs ago or the +// latest F3 finalized TipSet +// - "finalized": the TipSet with messages executed at least policy.ChainFinality (900) epochs ago +// or the latest F3 finalized TipSet +// - a decimal block number: the TipSet at the given height +// - a 0x-prefixed hex block number: the TipSet at the given height +// +// If a specific block number is specified and `strict` is true, an error is returned if the block +// number resolves to a null round, otherwise in the case of a null round the first non-null TipSet +// immediately before the null round is returned. +func (tsr *tipSetResolver) ResolveEthBlockSelector(ctx context.Context, selector string, strict bool) (*types.TipSet, error) { + switch selector { + case EthBlockSelectorEarliest: + return nil, fmt.Errorf(`block param "%s" is not supported`, EthBlockSelectorEarliest) + + case EthBlockSelectorPending: + return tsr.loader.GetHeaviestTipSet(), nil + + case EthBlockSelectorLatest: + // head - 1 because we're always one behind for Eth compatibility due to deferred execution + return tsr.loader.LoadTipSet(ctx, tsr.loader.GetHeaviestTipSet().Parents()) + + case EthBlockSelectorSafe, EthBlockSelectorFinalized: + defaultDelay := policy.ChainFinality + if selector == EthBlockSelectorSafe { + defaultDelay = ethtypes.SafeEpochDelay + } + + head := tsr.loader.GetHeaviestTipSet() + latestHeight := head.Height() - 1 // always one behind for Eth compatibility due to deferred execution + defaultHeight := latestHeight - defaultDelay + + if f3TipSet, err := tsr.getF3FinalizedTipSet(ctx); err != nil { + return nil, err + } else if f3TipSet != nil && f3TipSet.Height() > defaultHeight { + // return the parent of the finalized tipset (deferred execution, eth cares about t) + return tsr.loader.LoadTipSet(ctx, f3TipSet.Parents()) + } // else F3 is disabled, not running, or behind the default safe or finalized height + + ts, err := tsr.loader.GetTipsetByHeight(ctx, defaultHeight, head, true) + if err != nil { + return nil, xerrors.Errorf("loading tipset at height %v: %w", defaultHeight, err) + } + return ts, nil + + default: + // likely an 0x hex block number or a decimal block number + return tsr.resolveEthBlockNumberSelector(ctx, selector, strict) + } +} + +func (tsr *tipSetResolver) getF3FinalizedTipSet(ctx context.Context) (*types.TipSet, error) { + if !tsr.f3.IsEnabled() { + return nil, nil + } + if running, _ := tsr.f3.IsRunning(); !running { + return nil, nil + } + + cert, err := tsr.f3.GetLatestCert(ctx) + if err != nil { + log.Debugf("loading latest F3 certificate: %s", err) + return nil, nil + } + + tsk, err := types.TipSetKeyFromBytes(cert.ECChain.Head().Key) + if err != nil { + return nil, xerrors.Errorf("decoding tipset key reported by F3: %w", err) + } + + finalizedTipSet, err := tsr.loader.LoadTipSet(ctx, tsk) + if err != nil { + return nil, xerrors.Errorf("loading tipset reported as finalized by F3 %s: %w", tsk, err) + } + + return finalizedTipSet, nil +} + +func (tsr *tipSetResolver) resolveEthBlockNumberSelector(ctx context.Context, selector string, strict bool) (*types.TipSet, error) { + var num ethtypes.EthUint64 + if err := num.UnmarshalJSON([]byte(`"` + selector + `"`)); err != nil { + return nil, xerrors.Errorf("cannot parse block number: %v", err) + } + + head := tsr.loader.GetHeaviestTipSet() + if abi.ChainEpoch(num) > head.Height()-1 { + return nil, errors.New("requested a future epoch (beyond 'latest')") + } + + ts, err := tsr.loader.GetTipsetByHeight(ctx, abi.ChainEpoch(num), head, true) + if err != nil { + return nil, fmt.Errorf("cannot get tipset at height: %v", num) + } + + if strict && ts.Height() != abi.ChainEpoch(num) { + return nil, api.NewErrNullRound(abi.ChainEpoch(num)) + } + + return ts, nil +} diff --git a/chain/tsresolver/tipset_resolver_test.go b/chain/tsresolver/tipset_resolver_test.go new file mode 100644 index 00000000000..af7f757adf6 --- /dev/null +++ b/chain/tsresolver/tipset_resolver_test.go @@ -0,0 +1,217 @@ +package tsresolver_test + +import ( + "context" + "testing" + + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-f3/gpbft" + "github.com/filecoin-project/go-state-types/abi" + "github.com/filecoin-project/go-state-types/crypto" + "github.com/filecoin-project/lotus/chain/actors/builtin" + f3mock "github.com/filecoin-project/lotus/chain/lf3/mock" + "github.com/filecoin-project/lotus/chain/tsresolver" + "github.com/filecoin-project/lotus/chain/types" + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" +) + +var dummyCid = cid.MustParse("bafkqaaa") + +func TestResolveEthBlockSelector(t *testing.T) { + ctx := context.Background() + + t.Run("basic selectors", func(t *testing.T) { + req := require.New(t) + + parent := makeTestTipSet(t, 99, nil) + head := makeTestTipSet(t, 100, parent.Cids()) + + loader := &mockTipSetLoader{ + head: head, + tipsets: map[types.TipSetKey]*types.TipSet{ + parent.Key(): parent, + }, + } + + resolver := tsresolver.NewTipSetResolver(loader, &f3mock.MockF3API{}) + + ts, err := resolver.ResolveEthBlockSelector(ctx, "pending", true) + req.NoError(err) + req.Equal(head, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "latest", true) + req.NoError(err) + req.Equal(parent, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "earliest", true) + req.ErrorContains(err, "not supported") + req.Nil(ts) + }) + + for _, f3Status := range []string{"disabled", "stopped"} { + t.Run("safe/finalized with F3 "+f3Status, func(t *testing.T) { + req := require.New(t) + + head := makeTestTipSet(t, 1000, nil) + safe := makeTestTipSet(t, 969, nil) // head - 31 + final := makeTestTipSet(t, 99, nil) // head - 901 + + loader := &mockTipSetLoader{ + head: head, + byHeight: map[abi.ChainEpoch]*types.TipSet{ + 969: safe, + 99: final, + }, + } + + f3 := &f3mock.MockF3API{} + f3.SetEnabled(f3Status != "disabled") + f3.SetRunning(f3Status != "stopped") + + resolver := tsresolver.NewTipSetResolver(loader, f3) + + ts, err := resolver.ResolveEthBlockSelector(ctx, "safe", true) + req.NoError(err) + req.Equal(safe, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "finalized", true) + req.NoError(err) + req.Equal(final, ts) + }) + } + + t.Run("safe/finalized with F3 enabled and close to head", func(t *testing.T) { + // normal F3 operation, closer to head than default "safe" + + req := require.New(t) + + head := makeTestTipSet(t, 1000, nil) + finalzedParent := makeTestTipSet(t, 994, nil) + finalzed := makeTestTipSet(t, 995, finalzedParent.Cids()) + + loader := &mockTipSetLoader{ + head: head, + tipsets: map[types.TipSetKey]*types.TipSet{ + finalzedParent.Key(): finalzedParent, + finalzed.Key(): finalzed, + }, + } + + f3 := &f3mock.MockF3API{} + f3.SetEnabled(true) + f3.SetRunning(true) + f3.SetLatestCert(&certs.FinalityCertificate{ + ECChain: gpbft.ECChain{ + gpbft.TipSet{Key: finalzed.Key().Bytes()}, + }, + }) + + resolver := tsresolver.NewTipSetResolver(loader, f3) + + ts, err := resolver.ResolveEthBlockSelector(ctx, "safe", true) + req.NoError(err) + req.Equal(finalzedParent, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "finalized", true) + req.NoError(err) + req.Equal(finalzedParent, ts) + }) + + t.Run("safe/finalized with F3 enabled and far from head", func(t *testing.T) { + // F3 is running, but delayed longer than EC, so expect fall-back to EC behaviour + + req := require.New(t) + + head := makeTestTipSet(t, 1000, nil) + safe := makeTestTipSet(t, 969, nil) // head - 31 + final := makeTestTipSet(t, 99, nil) // head - 901 + finalzed := makeTestTipSet(t, 10, nil) // head - 990 + + loader := &mockTipSetLoader{ + head: head, + tipsets: map[types.TipSetKey]*types.TipSet{ + finalzed.Key(): finalzed, + }, + byHeight: map[abi.ChainEpoch]*types.TipSet{ + 969: safe, + 99: final, + }, + } + + f3 := &f3mock.MockF3API{} + f3.SetEnabled(true) + f3.SetRunning(true) + f3.SetLatestCert(&certs.FinalityCertificate{ + ECChain: gpbft.ECChain{ + gpbft.TipSet{Key: finalzed.Key().Bytes()}, + }, + }) + + resolver := tsresolver.NewTipSetResolver(loader, f3) + + ts, err := resolver.ResolveEthBlockSelector(ctx, "safe", true) + req.NoError(err) + req.Equal(safe, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "finalized", true) + req.NoError(err) + req.Equal(final, ts) + }) + + t.Run("block number resolution", func(t *testing.T) { + req := require.New(t) + + head := makeTestTipSet(t, 100, nil) + target := makeTestTipSet(t, 42, nil) + + loader := &mockTipSetLoader{ + head: head, + byHeight: map[abi.ChainEpoch]*types.TipSet{ + 42: target, + }, + } + + resolver := tsresolver.NewTipSetResolver(loader, &f3mock.MockF3API{}) + + ts, err := resolver.ResolveEthBlockSelector(ctx, "42", true) + req.NoError(err) + req.Equal(target, ts) + + ts, err = resolver.ResolveEthBlockSelector(ctx, "0x2a", true) + req.NoError(err) + req.Equal(target, ts) + }) +} + +func makeTestTipSet(t *testing.T, height int64, parents []cid.Cid) *types.TipSet { + if parents == nil { + parents = []cid.Cid{dummyCid, dummyCid} + } + ts, err := types.NewTipSet([]*types.BlockHeader{{ + Miner: builtin.SystemActorAddr, + Height: abi.ChainEpoch(height), + ParentStateRoot: dummyCid, + Messages: dummyCid, + ParentMessageReceipts: dummyCid, + BlockSig: &crypto.Signature{Type: crypto.SigTypeBLS}, + BLSAggregate: &crypto.Signature{Type: crypto.SigTypeBLS}, + Parents: parents, + }}) + require.NoError(t, err) + return ts +} + +type mockTipSetLoader struct { + head *types.TipSet + tipsets map[types.TipSetKey]*types.TipSet + byHeight map[abi.ChainEpoch]*types.TipSet +} + +func (m *mockTipSetLoader) GetHeaviestTipSet() *types.TipSet { return m.head } +func (m *mockTipSetLoader) LoadTipSet(_ context.Context, tsk types.TipSetKey) (*types.TipSet, error) { + return m.tipsets[tsk], nil +} +func (m *mockTipSetLoader) GetTipsetByHeight(_ context.Context, h abi.ChainEpoch, _ *types.TipSet, _ bool) (*types.TipSet, error) { + return m.byHeight[h], nil +} diff --git a/itests/eth_api_test.go b/itests/eth_api_test.go index 0838efa7bef..35fad0af516 100644 --- a/itests/eth_api_test.go +++ b/itests/eth_api_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "github.com/filecoin-project/go-address" + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-f3/gpbft" "github.com/filecoin-project/go-jsonrpc" "github.com/filecoin-project/go-state-types/abi" "github.com/filecoin-project/go-state-types/big" @@ -22,6 +24,7 @@ import ( "github.com/filecoin-project/lotus/build/buildconstants" "github.com/filecoin-project/lotus/chain/actors/policy" + f3mock "github.com/filecoin-project/lotus/chain/lf3/mock" "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/types/ethtypes" "github.com/filecoin-project/lotus/chain/wallet/key" @@ -396,42 +399,102 @@ func TestNetVersion(t *testing.T) { func TestEthBlockNumberAliases(t *testing.T) { blockTime := 2 * time.Millisecond kit.QuietMiningLogs() - client, _, ens := kit.EnsembleMinimal(t, kit.MockProofs(), kit.ThroughRPC()) - ens.InterconnectAll().BeginMining(blockTime) - ens.Start() - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() + testCases := []struct { + name string + f3Enabled bool + f3DelayEpochs int + expectedSafeEpoch int + expectedFinalizedEpoch int + }{ + { + name: "f3 disabled", + f3Enabled: false, + expectedSafeEpoch: 31, + expectedFinalizedEpoch: 901, + }, + { + name: "f3 enabled, close to head", + f3Enabled: true, + f3DelayEpochs: 5, + expectedSafeEpoch: 6, + expectedFinalizedEpoch: 6, + }, + { + name: "f3 enabled, far from head", + f3Enabled: true, + f3DelayEpochs: 1000, + expectedSafeEpoch: 31, + expectedFinalizedEpoch: 901, + }, + } - client.WaitTillChain(ctx, kit.HeightAtLeast(policy.ChainFinality+100)) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := require.New(t) - for _, tc := range []struct { - param string - expectedLag abi.ChainEpoch - }{ - {"latest", 1}, // head - 1 - {"safe", 30 + 1}, // "latest" - 30 - {"finalized", policy.ChainFinality + 1}, // "latest" - 900 - } { - t.Run(tc.param, func(t *testing.T) { - head, err := client.ChainHead(ctx) - require.NoError(t, err) - var blk ethtypes.EthBlock - for { // get a block while retaining a stable "head" reference - blk, err = client.EVM().EthGetBlockByNumber(ctx, tc.param, true) - require.NoError(t, err) - afterHead, err := client.ChainHead(ctx) - require.NoError(t, err) - if afterHead.Height() == head.Height() { - break - } - // else: whoops, we had a chain increment between getting head and getting "latest" so - // we won't be able to use head as a stable reference for comparison - head = afterHead + opts := []any{kit.MockProofs(), kit.ThroughRPC()} + f3 := &f3mock.MockF3API{} + if tc.f3Enabled { + opts = append(opts, kit.WithF3API(f3)) + f3.SetEnabled(true) + f3.SetRunning(true) + } + + client, _, ens := kit.EnsembleMinimal(t, opts...) + ens.InterconnectAll().BeginMining(blockTime) + ens.Start() + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + client.WaitTillChain(ctx, kit.HeightAtLeast(policy.ChainFinality+100)) + + for _, query := range []struct { + param string + expectedLag abi.ChainEpoch + }{ + {"latest", 1}, // head - 1 + {"safe", abi.ChainEpoch(tc.expectedSafeEpoch)}, + {"finalized", abi.ChainEpoch(tc.expectedFinalizedEpoch)}, + } { + t.Run(query.param, func(t *testing.T) { + head, err := client.ChainHead(ctx) + req.NoError(err) + + var blk ethtypes.EthBlock + for { + // loop here until we get all of the operations performed in a single epoch, if the + // operations span multiple epochs our numbers will be off + + if tc.f3Enabled { + ts, err := client.ChainGetTipSetByHeight(ctx, head.Height()-abi.ChainEpoch(tc.f3DelayEpochs), head.Key()) + req.NoError(err) + f3.SetLatestCert(&certs.FinalityCertificate{ + ECChain: gpbft.ECChain{ + gpbft.TipSet{Key: ts.Key().Bytes()}, + }, + }) + } + + blk, err = client.EVM().EthGetBlockByNumber(ctx, query.param, true) + req.NoError(err) + afterHead, err := client.ChainHead(ctx) + req.NoError(err) + if afterHead.Height() == head.Height() { + break + } + + // else: whoops, we had a chain increment between getting head and getting "latest" so + // we won't be able to use head as a stable reference for comparison + head = afterHead + } + + ts, err := client.ChainGetTipSetByHeight(ctx, head.Height()-query.expectedLag, head.Key()) + req.NoError(err) + req.Equal(int(ts.Height()), int(blk.Number)) + }) } - ts, err := client.ChainGetTipSetByHeight(ctx, head.Height()-tc.expectedLag, head.Key()) - require.NoError(t, err) - require.EqualValues(t, ts.Height(), blk.Number) }) } } diff --git a/itests/kit/node_opts.go b/itests/kit/node_opts.go index 1bfd4550977..9f90d4246ad 100644 --- a/itests/kit/node_opts.go +++ b/itests/kit/node_opts.go @@ -255,6 +255,11 @@ func F3Enabled(cfg *lf3.Config) NodeOpt { ) } +// WithF3API replaces the node's F3API with the provided one. Useful for mocking F3 behavior. +func WithF3API(f3 lf3.F3API) NodeOpt { + return ConstructorOpts(node.Override(new(lf3.F3API), func() lf3.F3API { return f3 })) +} + // SectorSize sets the sector size for this miner. Start() will populate the // corresponding proof type depending on the network version (genesis network // version if the Ensemble is unstarted, or the current network version diff --git a/node/builder_chain.go b/node/builder_chain.go index 72d6f2ee7f1..ee0dd5f50bb 100644 --- a/node/builder_chain.go +++ b/node/builder_chain.go @@ -31,6 +31,7 @@ import ( "github.com/filecoin-project/lotus/chain/stmgr" rpcstmgr "github.com/filecoin-project/lotus/chain/stmgr/rpc" "github.com/filecoin-project/lotus/chain/store" + "github.com/filecoin-project/lotus/chain/tsresolver" "github.com/filecoin-project/lotus/chain/vm" "github.com/filecoin-project/lotus/chain/wallet" ledgerwallet "github.com/filecoin-project/lotus/chain/wallet/ledger" @@ -169,8 +170,15 @@ var ChainNode = Options( If(build.IsF3Enabled(), Override(new(*lf3.Config), lf3.NewConfig), Override(new(manifest.ManifestProvider), lf3.NewManifestProvider), - Override(new(*lf3.F3), lf3.New), + Override(new(lf3.F3API), lf3.New), ), + If(!build.IsF3Enabled(), + Override(new(lf3.F3API), func() lf3.F3API { return lf3.DisabledF3{} }), + ), + + Override(new(tsresolver.F3), From(new(lf3.F3API))), + Override(new(tsresolver.TipSetLoader), From(new(*store.ChainStore))), + Override(new(tsresolver.TipSetResolver), tsresolver.NewTipSetResolver), ) func ConfigFullNode(c interface{}) Option { diff --git a/node/impl/full/eth.go b/node/impl/full/eth.go index cb1b66aa48c..ec163170656 100644 --- a/node/impl/full/eth.go +++ b/node/impl/full/eth.go @@ -37,6 +37,7 @@ import ( "github.com/filecoin-project/lotus/chain/messagepool" "github.com/filecoin-project/lotus/chain/stmgr" "github.com/filecoin-project/lotus/chain/store" + "github.com/filecoin-project/lotus/chain/tsresolver" "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/types/ethtypes" "github.com/filecoin-project/lotus/node/modules/dtypes" @@ -138,6 +139,7 @@ type EthModule struct { StateManager *stmgr.StateManager EthTraceFilterMaxResults uint64 EthEventHandler *EthEventHandler + TipSetResolver tsresolver.TipSetResolver EthBlkCache *arc.ARCCache[cid.Cid, *ethtypes.EthBlock] // caches blocks by their CID but blocks only have the transaction hashes EthBlkTxCache *arc.ARCCache[cid.Cid, *ethtypes.EthBlock] // caches blocks along with full transaction payload by their CID @@ -168,10 +170,11 @@ var _ EthEventAPI = (*EthEventHandler)(nil) type EthAPI struct { fx.In - Chain *store.ChainStore - StateManager *stmgr.StateManager - ChainIndexer index.Indexer - MpoolAPI MpoolAPI + Chain *store.ChainStore + StateManager *stmgr.StateManager + ChainIndexer index.Indexer + MpoolAPI MpoolAPI + TipSetResolver tsresolver.TipSetResolver EthModuleAPI EthEventAPI @@ -241,9 +244,9 @@ func (a *EthAPI) FilecoinAddressToEthAddress(ctx context.Context, p jsonrpc.RawP blkParam = *params.BlkParam } - ts, err := getTipsetByBlockNumber(ctx, a.Chain, blkParam, false) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, blkParam, false) if err != nil { - return ethtypes.EthAddress{}, err + return ethtypes.EthAddress{}, xerrors.Errorf("failed to resolve block param: %s: %w", blkParam, err) } // Lookup the ID address @@ -339,10 +342,11 @@ func (a *EthModule) EthGetBlockByHash(ctx context.Context, blkHash ethtypes.EthH } func (a *EthModule) EthGetBlockByNumber(ctx context.Context, blkParam string, fullTxInfo bool) (ethtypes.EthBlock, error) { - ts, err := getTipsetByBlockNumber(ctx, a.Chain, blkParam, true) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, blkParam, true) if err != nil { - return ethtypes.EthBlock{}, err + return ethtypes.EthBlock{}, xerrors.Errorf("failed to resolve block param: %s: %w", blkParam, err) } + return newEthBlockFromFilecoinTipSet(ctx, ts, fullTxInfo, a.Chain, a.StateAPI) } @@ -592,9 +596,9 @@ func (a *EthAPI) EthGetTransactionByBlockHashAndIndex(ctx context.Context, blkHa } func (a *EthAPI) EthGetTransactionByBlockNumberAndIndex(ctx context.Context, blkParam string, index ethtypes.EthUint64) (*ethtypes.EthTx, error) { - ts, err := getTipsetByBlockNumber(ctx, a.Chain, blkParam, true) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, blkParam, true) if err != nil { - return nil, err + return nil, xerrors.Errorf("failed to resolve block param: %s: %w", blkParam, err) } if ts == nil { @@ -963,9 +967,9 @@ func (a *EthModule) EthFeeHistory(ctx context.Context, p jsonrpc.RawParams) (eth } } - ts, err := getTipsetByBlockNumber(ctx, a.Chain, params.NewestBlkNum, false) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, params.NewestBlkNum, false) if err != nil { - return ethtypes.EthFeeHistory{}, err + return ethtypes.EthFeeHistory{}, xerrors.Errorf("failed to resolve newestBlock param: %w", err) } var ( @@ -1124,9 +1128,9 @@ func (a *EthModule) Web3ClientVersion(ctx context.Context) (string, error) { } func (a *EthModule) EthTraceBlock(ctx context.Context, blkNum string) ([]*ethtypes.EthTraceBlock, error) { - ts, err := getTipsetByBlockNumber(ctx, a.Chain, blkNum, true) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, blkNum, true) if err != nil { - return nil, err + return nil, xerrors.Errorf("failed to resolve block param: %w", err) } stRoot, trace, err := a.StateManager.ExecutionTrace(ctx, ts) @@ -1195,9 +1199,9 @@ func (a *EthModule) EthTraceReplayBlockTransactions(ctx context.Context, blkNum if len(traceTypes) != 1 || traceTypes[0] != "trace" { return nil, fmt.Errorf("only 'trace' is supported") } - ts, err := getTipsetByBlockNumber(ctx, a.Chain, blkNum, true) + ts, err := a.TipSetResolver.ResolveEthBlockSelector(ctx, blkNum, true) if err != nil { - return nil, err + return nil, xerrors.Errorf("failed to resolve block param: %w", err) } stRoot, trace, err := a.StateManager.ExecutionTrace(ctx, ts) diff --git a/node/impl/full/eth_utils.go b/node/impl/full/eth_utils.go index 00f3de90163..9063c1fe63f 100644 --- a/node/impl/full/eth_utils.go +++ b/node/impl/full/eth_utils.go @@ -23,7 +23,6 @@ import ( "github.com/filecoin-project/lotus/build/buildconstants" "github.com/filecoin-project/lotus/chain/actors" "github.com/filecoin-project/lotus/chain/actors/builtin" - "github.com/filecoin-project/lotus/chain/actors/policy" "github.com/filecoin-project/lotus/chain/state" "github.com/filecoin-project/lotus/chain/store" "github.com/filecoin-project/lotus/chain/types" @@ -43,57 +42,6 @@ func init() { } } -func getTipsetByBlockNumber(ctx context.Context, chain *store.ChainStore, blkParam string, strict bool) (*types.TipSet, error) { - if blkParam == "earliest" { - return nil, fmt.Errorf("block param \"earliest\" is not supported") - } - - head := chain.GetHeaviestTipSet() - switch blkParam { - case "pending": - return head, nil - case "latest": - parent, err := chain.GetTipSetFromKey(ctx, head.Parents()) - if err != nil { - return nil, fmt.Errorf("cannot get parent tipset") - } - return parent, nil - case "safe": - latestHeight := head.Height() - 1 - safeHeight := latestHeight - ethtypes.SafeEpochDelay - ts, err := chain.GetTipsetByHeight(ctx, safeHeight, head, true) - if err != nil { - return nil, fmt.Errorf("cannot get tipset at height: %v", safeHeight) - } - return ts, nil - case "finalized": - latestHeight := head.Height() - 1 - safeHeight := latestHeight - policy.ChainFinality - ts, err := chain.GetTipsetByHeight(ctx, safeHeight, head, true) - if err != nil { - return nil, fmt.Errorf("cannot get tipset at height: %v", safeHeight) - } - return ts, nil - default: - var num ethtypes.EthUint64 - err := num.UnmarshalJSON([]byte(`"` + blkParam + `"`)) - if err != nil { - return nil, fmt.Errorf("cannot parse block number: %v", err) - } - if abi.ChainEpoch(num) > head.Height()-1 { - return nil, fmt.Errorf("requested a future epoch (beyond 'latest')") - } - ts, err := chain.GetTipsetByHeight(ctx, abi.ChainEpoch(num), head, true) - if err != nil { - return nil, fmt.Errorf("cannot get tipset at height: %v", num) - } - if strict && ts.Height() != abi.ChainEpoch(num) { - return nil, api.NewErrNullRound(abi.ChainEpoch(num)) - } - return ts, nil - } -} - func getTipsetByEthBlockNumberOrHash(ctx context.Context, chain *store.ChainStore, blkParam ethtypes.EthBlockNumberOrHash) (*types.TipSet, error) { head := chain.GetHeaviestTipSet() diff --git a/node/impl/full/f3.go b/node/impl/full/f3.go index 118099dfcc0..906b2832d0a 100644 --- a/node/impl/full/f3.go +++ b/node/impl/full/f3.go @@ -19,11 +19,11 @@ import ( type F3API struct { fx.In - F3 *lf3.F3 `optional:"true"` + F3 lf3.F3API } func (f3api *F3API) F3GetOrRenewParticipationTicket(ctx context.Context, miner address.Address, previous api.F3ParticipationTicket, instances uint64) (api.F3ParticipationTicket, error) { - if f3api.F3 == nil { + if !f3api.F3.IsEnabled() { log.Infof("F3GetParticipationTicket called for %v, F3 is disabled", miner) return api.F3ParticipationTicket{}, api.ErrF3Disabled } @@ -35,8 +35,7 @@ func (f3api *F3API) F3GetOrRenewParticipationTicket(ctx context.Context, miner a } func (f3api *F3API) F3Participate(ctx context.Context, ticket api.F3ParticipationTicket) (api.F3ParticipationLease, error) { - - if f3api.F3 == nil { + if !f3api.F3.IsEnabled() { log.Infof("F3Participate called, F3 is disabled") return api.F3ParticipationLease{}, api.ErrF3Disabled } @@ -44,57 +43,33 @@ func (f3api *F3API) F3Participate(ctx context.Context, ticket api.F3Participatio } func (f3api *F3API) F3GetCertificate(ctx context.Context, instance uint64) (*certs.FinalityCertificate, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } return f3api.F3.GetCert(ctx, instance) } func (f3api *F3API) F3GetLatestCertificate(ctx context.Context) (*certs.FinalityCertificate, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } return f3api.F3.GetLatestCert(ctx) } func (f3api *F3API) F3GetManifest(ctx context.Context) (*manifest.Manifest, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } - return f3api.F3.GetManifest(ctx), nil + return f3api.F3.GetManifest(ctx) } func (f3api *F3API) F3IsRunning(context.Context) (bool, error) { - if f3api.F3 == nil { - return false, api.ErrF3Disabled - } - return f3api.F3.IsRunning(), nil + return f3api.F3.IsRunning() } func (f3api *F3API) F3GetECPowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } return f3api.F3.GetPowerTable(ctx, tsk) } func (f3api *F3API) F3GetF3PowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } return f3api.F3.GetF3PowerTable(ctx, tsk) } func (f3api *F3API) F3GetProgress(context.Context) (gpbft.Instant, error) { - if f3api.F3 == nil { - return gpbft.Instant{}, api.ErrF3Disabled - } - return f3api.F3.Progress(), nil + return f3api.F3.Progress() } func (f3api *F3API) F3ListParticipants(context.Context) ([]api.F3Participant, error) { - if f3api.F3 == nil { - return nil, api.ErrF3Disabled - } - return f3api.F3.ListParticipants(), nil + return f3api.F3.ListParticipants() } diff --git a/node/modules/ethmodule.go b/node/modules/ethmodule.go index 61d957b7fad..997abc0ddc6 100644 --- a/node/modules/ethmodule.go +++ b/node/modules/ethmodule.go @@ -14,6 +14,7 @@ import ( "github.com/filecoin-project/lotus/chain/messagepool" "github.com/filecoin-project/lotus/chain/stmgr" "github.com/filecoin-project/lotus/chain/store" + "github.com/filecoin-project/lotus/chain/tsresolver" "github.com/filecoin-project/lotus/chain/types/ethtypes" "github.com/filecoin-project/lotus/node/config" "github.com/filecoin-project/lotus/node/impl/full" @@ -21,11 +22,39 @@ import ( "github.com/filecoin-project/lotus/node/repo" ) -func EthModuleAPI(cfg config.FevmConfig) func(helpers.MetricsCtx, repo.LockedRepo, fx.Lifecycle, *store.ChainStore, *stmgr.StateManager, - EventHelperAPI, *messagepool.MessagePool, full.StateAPI, full.ChainAPI, full.MpoolAPI, full.SyncAPI, *full.EthEventHandler, index.Indexer) (*full.EthModule, error) { - return func(mctx helpers.MetricsCtx, r repo.LockedRepo, lc fx.Lifecycle, cs *store.ChainStore, sm *stmgr.StateManager, evapi EventHelperAPI, - mp *messagepool.MessagePool, stateapi full.StateAPI, chainapi full.ChainAPI, mpoolapi full.MpoolAPI, syncapi full.SyncAPI, - ethEventHandler *full.EthEventHandler, chainIndexer index.Indexer) (*full.EthModule, error) { +func EthModuleAPI(cfg config.FevmConfig) func( + helpers.MetricsCtx, + repo.LockedRepo, + fx.Lifecycle, + *store.ChainStore, + *stmgr.StateManager, + EventHelperAPI, + *messagepool.MessagePool, + full.StateAPI, + full.ChainAPI, + full.MpoolAPI, + full.SyncAPI, + *full.EthEventHandler, + index.Indexer, + tsresolver.TipSetResolver, +) (*full.EthModule, error) { + + return func( + mctx helpers.MetricsCtx, + r repo.LockedRepo, + lc fx.Lifecycle, + cs *store.ChainStore, + sm *stmgr.StateManager, + evapi EventHelperAPI, + mp *messagepool.MessagePool, + stateapi full.StateAPI, + chainapi full.ChainAPI, + mpoolapi full.MpoolAPI, + syncapi full.SyncAPI, + ethEventHandler *full.EthEventHandler, + chainIndexer index.Indexer, + tipsetResolver tsresolver.TipSetResolver, + ) (*full.EthModule, error) { // prefill the whole skiplist cache maintained internally by the GetTipsetByHeight go func() { @@ -69,7 +98,8 @@ func EthModuleAPI(cfg config.FevmConfig) func(helpers.MetricsCtx, repo.LockedRep EthBlkCache: blkCache, EthBlkTxCache: blkTxCache, - ChainIndexer: chainIndexer, + ChainIndexer: chainIndexer, + TipSetResolver: tipsetResolver, }, nil } }