diff --git a/CHANGELOG.md b/CHANGELOG.md index 13867e6df..053e4e936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ # UNRELEASED - fix(eth): always return nil for eth transactions not found ([filecoin-project/lotus#12999](https://github.com/filecoin-project/lotus/pull/12999)) +- feat: add experimental v2 APIs that are "F3 aware." (TODO: expand this section significantly to cover where someone learns about the new APIs, how they enable them, and what expectations they should have around them—i.e., they may change) # Node and Miner v1.32.2 / 2025-04-04 diff --git a/api/docgen/docgen.go b/api/docgen/docgen.go index 035bc6559..37265c062 100644 --- a/api/docgen/docgen.go +++ b/api/docgen/docgen.go @@ -38,6 +38,7 @@ import ( "github.com/filecoin-project/lotus/api" apitypes "github.com/filecoin-project/lotus/api/types" "github.com/filecoin-project/lotus/api/v0api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/build/buildconstants" "github.com/filecoin-project/lotus/chain/actors/builtin/miner" "github.com/filecoin-project/lotus/chain/actors/builtin/verifreg" @@ -468,6 +469,7 @@ func init() { }, Input: ecchain, }) + addExample(types.TipSetSelectors.Finalized) } func GetAPIType(name, pkg string) (i interface{}, t reflect.Type, permStruct []reflect.Type) { @@ -508,6 +510,15 @@ func GetAPIType(name, pkg string) (i interface{}, t reflect.Type, permStruct []r default: panic("unknown type") } + case "v2api": + switch name { + case "FullNode": + i = &v2api.FullNodeStruct{} + t = reflect.TypeOf(new(struct{ v2api.FullNode })).Elem() + permStruct = append(permStruct, reflect.TypeOf(v2api.FullNodeStruct{}.Internal)) + default: + panic("unknown type") + } } return } diff --git a/api/v2api/doc.go b/api/v2api/doc.go new file mode 100644 index 000000000..e00eab4b3 --- /dev/null +++ b/api/v2api/doc.go @@ -0,0 +1,5 @@ +// Package v2api provides an interface for interacting with the v2 full node APIs +// within the Filecoin network. +// +// This package is experimental and the API may change without notice. +package v2api diff --git a/api/v2api/full.go b/api/v2api/full.go new file mode 100644 index 000000000..d1888b50b --- /dev/null +++ b/api/v2api/full.go @@ -0,0 +1,52 @@ +package v2api + +import ( + "context" + + "github.com/filecoin-project/lotus/chain/types" +) + +//go:generate go run github.com/golang/mock/mockgen -destination=v2mocks/mock_full.go -package=v2mocks . FullNode + +// FullNode represents an interface for the v2 full node APIs. This interface +// currently consists of chain-related functionalities and the API is +// experimental and subject to change. +type FullNode interface { + // MethodGroup: Chain + // The Chain method group contains methods for interacting with + // the blockchain. + // + // Note: This API is experimental and may change in the future. + // + // Please see Filecoin V2 API design documentation for more details: + // - https://www.notion.so/filecoindev/Lotus-F3-aware-APIs-1cfdc41950c180ae97fef580e79427d5 + // - https://www.notion.so/filecoindev/Filecoin-V2-APIs-1d0dc41950c1808b914de5966d501658 + + // ChainGetTipSet retrieves a tipset that corresponds to the specified selector + // criteria. The criteria can be provided in the form of a tipset key, a + // blockchain height including an optional fallback to previous non-null tipset, + // or a designated tag such as "latest" or "finalized". + // + // The "Finalized" tag returns the tipset that is considered finalized based on + // the consensus protocol of the current node, either Filecoin EC Finality or + // Filecoin Fast Finality (F3). The finalized tipset selection gracefully falls + // back to EC finality in cases where F3 isn't ready or not running. + // + // In a case where no selector is provided, an error is returned. The selector + // must be explicitly specified. + // + // For more details, refer to the types.TipSetSelector and + // types.NewTipSetSelector. + // + // Example usage: + // + // selector := types.TipSetSelectors.Latest + // tipSet, err := node.ChainGetTipSet(context.Background(), selector) + // if err != nil { + // fmt.Println("Error retrieving tipset:", err) + // return + // } + // fmt.Printf("Latest TipSet: %v\n", tipSet) + // + ChainGetTipSet(context.Context, types.TipSetSelector) (*types.TipSet, error) //perm:read +} diff --git a/api/v2api/permissioned.go b/api/v2api/permissioned.go new file mode 100644 index 000000000..c15e8aea6 --- /dev/null +++ b/api/v2api/permissioned.go @@ -0,0 +1,13 @@ +package v2api + +import ( + "github.com/filecoin-project/go-jsonrpc/auth" + + "github.com/filecoin-project/lotus/api" +) + +func PermissionedFullAPI(a FullNode) FullNode { + var out FullNodeStruct + auth.PermissionedProxy(api.AllPermissions, api.DefaultPerms, a, &out.Internal) + return &out +} diff --git a/api/v2api/proxy_gen.go b/api/v2api/proxy_gen.go new file mode 100644 index 000000000..7ccbffd2b --- /dev/null +++ b/api/v2api/proxy_gen.go @@ -0,0 +1,37 @@ +// Code generated by github.com/filecoin-project/lotus/gen/api. DO NOT EDIT. + +package v2api + +import ( + "context" + + "golang.org/x/xerrors" + + "github.com/filecoin-project/lotus/chain/types" +) + +var ErrNotSupported = xerrors.New("method not supported") + +type FullNodeStruct struct { + Internal FullNodeMethods +} + +type FullNodeMethods struct { + ChainGetTipSet func(p0 context.Context, p1 types.TipSetSelector) (*types.TipSet, error) `perm:"read"` +} + +type FullNodeStub struct { +} + +func (s *FullNodeStruct) ChainGetTipSet(p0 context.Context, p1 types.TipSetSelector) (*types.TipSet, error) { + if s.Internal.ChainGetTipSet == nil { + return nil, ErrNotSupported + } + return s.Internal.ChainGetTipSet(p0, p1) +} + +func (s *FullNodeStub) ChainGetTipSet(p0 context.Context, p1 types.TipSetSelector) (*types.TipSet, error) { + return nil, ErrNotSupported +} + +var _ FullNode = new(FullNodeStruct) diff --git a/api/v2api/v2mocks/mock_full.go b/api/v2api/v2mocks/mock_full.go new file mode 100644 index 000000000..e108bcb07 --- /dev/null +++ b/api/v2api/v2mocks/mock_full.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/filecoin-project/lotus/api/v2api (interfaces: FullNode) + +// Package v2mocks is a generated GoMock package. +package v2mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + + types "github.com/filecoin-project/lotus/chain/types" +) + +// MockFullNode is a mock of FullNode interface. +type MockFullNode struct { + ctrl *gomock.Controller + recorder *MockFullNodeMockRecorder +} + +// MockFullNodeMockRecorder is the mock recorder for MockFullNode. +type MockFullNodeMockRecorder struct { + mock *MockFullNode +} + +// NewMockFullNode creates a new mock instance. +func NewMockFullNode(ctrl *gomock.Controller) *MockFullNode { + mock := &MockFullNode{ctrl: ctrl} + mock.recorder = &MockFullNodeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFullNode) EXPECT() *MockFullNodeMockRecorder { + return m.recorder +} + +// ChainGetTipSet mocks base method. +func (m *MockFullNode) ChainGetTipSet(arg0 context.Context, arg1 types.TipSetSelector) (*types.TipSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChainGetTipSet", arg0, arg1) + ret0, _ := ret[0].(*types.TipSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChainGetTipSet indicates an expected call of ChainGetTipSet. +func (mr *MockFullNodeMockRecorder) ChainGetTipSet(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChainGetTipSet", reflect.TypeOf((*MockFullNode)(nil).ChainGetTipSet), arg0, arg1) +} diff --git a/build/openrpc/v2/full.json b/build/openrpc/v2/full.json new file mode 100644 index 000000000..d450cb8d3 --- /dev/null +++ b/build/openrpc/v2/full.json @@ -0,0 +1,95 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Lotus RPC API", + "version": "1.32.3-dev" + }, + "methods": [ + { + "name": "Filecoin.ChainGetTipSet", + "description": "```go\nfunc (s *FullNodeStruct) ChainGetTipSet(p0 context.Context, p1 types.TipSetSelector) (*types.TipSet, error) {\n\tif s.Internal.ChainGetTipSet == nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\treturn s.Internal.ChainGetTipSet(p0, p1)\n}\n```", + "summary": "ChainGetTipSet retrieves a tipset that corresponds to the specified selector\ncriteria. The criteria can be provided in the form of a tipset key, a\nblockchain height including an optional fallback to previous non-null tipset,\nor a designated tag such as \"latest\" or \"finalized\".\n\nThe \"Finalized\" tag returns the tipset that is considered finalized based on\nthe consensus protocol of the current node, either Filecoin EC Finality or\nFilecoin Fast Finality (F3). The finalized tipset selection gracefully falls\nback to EC finality in cases where F3 isn't ready or not running.\n\nIn a case where no selector is provided, an error is returned. The selector\nmust be explicitly specified.\n\nFor more details, refer to the types.TipSetSelector and\ntypes.NewTipSetSelector.\n\nExample usage:\n\n\tselector := types.TipSetSelectors.Latest\n\ttipSet, err := node.ChainGetTipSet(context.Background(), selector)\n\tif err != nil {\n\t\tfmt.Println(\"Error retrieving tipset:\", err)\n\t\treturn\n\t}\n\tfmt.Printf(\"Latest TipSet: %v\\n\", tipSet)\n", + "paramStructure": "by-position", + "params": [ + { + "name": "p1", + "description": "types.TipSetSelector", + "summary": "", + "schema": { + "examples": [ + { + "tag": "finalized" + } + ], + "additionalProperties": false, + "properties": { + "height": { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "key": { + "additionalProperties": false, + "type": "object" + }, + "tag": { + "type": "string" + } + }, + "type": "object" + }, + "at": { + "title": "number", + "type": "number" + }, + "previous": { + "type": "boolean" + } + }, + "type": "object" + }, + "key": { + "additionalProperties": false, + "type": "object" + }, + "tag": { + "type": "string" + } + }, + "type": [ + "object" + ] + }, + "required": true, + "deprecated": false + } + ], + "result": { + "name": "*types.TipSet", + "description": "*types.TipSet", + "summary": "", + "schema": { + "examples": [ + { + "Cids": null, + "Blocks": null, + "Height": 0 + } + ], + "additionalProperties": false, + "type": [ + "object" + ] + }, + "required": true, + "deprecated": false + }, + "deprecated": false, + "externalDocs": { + "description": "Github remote link", + "url": "https://github.com/filecoin-project/lotus/blob/master/api/v2api/proxy_gen.go#L26" + } + } + ] +} \ No newline at end of file diff --git a/chain/lf3/f3.go b/chain/lf3/f3.go index 06a407b40..048f3028b 100644 --- a/chain/lf3/f3.go +++ b/chain/lf3/f3.go @@ -29,6 +29,21 @@ import ( "github.com/filecoin-project/lotus/node/repo" ) +var _ F3Backend = (*F3)(nil) + +type F3Backend interface { + GetOrRenewParticipationTicket(_ context.Context, minerID uint64, previous api.F3ParticipationTicket, instances uint64) (api.F3ParticipationTicket, error) + Participate(_ context.Context, ticket api.F3ParticipationTicket) (api.F3ParticipationLease, error) + ListParticipants() []api.F3Participant + GetManifest(ctx context.Context) (*manifest.Manifest, error) + GetCert(ctx context.Context, instance uint64) (*certs.FinalityCertificate, error) + GetLatestCert(ctx context.Context) (*certs.FinalityCertificate, error) + GetPowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) + GetF3PowerTable(ctx context.Context, tsk types.TipSetKey) (gpbft.PowerEntries, error) + IsRunning() bool + Progress() gpbft.InstanceProgress +} + type F3 struct { inner *f3.F3 ec *ecWrapper diff --git a/chain/types/tipset_selector.go b/chain/types/tipset_selector.go new file mode 100644 index 000000000..5abacc306 --- /dev/null +++ b/chain/types/tipset_selector.go @@ -0,0 +1,171 @@ +package types + +import ( + "golang.org/x/xerrors" + + "github.com/filecoin-project/go-state-types/abi" +) + +var ( + // TipSetTags represents the predefined set of tags for tipsets. The supported + // tags are: + // - Latest: the most recent tipset in the chain with the heaviest weight. + // - Finalized: the most recent tipset considered final by the node. + // + // See TipSetTag. + TipSetTags = struct { + Latest TipSetTag + Finalized TipSetTag + }{ + Latest: TipSetTag("latest"), + Finalized: TipSetTag("finalized"), + } + + // TipSetSelectors represents the predefined set of selectors for tipsets. + // + // See TipSetSelector. + TipSetSelectors = struct { + Latest TipSetSelector + Finalized TipSetSelector + Height func(abi.ChainEpoch, bool, *TipSetAnchor) TipSetSelector + Key func(TipSetKey) TipSetSelector + }{ + Latest: TipSetSelector{Tag: &TipSetTags.Latest}, + Finalized: TipSetSelector{Tag: &TipSetTags.Finalized}, + Height: func(height abi.ChainEpoch, previous bool, anchor *TipSetAnchor) TipSetSelector { + return TipSetSelector{Height: &TipSetHeight{At: &height, Previous: previous, Anchor: anchor}} + }, + Key: func(key TipSetKey) TipSetSelector { return TipSetSelector{Key: &key} }, + } + + // TipSetAnchors represents the predefined set of anchors for tipsets. + // + // See TipSetAnchor. + TipSetAnchors = struct { + Latest *TipSetAnchor + Finalized *TipSetAnchor + Key func(TipSetKey) *TipSetAnchor + }{ + Latest: &TipSetAnchor{Tag: &TipSetTags.Latest}, + Finalized: &TipSetAnchor{Tag: &TipSetTags.Finalized}, + Key: func(key TipSetKey) *TipSetAnchor { return &TipSetAnchor{Key: &key} }, + } +) + +// TipSetTag is a string that represents a pointer to a tipset. +// See TipSetSelector. +type TipSetTag string + +// TipSetSelector captures the selection criteria for a tipset. +// +// The supported criterion for selection is one of the following: +// - Key: the tipset key, see TipSetKey. +// - Height: the tipset height with an optional fallback to non-null parent, see TipSetHeight. +// - Tag: the tipset tag, either "latest" or "finalized", see TipSetTags. +// +// At most, one such criterion can be specified at a time. Otherwise, the +// criterion is considered to be invalid. See Validate. +// +// Experimental: This API is experimental and may change without notice. +type TipSetSelector struct { + Key *TipSetKey `json:"key,omitempty"` + Height *TipSetHeight `json:"height,omitempty"` + Tag *TipSetTag `json:"tag,omitempty"` +} + +// Validate ensures that the TipSetSelector is valid. It checks that only one of +// the selection criteria is specified. If no criteria are specified, it returns +// nil, indicating that the default selection criteria should be used as defined +// by the Lotus API Specification. +func (tss TipSetSelector) Validate() error { + var criteria int + if tss.Key != nil { + criteria++ + } + if tss.Tag != nil { + criteria++ + } + if tss.Height != nil { + criteria++ + if err := tss.Height.Validate(); err != nil { + return xerrors.Errorf("validating tipset height: %w", err) + } + } + if criteria != 1 { + return xerrors.Errorf("exactly one tipset selection criteria must be specified, found: %v", criteria) + } + return nil +} + +// TipSetHeight is a criterion that selects a tipset At given height anchored to +// a given parent tipset. +// +// In a case where the tipset at given height is null, and Previous is true, +// it'll select the previous non-null tipset instead. Otherwise, it returns the +// null tipset at the given height. +// +// The Anchor may optionally be specified as TipSetTag, or TipSetKey. If +// specified, the selected tipset is guaranteed to be a child of the tipset +// specified by the anchor at the given height. Otherwise, the "finalized" TipSetTag +// is used as the Anchor. +// +// Experimental: This API is experimental and may change without notice. +type TipSetHeight struct { + At *abi.ChainEpoch `json:"at,omitempty"` + Previous bool `json:"previous,omitempty"` + Anchor *TipSetAnchor `json:"anchor,omitempty"` +} + +// Validate ensures that the TipSetHeight is valid. It checks that the height is +// not negative and the Anchor is valid. +// +// A nil or a zero-valued height is considered to be valid. +func (tsh TipSetHeight) Validate() error { + if tsh.At == nil { + return xerrors.New("invalid tipset height: at epoch must be specified") + } + if *tsh.At < 0 { + return xerrors.New("invalid tipset height: epoch cannot be less than zero") + } + return tsh.Anchor.Validate() +} + +// TipSetAnchor represents a tipset in the chain that can be used as an anchor +// for selecting a tipset. The anchor may be specified as a TipSetTag or a +// TipSetKey but not both. Defaults to TipSetTag "finalized" if neither are +// specified. +// +// See TipSetHeight. +// +// Experimental: This API is experimental and may change without notice. +type TipSetAnchor struct { + + // TODO: We might want to rename the term "anchor" to "parent" if they're + // conceptually interchangeable. Because, it is easier to reuse a term that + // already exist compared to teaching people a new one. For now we'll keep it as + // "anchor" to keep consistent with the internal API design discussions. We will + // revisit the terminology here as the new API groups are added, namely + // StateSearchMsg. + + Key *TipSetKey `json:"key,omitempty"` + Tag *TipSetTag `json:"tag,omitempty"` +} + +// Validate ensures that the TipSetAnchor is valid. It checks that at most one +// of TipSetKey or TipSetTag is specified. Otherwise, it returns an error. +// +// Note that a nil or a zero-valued anchor is valid, and is considered to be +// equivalent to the default anchor, which is the tipset tagged as "finalized". +func (tsa *TipSetAnchor) Validate() error { + if tsa == nil { + // An unspecified Anchor is valid, because it's an optional field, and falls back + // to whatever the API decides the default to be. + return nil + } + if tsa.Key != nil && tsa.Tag != nil { + return xerrors.New("invalid tipset anchor: at most one of key or tag must be specified") + } + // Zero-valued anchor is valid, and considered to be an equivalent to whatever + // the API decides the default to be. + return nil +} diff --git a/chain/types/tipset_selector_test.go b/chain/types/tipset_selector_test.go new file mode 100644 index 000000000..e8b5b29a6 --- /dev/null +++ b/chain/types/tipset_selector_test.go @@ -0,0 +1,81 @@ +package types_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/filecoin-project/lotus/chain/types" +) + +func TestTipSetSelector_Marshalling(t *testing.T) { + for _, test := range []struct { + name string + subject types.TipSetSelector + wantJson string + wantErr string + }{ + { + name: "zero-valued", + wantErr: "exactly one tipset selection criteria must be specified, found: 0", + }, + { + name: "height", + subject: types.TipSetSelectors.Height(123, true, nil), + wantJson: `{"height":{"at":123,"previous":true}}`, + }, + { + name: "height anchored to finalized", + subject: types.TipSetSelectors.Height(123, true, types.TipSetAnchors.Finalized), + wantJson: `{"height":{"at":123,"previous":true,"anchor":{"tag":"finalized"}}}`, + }, + { + name: "invalid height anchor", + subject: types.TipSetSelectors.Height(123, true, &types.TipSetAnchor{ + Tag: &types.TipSetTags.Finalized, + Key: &types.TipSetKey{}, + }), + wantErr: "at most one of key or tag", + }, + { + name: "height with no epoch", + subject: types.TipSetSelector{ + Height: &types.TipSetHeight{}, + }, + wantErr: "epoch must be specified", + }, + { + name: "invalid height epoch", + subject: types.TipSetSelectors.Height(-1, false, nil), + wantErr: "epoch cannot be less than zero", + }, + { + name: "key", + subject: types.TipSetSelectors.Key(types.TipSetKey{}), + wantJson: `{"key":[]}`, + }, + { + name: "tag finalized", + subject: types.TipSetSelectors.Finalized, + wantJson: `{"tag":"finalized"}`, + }, + { + name: "tag latest", + subject: types.TipSetSelectors.Latest, + wantJson: `{"tag":"latest"}`, + }, + } { + t.Run(test.name, func(t *testing.T) { + err := test.subject.Validate() + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + } else { + require.NoError(t, err) + gotMarshalled, err := json.Marshal(test.subject) + require.NoError(t, err) + require.JSONEq(t, test.wantJson, string(gotMarshalled)) + } + }) + } +} diff --git a/cli/lotus/daemon.go b/cli/lotus/daemon.go index 7aa37cb18..e372b193f 100644 --- a/cli/lotus/daemon.go +++ b/cli/lotus/daemon.go @@ -31,6 +31,8 @@ import ( "github.com/filecoin-project/go-paramfetch" lapi "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/v1api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/build" "github.com/filecoin-project/lotus/build/buildconstants" "github.com/filecoin-project/lotus/chain/beacon/drand" @@ -392,9 +394,11 @@ var DaemonCmd = &cli.Command{ log.Warnf("unable to inject prometheus ipfs/go-metrics exporter; some metrics will be unavailable; err: %s", err) } - var api lapi.FullNode + var v1 v1api.FullNode + var v2 v2api.FullNode stop, err := node.New(ctx, - node.FullAPI(&api, node.Lite(isLite)), + node.FullAPI(&v1, node.Lite(isLite)), + node.FullAPIv2(&v2), node.Base(), node.Repo(r), @@ -424,7 +428,7 @@ var DaemonCmd = &cli.Command{ } if cctx.String("import-key") != "" { - if err := importKey(ctx, api, cctx.String("import-key")); err != nil { + if err := importKey(ctx, v1, cctx.String("import-key")); err != nil { log.Errorf("importing key failed: %+v", err) } } @@ -445,7 +449,7 @@ var DaemonCmd = &cli.Command{ } // Instantiate the full node handler. - h, err := node.FullNodeHandler(api, true, serverOptions...) + h, err := node.FullNodeHandler(v1, v2, true, serverOptions...) if err != nil { return fmt.Errorf("failed to instantiate rpc handler: %s", err) } diff --git a/documentation/en/api-v2-unstable-methods.md b/documentation/en/api-v2-unstable-methods.md new file mode 100644 index 000000000..7cc885beb --- /dev/null +++ b/documentation/en/api-v2-unstable-methods.md @@ -0,0 +1,62 @@ +# Groups +* [Chain](#Chain) + * [ChainGetTipSet](#ChainGetTipSet) +## Chain +The Chain method group contains methods for interacting with +the blockchain. + +Note: This API is experimental and may change in the future. + +Please see Filecoin V2 API design documentation for more details: + - https://www.notion.so/filecoindev/Lotus-F3-aware-APIs-1cfdc41950c180ae97fef580e79427d5 + - https://www.notion.so/filecoindev/Filecoin-V2-APIs-1d0dc41950c1808b914de5966d501658 + + +### ChainGetTipSet +ChainGetTipSet retrieves a tipset that corresponds to the specified selector +criteria. The criteria can be provided in the form of a tipset key, a +blockchain height including an optional fallback to previous non-null tipset, +or a designated tag such as "latest" or "finalized". + +The "Finalized" tag returns the tipset that is considered finalized based on +the consensus protocol of the current node, either Filecoin EC Finality or +Filecoin Fast Finality (F3). The finalized tipset selection gracefully falls +back to EC finality in cases where F3 isn't ready or not running. + +In a case where no selector is provided, an error is returned. The selector +must be explicitly specified. + +For more details, refer to the types.TipSetSelector and +types.NewTipSetSelector. + +Example usage: + + selector := types.TipSetSelectors.Latest + tipSet, err := node.ChainGetTipSet(context.Background(), selector) + if err != nil { + fmt.Println("Error retrieving tipset:", err) + return + } + fmt.Printf("Latest TipSet: %v\n", tipSet) + + +Perms: read + +Inputs: +```json +[ + { + "tag": "finalized" + } +] +``` + +Response: +```json +{ + "Cids": null, + "Blocks": null, + "Height": 0 +} +``` + diff --git a/gen/api/proxygen.go b/gen/api/proxygen.go index 2bf83b62f..748fc7f21 100644 --- a/gen/api/proxygen.go +++ b/gen/api/proxygen.go @@ -28,6 +28,7 @@ type Visitor struct { func main() { var lets errgroup.Group lets.Go(generateApi) + lets.Go(generateApiV2) lets.Go(generateApiV0) if err := lets.Wait(); err != nil { fmt.Println("Error:", err) @@ -44,6 +45,10 @@ func generateApi() error { return generate("./api", "api", "api", "./api/proxy_gen.go") } +func generateApiV2() error { + return generate("./api/v2api", "v2api", "v2api", "./api/v2api/proxy_gen.go") +} + func (v *Visitor) Visit(node ast.Node) ast.Visitor { st, ok := node.(*ast.TypeSpec) if !ok { diff --git a/gen/docs/main.go b/gen/docs/main.go index 95a803f12..7c2fc0f38 100644 --- a/gen/docs/main.go +++ b/gen/docs/main.go @@ -15,6 +15,7 @@ func main() { lets.SetLimit(1) // TODO: Investigate why this can't run in parallel. lets.Go(generateApiFull) lets.Go(generateApiV0Methods) + lets.Go(generateApiV2Methods) lets.Go(generateStorage) lets.Go(generateWorker) lets.Go(generateOpenRpcGateway) @@ -53,6 +54,16 @@ func generateApiV0Methods() error { } } +func generateApiV2Methods() error { + if ainfo, err := docgen.ParseApiASTInfo("api/v2api/full.go", "FullNode", "v2api", "./api/v2api"); err != nil { + return err + } else if err := generateMarkdown("documentation/en/api-v2-unstable-methods.md", "FullNode", "v2api", ainfo); err != nil { + return err + } else { + return generateOpenRpc("build/openrpc/v2/full.json", "FullNode", "v2api", ainfo) + } +} + func generateApiFull() error { if ainfo, err := docgen.ParseApiASTInfo("api/api_full.go", "FullNode", "api", "./api"); err != nil { return err diff --git a/itests/api_v2_test.go b/itests/api_v2_test.go new file mode 100644 index 000000000..cb072b8c3 --- /dev/null +++ b/itests/api_v2_test.go @@ -0,0 +1,344 @@ +package itests + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/filecoin-project/go-f3" + "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/go-state-types/abi" + + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/chain/actors/policy" + "github.com/filecoin-project/lotus/chain/lf3" + "github.com/filecoin-project/lotus/chain/types" + "github.com/filecoin-project/lotus/itests/kit" +) + +func TestAPIV2_GetTipSetThroughRPC(t *testing.T) { + const ( + timeout = 2 * time.Minute + blockTime = 10 * time.Millisecond + f3FinalizedEpoch = 123 + targetHeight = 20 + policy.ChainFinality + ) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + t.Cleanup(cancel) + kit.QuietMiningLogs() + + mockF3 := newMockF3Backend() + subject, _, network := kit.EnsembleMinimal(t, kit.ThroughRPC(), kit.F3Backend(mockF3)) + network.BeginMining(blockTime) + subject.WaitTillChain(ctx, kit.HeightAtLeast(targetHeight)) + + var ( + heaviest = func(t *testing.T) *types.TipSet { + head, err := subject.ChainHead(ctx) + require.NoError(t, err) + return head + } + ecFinalized = func(t *testing.T) *types.TipSet { + head, err := subject.ChainHead(ctx) + require.NoError(t, err) + ecFinalized, err := subject.ChainGetTipSetByHeight(ctx, head.Height()-policy.ChainFinality, head.Key()) + require.NoError(t, err) + return ecFinalized + } + tipSetAtHeight = func(height abi.ChainEpoch) func(t *testing.T) *types.TipSet { + return func(t *testing.T) *types.TipSet { + ts, err := subject.ChainGetTipSetByHeight(ctx, height, types.EmptyTSK) + require.NoError(t, err) + return ts + } + } + internalF3Error = errors.New("lost hearing in left eye") + plausibleCert = func(t *testing.T) *certs.FinalityCertificate { + f3FinalisedTipSet := tipSetAtHeight(f3FinalizedEpoch)(t) + return &certs.FinalityCertificate{ + ECChain: &gpbft.ECChain{ + TipSets: []*gpbft.TipSet{{ + Epoch: int64(f3FinalisedTipSet.Height()), + Key: f3FinalisedTipSet.Key().Bytes(), + }}, + }, + } + } + implausibleCert = &certs.FinalityCertificate{ + ECChain: &gpbft.ECChain{ + TipSets: []*gpbft.TipSet{{ + Epoch: int64(1413), + Key: []byte(`🐠`), + }}, + }, + } + ) + + // The tests here use the raw JSON request form for testing to both test the API + // through RPC, and showcase what the raw request on the wire would look like at + // Layer 7 of ISO model. + for _, test := range []struct { + name string + when func(t *testing.T) + request string + wantTipSet func(t *testing.T) *types.TipSet + wantErr string + wantResponseStatus int + }{ + { + name: "no selector is error", + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet"],"id":1}`, + wantErr: "Parse error", + wantResponseStatus: http.StatusInternalServerError, + }, + { + name: "latest tag is ok", + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"latest"}],"id":1}`, + wantTipSet: heaviest, + wantResponseStatus: http.StatusOK, + }, + { + name: "finalized tag when f3 disabled falls back to ec", + when: func(t *testing.T) { + mockF3.running = false + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, + wantTipSet: ecFinalized, + wantResponseStatus: http.StatusOK, + }, + { + name: "finalized tag is ok", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCertErr = nil + mockF3.latestCert = plausibleCert(t) + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, + wantTipSet: tipSetAtHeight(f3FinalizedEpoch), + wantResponseStatus: http.StatusOK, + }, + { + name: "finalized tag when f3 not ready falls back to ec", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = nil + mockF3.latestCertErr = api.ErrF3NotReady + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, + wantTipSet: ecFinalized, + wantResponseStatus: http.StatusOK, + }, + { + name: "finalized tag when f3 fails is error", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = nil + mockF3.latestCertErr = internalF3Error + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, + wantErr: internalF3Error.Error(), + wantResponseStatus: http.StatusOK, + }, + { + name: "latest tag when f3 fails is ok", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = nil + mockF3.latestCertErr = internalF3Error + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"latest"}],"id":1}`, + wantTipSet: heaviest, + wantResponseStatus: http.StatusOK, + }, + { + name: "finalized tag when f3 is broken", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = implausibleCert + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, + wantErr: "decoding latest f3 cert tipset key", + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no anchor without f3 falling back to ec is ok", + when: func(t *testing.T) { + mockF3.running = false + mockF3.latestCert = nil + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":20}}],"id":1}`, + wantTipSet: tipSetAtHeight(20), + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no epoch", + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{}}],"id":1}`, + wantErr: "epoch must be specified", + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no anchor before finalized epoch is ok", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = plausibleCert(t) + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":111}}],"id":1}`, + wantTipSet: tipSetAtHeight(111), + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no anchor after finalized epoch is error", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = plausibleCert(t) + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":145}}],"id":1}`, + wantErr: "looking for tipset with height greater than start point", + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no anchor when f3 fails is error", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = nil + mockF3.latestCertErr = internalF3Error + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":456}}],"id":1}`, + wantErr: internalF3Error.Error(), + wantResponseStatus: http.StatusOK, + }, + { + name: "height with no anchor and nil f3 cert falling back to ec fails", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = nil + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":111}}],"id":1}`, + wantErr: "looking for tipset with height greater than start point", + wantResponseStatus: http.StatusOK, + }, + { + name: "height with anchor to latest", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCert = plausibleCert(t) + mockF3.latestCertErr = nil + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":890,"anchor":{"tag":"latest"}}}],"id":1}`, + wantTipSet: tipSetAtHeight(890), + wantResponseStatus: http.StatusOK, + }, + } { + t.Run(test.name, func(t *testing.T) { + if test.when != nil { + test.when(t) + } + gotResponseCode, gotResponseBody := subject.DoRawRPCRequest(t, 2, test.request) + require.Equal(t, test.wantResponseStatus, gotResponseCode, string(gotResponseBody)) + var resultOrError struct { + Result *types.TipSet `json:"result,omitempty"` + Error *struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + } `json:"error,omitempty"` + } + require.NoError(t, json.Unmarshal(gotResponseBody, &resultOrError)) + if test.wantErr != "" { + require.Nil(t, resultOrError.Result) + require.Contains(t, resultOrError.Error.Message, test.wantErr) + } else { + require.Nil(t, resultOrError.Error) + require.Equal(t, test.wantTipSet(t), resultOrError.Result) + } + }) + } +} + +var _ lf3.F3Backend = (*mockF3Backend)(nil) + +type mockF3Backend struct { + progress gpbft.InstanceProgress + latestCert *certs.FinalityCertificate + latestCertErr error + certs map[uint64]*certs.FinalityCertificate + participants map[uint64]struct{} + manifest *manifest.Manifest + running bool +} + +func newMockF3Backend() *mockF3Backend { + return &mockF3Backend{ + certs: make(map[uint64]*certs.FinalityCertificate), + participants: make(map[uint64]struct{}), + } +} + +func (t *mockF3Backend) GetOrRenewParticipationTicket(_ context.Context, minerID uint64, _ api.F3ParticipationTicket, _ uint64) (api.F3ParticipationTicket, error) { + if !t.running { + return nil, f3.ErrF3NotRunning + } + return binary.BigEndian.AppendUint64(nil, minerID), nil +} + +func (t *mockF3Backend) Participate(_ context.Context, ticket api.F3ParticipationTicket) (api.F3ParticipationLease, error) { + if !t.running { + return api.F3ParticipationLease{}, f3.ErrF3NotRunning + } + mid := binary.BigEndian.Uint64(ticket) + if _, ok := t.participants[mid]; !ok { + return api.F3ParticipationLease{}, api.ErrF3ParticipationTicketInvalid + } + return api.F3ParticipationLease{ + Network: "fish", + Issuer: "fishmonger", + MinerID: mid, + FromInstance: t.progress.ID, + ValidityTerm: 5, + }, nil +} + +func (t *mockF3Backend) GetManifest(context.Context) (*manifest.Manifest, error) { + if !t.running { + return nil, f3.ErrF3NotRunning + } + return t.manifest, nil +} + +func (t *mockF3Backend) GetCert(_ context.Context, instance uint64) (*certs.FinalityCertificate, error) { + if !t.running { + return nil, f3.ErrF3NotRunning + } + return t.certs[instance], nil +} + +func (t *mockF3Backend) GetLatestCert(context.Context) (*certs.FinalityCertificate, error) { + if !t.running { + return nil, f3.ErrF3NotRunning + } + return t.latestCert, t.latestCertErr +} + +func (t *mockF3Backend) GetPowerTable(context.Context, types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, nil +} + +func (t *mockF3Backend) GetF3PowerTable(context.Context, types.TipSetKey) (gpbft.PowerEntries, error) { + return nil, nil +} + +func (t *mockF3Backend) ListParticipants() []api.F3Participant { return nil } +func (t *mockF3Backend) IsRunning() bool { return t.running } +func (t *mockF3Backend) Progress() gpbft.InstanceProgress { return t.progress } diff --git a/itests/f3_test.go b/itests/f3_test.go index d13bc4791..c35d64ab3 100644 --- a/itests/f3_test.go +++ b/itests/f3_test.go @@ -130,7 +130,7 @@ func TestF3_InactiveModes(t *testing.T) { opts := []any{kit.MockProofs()} if tc.mode == "disabled" { - opts = append(opts, kit.F3Enabled(nil)) + opts = append(opts, kit.F3Disabled()) } client, miner, ens := kit.EnsembleMinimal(t, opts...) @@ -505,7 +505,7 @@ func setupWithStaticManifest(t *testing.T, manif *manifest.Manifest, testBootstr AllowDynamicFinalize: !testBootstrap, } - nodeOpts := []kit.NodeOpt{kit.WithAllSubsystems(), kit.F3Enabled(cfg)} + nodeOpts := []kit.NodeOpt{kit.WithAllSubsystems(), kit.F3Config(cfg)} nodeOpts = append(nodeOpts, extraOpts...) minerOpts := []kit.NodeOpt{kit.WithAllSubsystems(), kit.ConstructorOpts(node.Override(node.F3Participation, modules.F3Participation))} minerOpts = append(minerOpts, extraOpts...) diff --git a/itests/gateway_test.go b/itests/gateway_test.go index 80cfcecb9..df1f3c4d1 100644 --- a/itests/gateway_test.go +++ b/itests/gateway_test.go @@ -587,7 +587,7 @@ func TestGatewayF3(t *testing.T) { t.Run("disabled", func(t *testing.T) { ctx := context.Background() - nodes := startNodes(ctx, t, withNodeOpts(kit.F3Enabled(nil))) + nodes := startNodes(ctx, t, withNodeOpts(kit.F3Disabled())) cert, err := nodes.lite.F3GetLatestCertificate(ctx) require.ErrorIs(t, err, api.ErrF3Disabled) diff --git a/itests/kit/ensemble.go b/itests/kit/ensemble.go index d7b5feb58..9bfe1afd6 100644 --- a/itests/kit/ensemble.go +++ b/itests/kit/ensemble.go @@ -447,6 +447,7 @@ func (n *Ensemble) Start() *Ensemble { opts := []node.Option{ node.FullAPI(&full.FullNode, node.Lite(full.options.lite)), + node.FullAPIv2(&full.V2), node.Base(), node.Repo(r), node.If(full.options.disableLibp2p, node.MockHost(n.mn)), diff --git a/itests/kit/node_full.go b/itests/kit/node_full.go index d41b486a1..117bf445d 100644 --- a/itests/kit/node_full.go +++ b/itests/kit/node_full.go @@ -20,6 +20,7 @@ import ( "github.com/filecoin-project/lotus/api" "github.com/filecoin-project/lotus/api/v1api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/wallet/key" cliutil "github.com/filecoin-project/lotus/cli/util" @@ -35,6 +36,7 @@ type Libp2p struct { // TestFullNode represents a full node enrolled in an Ensemble. type TestFullNode struct { v1api.FullNode + V2 v2api.FullNode t *testing.T diff --git a/itests/kit/node_opts.go b/itests/kit/node_opts.go index 9c9a20065..40dc1e809 100644 --- a/itests/kit/node_opts.go +++ b/itests/kit/node_opts.go @@ -247,20 +247,28 @@ func MutateSealingConfig(mut func(sc *config.SealingConfig)) NodeOpt { }))) } -// F3Enabled enables the F3 feature in the node. If the provided config is nil, -// the feature is disabled. -func F3Enabled(cfg *lf3.Config) NodeOpt { - if cfg == nil { - return ConstructorOpts( - node.Unset(new(*lf3.Config)), - node.Unset(new(manifest.ManifestProvider)), - node.Unset(new(*lf3.F3)), - ) - } +// F3Config sets the F3 configuration to be used by test node. +func F3Config(cfg *lf3.Config) NodeOpt { return ConstructorOpts( node.Override(new(*lf3.Config), cfg), - node.Override(new(manifest.ManifestProvider), lf3.NewManifestProvider), - node.Override(new(*lf3.F3), lf3.New), + ) +} + +// F3Backend overrides the F3 backend implementation used by test node. +func F3Backend(backend lf3.F3Backend) NodeOpt { + return ConstructorOpts( + node.Override(new(lf3.F3Backend), backend), + ) +} + +// F3Disabled disables the F3 subsystem for this node. +func F3Disabled() NodeOpt { + return ConstructorOpts( + node.Unset(new(*lf3.Config)), + node.Unset(new(*lf3.ContractManifestProvider)), + node.Unset(new(lf3.StateCaller)), + node.Unset(new(manifest.ManifestProvider)), + node.Unset(new(lf3.F3Backend)), ) } diff --git a/itests/kit/rpc.go b/itests/kit/rpc.go index 6b63eb1eb..248e75883 100644 --- a/itests/kit/rpc.go +++ b/itests/kit/rpc.go @@ -1,8 +1,10 @@ package kit import ( + "bytes" "context" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -44,7 +46,7 @@ func CreateRPCServer(t *testing.T, handler http.Handler, listener net.Listener) } func fullRpc(t *testing.T, f *TestFullNode) (*TestFullNode, Closer) { - handler, err := node.FullNodeHandler(f.FullNode, false) + handler, err := node.FullNodeHandler(f.FullNode, f.V2, false) require.NoError(t, err) l, err := net.Listen("tcp", "127.0.0.1:0") @@ -105,3 +107,19 @@ func workerRpc(t *testing.T, m *TestWorker) *TestWorker { m.ListenAddr, m.Worker = maddr, cl return m } + +func (full *TestFullNode) DoRawRPCRequest(t *testing.T, version int, body string) (int, []byte) { + t.Helper() + require.NotEmpty(t, full.ListenURL, "not listening for rpc, turn on with `kit.ThroughRPC()`") + + endpoint := fmt.Sprintf("%s/rpc/v%d", full.ListenURL, version) + request, err := http.NewRequest("POST", endpoint, bytes.NewReader([]byte(body))) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer func() { require.NoError(t, response.Body.Close()) }() + respBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + return response.StatusCode, respBody +} diff --git a/metrics/proxy/proxy.go b/metrics/proxy/proxy.go index 1f0bd9672..09bb4cca5 100644 --- a/metrics/proxy/proxy.go +++ b/metrics/proxy/proxy.go @@ -9,6 +9,7 @@ import ( "go.opencensus.io/tag" "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/metrics" ) @@ -30,6 +31,12 @@ func MetricedFullAPI(a api.FullNode) api.FullNode { return &out } +func MetricedFullV2API(a v2api.FullNode) v2api.FullNode { + var out v2api.FullNodeStruct + proxy(a, &out) + return &out +} + func MetricedWorkerAPI(a api.Worker) api.Worker { var out api.WorkerStruct proxy(a, &out) diff --git a/node/builder.go b/node/builder.go index 98b40355d..3fb9fd29a 100644 --- a/node/builder.go +++ b/node/builder.go @@ -136,6 +136,7 @@ const ( StoreEventsKey InitChainIndexerKey + InitApiV2Key _nInvokes // keep this last ) diff --git a/node/builder_chain.go b/node/builder_chain.go index 3748698dc..377750f9e 100644 --- a/node/builder_chain.go +++ b/node/builder_chain.go @@ -12,6 +12,7 @@ import ( "github.com/filecoin-project/go-f3/manifest" "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/build" "github.com/filecoin-project/lotus/chain" "github.com/filecoin-project/lotus/chain/beacon" @@ -136,6 +137,7 @@ var ChainNode = Options( Override(new(messagepool.Provider), messagepool.NewProviderLite), Override(new(messagepool.MpoolNonceAPI), From(new(modules.MpoolNonceAPI))), Override(new(full.ChainModuleAPI), From(new(api.Gateway))), + Override(new(full.ChainModuleAPIv2), From(new(full.ChainModuleV2))), Override(new(full.GasModuleAPI), From(new(api.Gateway))), Override(new(full.MpoolModuleAPI), From(new(api.Gateway))), Override(new(full.StateModuleAPI), From(new(api.Gateway))), @@ -162,6 +164,7 @@ var ChainNode = Options( Override(new(messagepool.Provider), messagepool.NewProvider), Override(new(messagepool.MpoolNonceAPI), From(new(*messagepool.MessagePool))), Override(new(full.ChainModuleAPI), From(new(full.ChainModule))), + Override(new(full.ChainModuleAPIv2), From(new(full.ChainModuleV2))), Override(new(full.GasModuleAPI), From(new(full.GasModule))), Override(new(full.MpoolModuleAPI), From(new(full.MpoolModule))), Override(new(full.StateModuleAPI), From(new(full.StateModule))), @@ -179,7 +182,7 @@ var ChainNode = Options( Override(new(*lf3.ContractManifestProvider), lf3.NewContractManifestProvider), Override(new(lf3.StateCaller), From(new(full.StateModule))), Override(new(manifest.ManifestProvider), lf3.NewManifestProvider), - Override(new(*lf3.F3), lf3.New), + Override(new(lf3.F3Backend), lf3.New), ), ) @@ -363,3 +366,12 @@ func FullAPI(out *api.FullNode, fopts ...FullOption) Option { }, ) } + +func FullAPIv2(out *v2api.FullNode) Option { + return func(s *Settings) error { + resAPI := &impl.FullNodeAPIv2{} + s.invokes[InitApiV2Key] = fx.Populate(resAPI) + *out = resAPI + return nil + } +} diff --git a/node/impl/full.go b/node/impl/full.go index 156bc2477..e483be06c 100644 --- a/node/impl/full.go +++ b/node/impl/full.go @@ -6,8 +6,10 @@ import ( logging "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p/core/peer" + "go.uber.org/fx" "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/build" "github.com/filecoin-project/lotus/chain/actors/policy" "github.com/filecoin-project/lotus/node/impl/common" @@ -121,3 +123,11 @@ func (n *FullNodeAPI) NodeStatus(ctx context.Context, inclChainStatus bool) (sta } var _ api.FullNode = &FullNodeAPI{} + +type FullNodeAPIv2 struct { + fx.In + + full.ChainModuleAPIv2 +} + +var _ v2api.FullNode = &FullNodeAPIv2{} diff --git a/node/impl/full/chain_v2.go b/node/impl/full/chain_v2.go new file mode 100644 index 000000000..583d2b5e8 --- /dev/null +++ b/node/impl/full/chain_v2.go @@ -0,0 +1,120 @@ +package full + +import ( + "context" + "errors" + + "go.uber.org/fx" + "golang.org/x/xerrors" + + "github.com/filecoin-project/go-f3" + + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/chain/actors/policy" + "github.com/filecoin-project/lotus/chain/lf3" + "github.com/filecoin-project/lotus/chain/store" + "github.com/filecoin-project/lotus/chain/types" +) + +type ChainModuleAPIv2 interface { + ChainGetTipSet(context.Context, types.TipSetSelector) (*types.TipSet, error) +} + +type ChainModuleV2 struct { + Chain *store.ChainStore + F3 lf3.F3Backend `optional:"true"` + + fx.In +} + +var _ ChainModuleAPIv2 = (*ChainModuleV2)(nil) + +func (cm *ChainModuleV2) ChainGetTipSet(ctx context.Context, selector types.TipSetSelector) (*types.TipSet, error) { + if err := selector.Validate(); err != nil { + return nil, xerrors.Errorf("validating selector: %w", err) + } + + // Get tipset by key. + if selector.Key != nil { + return cm.Chain.GetTipSetFromKey(ctx, *selector.Key) + } + + // Get tipset by height. + if selector.Height != nil { + anchor, err := cm.getTipSetByAnchor(ctx, selector.Height.Anchor) + if err != nil { + return nil, xerrors.Errorf("getting anchor from tipset: %w", err) + } + return cm.Chain.GetTipsetByHeight(ctx, *selector.Height.At, anchor, selector.Height.Previous) + } + + // Get tipset by tag, either latest or finalized. + if selector.Tag != nil { + return cm.getTipSetByTag(ctx, *selector.Tag) + } + + return nil, xerrors.Errorf("no tipset found for selector") +} + +func (cm *ChainModuleV2) getTipSetByTag(ctx context.Context, tag types.TipSetTag) (*types.TipSet, error) { + if tag == types.TipSetTags.Latest { + return cm.Chain.GetHeaviestTipSet(), nil + } + if tag != types.TipSetTags.Finalized { + return nil, xerrors.Errorf("unknown tipset tag: %s", tag) + } + + if cm.F3 == nil { + // F3 is disabled; fall back to EC finality. + return cm.getECFinalized(ctx) + } + + cert, err := cm.F3.GetLatestCert(ctx) + if err != nil { + if errors.Is(err, f3.ErrF3NotRunning) || errors.Is(err, api.ErrF3NotReady) { + // Only fall back to EC finality if F3 isn't running or not ready. + log.Debugw("F3 not running or not ready, falling back to EC finality", "err", err) + return cm.getECFinalized(ctx) + } + return nil, err + } + if cert == nil { + // No latest certificate. Fall back to EC finality. + return cm.getECFinalized(ctx) + } + + // Extract the finalized tipeset from the certificate. + tsk, err := types.TipSetKeyFromBytes(cert.ECChain.Head().Key) + if err != nil { + return nil, xerrors.Errorf("decoding latest f3 cert tipset key: %w", err) + } + ts, err := cm.Chain.LoadTipSet(ctx, tsk) + if err != nil { + return nil, xerrors.Errorf("loading latest f3 cert tipset %s: %w", tsk, err) + } + return ts, nil +} + +func (cm *ChainModuleV2) getTipSetByAnchor(ctx context.Context, anchor *types.TipSetAnchor) (*types.TipSet, error) { + switch { + case anchor == nil: + // No anchor specified. Fall back to finalized tipset. + return cm.getTipSetByTag(ctx, types.TipSetTags.Finalized) + case anchor.Key == nil && anchor.Tag == nil: + // Anchor is zero-valued. Fall back to heaviest tipset. + return cm.Chain.GetHeaviestTipSet(), nil + case anchor.Key != nil: + // Get tipset at the specified key. + return cm.Chain.GetTipSetFromKey(ctx, *anchor.Key) + case anchor.Tag != nil: + return cm.getTipSetByTag(ctx, *anchor.Tag) + default: + return nil, xerrors.Errorf("invalid anchor: %v", anchor) + } +} + +func (cm *ChainModuleV2) getECFinalized(ctx context.Context) (*types.TipSet, error) { + head := cm.Chain.GetHeaviestTipSet() + finalizedHeight := head.Height() - policy.ChainFinality + return cm.Chain.GetTipsetByHeight(ctx, finalizedHeight, head, true) +} diff --git a/node/impl/full/f3.go b/node/impl/full/f3.go index 055ebcd3c..64dc1bef5 100644 --- a/node/impl/full/f3.go +++ b/node/impl/full/f3.go @@ -19,7 +19,7 @@ import ( type F3API struct { fx.In - F3 *lf3.F3 `optional:"true"` + F3 lf3.F3Backend `optional:"true"` } func (f3api *F3API) F3GetOrRenewParticipationTicket(ctx context.Context, miner address.Address, previous api.F3ParticipationTicket, instances uint64) (api.F3ParticipationTicket, error) { diff --git a/node/rpc.go b/node/rpc.go index bcf78799f..45154e7ac 100644 --- a/node/rpc.go +++ b/node/rpc.go @@ -22,6 +22,7 @@ import ( "github.com/filecoin-project/lotus/api" "github.com/filecoin-project/lotus/api/v0api" "github.com/filecoin-project/lotus/api/v1api" + "github.com/filecoin-project/lotus/api/v2api" "github.com/filecoin-project/lotus/lib/rpcenc" "github.com/filecoin-project/lotus/metrics" "github.com/filecoin-project/lotus/metrics/proxy" @@ -66,7 +67,7 @@ func ServeRPC(h http.Handler, id string, addr multiaddr.Multiaddr) (StopFunc, er } // FullNodeHandler returns a full node handler, to be mounted as-is on the server. -func FullNodeHandler(a v1api.FullNode, permissioned bool, opts ...jsonrpc.ServerOption) (http.Handler, error) { +func FullNodeHandler(v1 v1api.FullNode, v2 v2api.FullNode, permissioned bool, opts ...jsonrpc.ServerOption) (http.Handler, error) { m := mux.NewRouter() serveRpc := func(path string, hnd interface{}) { @@ -78,34 +79,40 @@ func FullNodeHandler(a v1api.FullNode, permissioned bool, opts ...jsonrpc.Server var handler http.Handler = rpcServer if permissioned { - handler = &auth.Handler{Verify: a.AuthVerify, Next: rpcServer.ServeHTTP} + handler = &auth.Handler{Verify: v1.AuthVerify, Next: rpcServer.ServeHTTP} } m.Handle(path, handler) } - fnapi := proxy.MetricedFullAPI(a) + v1Proxy := proxy.MetricedFullAPI(v1) + v2Proxy := proxy.MetricedFullV2API(v2) if permissioned { - fnapi = api.PermissionedFullAPI(fnapi) + v1Proxy = api.PermissionedFullAPI(v1Proxy) + v2Proxy = v2api.PermissionedFullAPI(v2Proxy) } + v0Proxy := &v0api.WrapperV1Full{FullNode: v1Proxy} - var v0 v0api.FullNode = &(struct{ v0api.FullNode }{&v0api.WrapperV1Full{FullNode: fnapi}}) - serveRpc("/rpc/v1", fnapi) - serveRpc("/rpc/v0", v0) + serveRpc("/rpc/v1", v1Proxy) + serveRpc("/rpc/v2", v2Proxy) + serveRpc("/rpc/v0", v0Proxy) // debugging m.Handle("/debug/metrics", metrics.Exporter()) m.Handle("/debug/pprof-set/block", handleFractionOpt("BlockProfileRate", runtime.SetBlockProfileRate)) - m.Handle("/debug/pprof-set/mutex", handleFractionOpt("MutexProfileFraction", func(x int) { - runtime.SetMutexProfileFraction(x) - })) - m.Handle("/health/livez", NewLiveHandler(a)) - m.Handle("/health/readyz", NewReadyHandler(a)) + m.Handle("/debug/pprof-set/mutex", handleFractionOpt("MutexProfileFraction", setMutexProfileFraction)) + m.Handle("/health/livez", NewLiveHandler(v1)) + m.Handle("/health/readyz", NewReadyHandler(v1)) m.PathPrefix("/").Handler(http.DefaultServeMux) // pprof return m, nil } +func setMutexProfileFraction(to int) { + from := runtime.SetMutexProfileFraction(to) + log.Debugw("Mutex profile fraction rate is set", "from", from, "to", to) +} + // MinerHandler returns a miner handler, to be mounted as-is on the server. func MinerHandler(a api.StorageMiner, permissioned bool) (http.Handler, error) { mapi := proxy.MetricedStorMinerAPI(a)