diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a1fd45808..887ab01462a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Pull requests: - https://github.com/filecoin-project/lotus/pull/13003 - https://github.com/filecoin-project/lotus/pull/13027 + - https://github.com/filecoin-project/lotus/pull/13034 # Node and Miner v1.32.2 / 2025-04-04 diff --git a/api/v2api/full.go b/api/v2api/full.go index 244c2dbb219..a89d4943394 100644 --- a/api/v2api/full.go +++ b/api/v2api/full.go @@ -27,13 +27,19 @@ type FullNode interface { // 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". + // or a designated tag such as "latest", "finalized", or "safe". // // 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. // + // The "Safe" tag returns the tipset between the "Finalized" tipset and + // "Latest - build.SafeHeightDistance". This provides a balance between + // finality confidence and recency. If the tipset at the safe height is null, + // the first non-nil parent tipset is returned, similar to the behavior of + // selecting by height with the 'previous' option set to true. + // // In a case where no selector is provided, an error is returned. The selector // must be explicitly specified. // @@ -42,13 +48,13 @@ type FullNode interface { // // 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) + // 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/build/openrpc/v2/full.json b/build/openrpc/v2/full.json index 7f30347adfe..1175f887ddb 100644 --- a/build/openrpc/v2/full.json +++ b/build/openrpc/v2/full.json @@ -8,7 +8,7 @@ { "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", + "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\", \"finalized\", or \"safe\".\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\nThe \"Safe\" tag returns the tipset between the \"Finalized\" tipset and\n\"Latest - build.SafeHeightDistance\". This provides a balance between\nfinality confidence and recency. If the tipset at the safe height is null,\nthe first non-nil parent tipset is returned, similar to the behavior of\nselecting by height with the 'previous' option set to true.\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 selector := types.TipSetSelectors.Latest\n tipSet, err := node.ChainGetTipSet(context.Background(), selector)\n if err != nil {\n fmt.Println(\"Error retrieving tipset:\", err)\n return\n }\n fmt.Printf(\"Latest TipSet: %v\\n\", tipSet)\n", "paramStructure": "by-position", "params": [ { diff --git a/build/parameters.go b/build/parameters.go index 61a71314704..c29956e87ef 100644 --- a/build/parameters.go +++ b/build/parameters.go @@ -82,3 +82,5 @@ const Eip155ChainId = buildconstants.Eip155ChainId // Deprecated: Use buildconst var WhitelistedBlock = buildconstants.WhitelistedBlock // Deprecated: Use buildconstants.WhitelistedBlock instead const Finality = policy.ChainFinality // Deprecated: Use policy.ChainFinality instead + +const SafeHeightDistance = 200 diff --git a/chain/types/tipset_selector.go b/chain/types/tipset_selector.go index 5abacc30600..5eb76b17186 100644 --- a/chain/types/tipset_selector.go +++ b/chain/types/tipset_selector.go @@ -11,14 +11,18 @@ var ( // tags are: // - Latest: the most recent tipset in the chain with the heaviest weight. // - Finalized: the most recent tipset considered final by the node. + // - Safe: the most recent tipset between Finalized and Latest - build.SafeHeightDistance. + // If the tipset at the safe height is null, the first non-nil parent tipset is returned. // // See TipSetTag. TipSetTags = struct { Latest TipSetTag Finalized TipSetTag + Safe TipSetTag }{ Latest: TipSetTag("latest"), Finalized: TipSetTag("finalized"), + Safe: TipSetTag("safe"), } // TipSetSelectors represents the predefined set of selectors for tipsets. @@ -27,11 +31,13 @@ var ( TipSetSelectors = struct { Latest TipSetSelector Finalized TipSetSelector + Safe TipSetSelector Height func(abi.ChainEpoch, bool, *TipSetAnchor) TipSetSelector Key func(TipSetKey) TipSetSelector }{ Latest: TipSetSelector{Tag: &TipSetTags.Latest}, Finalized: TipSetSelector{Tag: &TipSetTags.Finalized}, + Safe: TipSetSelector{Tag: &TipSetTags.Safe}, Height: func(height abi.ChainEpoch, previous bool, anchor *TipSetAnchor) TipSetSelector { return TipSetSelector{Height: &TipSetHeight{At: &height, Previous: previous, Anchor: anchor}} }, @@ -44,10 +50,12 @@ var ( TipSetAnchors = struct { Latest *TipSetAnchor Finalized *TipSetAnchor + Safe *TipSetAnchor Key func(TipSetKey) *TipSetAnchor }{ Latest: &TipSetAnchor{Tag: &TipSetTags.Latest}, Finalized: &TipSetAnchor{Tag: &TipSetTags.Finalized}, + Safe: &TipSetAnchor{Tag: &TipSetTags.Safe}, Key: func(key TipSetKey) *TipSetAnchor { return &TipSetAnchor{Key: &key} }, } ) diff --git a/chain/types/tipset_selector_test.go b/chain/types/tipset_selector_test.go index e8b5b29a622..c816e1ce9a1 100644 --- a/chain/types/tipset_selector_test.go +++ b/chain/types/tipset_selector_test.go @@ -65,6 +65,11 @@ func TestTipSetSelector_Marshalling(t *testing.T) { subject: types.TipSetSelectors.Latest, wantJson: `{"tag":"latest"}`, }, + { + name: "tag safe", + subject: types.TipSetSelectors.Safe, + wantJson: `{"tag":"safe"}`, + }, } { t.Run(test.name, func(t *testing.T) { err := test.subject.Validate() diff --git a/documentation/en/api-v2-unstable-methods.md b/documentation/en/api-v2-unstable-methods.md index 41d43cb951e..bc4f7940d7d 100644 --- a/documentation/en/api-v2-unstable-methods.md +++ b/documentation/en/api-v2-unstable-methods.md @@ -19,13 +19,19 @@ Please see Filecoin V2 API design documentation for more details: 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". +or a designated tag such as "latest", "finalized", or "safe". 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. +The "Safe" tag returns the tipset between the "Finalized" tipset and +"Latest - build.SafeHeightDistance". This provides a balance between +finality confidence and recency. If the tipset at the safe height is null, +the first non-nil parent tipset is returned, similar to the behavior of +selecting by height with the 'previous' option set to true. + In a case where no selector is provided, an error is returned. The selector must be explicitly specified. @@ -34,13 +40,13 @@ 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) + 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 diff --git a/itests/api_v2_test.go b/itests/api_v2_test.go index 705e6cbcbfd..17a9f40a894 100644 --- a/itests/api_v2_test.go +++ b/itests/api_v2_test.go @@ -19,6 +19,7 @@ import ( "github.com/filecoin-project/go-state-types/abi" "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/build" "github.com/filecoin-project/lotus/chain/actors/policy" "github.com/filecoin-project/lotus/chain/lf3" "github.com/filecoin-project/lotus/chain/types" @@ -54,6 +55,13 @@ func TestAPIV2_ThroughRPC(t *testing.T) { require.NoError(t, err) return ecFinalized } + safe = func(t *testing.T) *types.TipSet { + head, err := subject.ChainHead(ctx) + require.NoError(t, err) + safe, err := subject.ChainGetTipSetByHeight(ctx, head.Height()-build.SafeHeightDistance, head.Key()) + require.NoError(t, err) + return safe + } 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) @@ -62,8 +70,8 @@ func TestAPIV2_ThroughRPC(t *testing.T) { } } internalF3Error = errors.New("lost hearing in left eye") - plausibleCert = func(t *testing.T) *certs.FinalityCertificate { - f3FinalisedTipSet := tipSetAtHeight(f3FinalizedEpoch)(t) + plausibleCertAt = func(t *testing.T, epoch abi.ChainEpoch) *certs.FinalityCertificate { + f3FinalisedTipSet := tipSetAtHeight(epoch)(t) return &certs.FinalityCertificate{ ECChain: &gpbft.ECChain{ TipSets: []*gpbft.TipSet{{ @@ -122,12 +130,34 @@ func TestAPIV2_ThroughRPC(t *testing.T) { when: func(t *testing.T) { mockF3.running = true mockF3.latestCertErr = nil - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) }, request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"finalized"}],"id":1}`, wantTipSet: tipSetAtHeight(f3FinalizedEpoch), wantResponseStatus: http.StatusOK, }, + { + name: "safe tag is ec safe distance when more recent than f3 finalized", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCertErr = nil + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"safe"}],"id":1}`, + wantTipSet: safe, + wantResponseStatus: http.StatusOK, + }, + { + name: "safe tag is f3 finalized when ec minus safe distance is too old", + when: func(t *testing.T) { + mockF3.running = true + mockF3.latestCertErr = nil + mockF3.latestCert = plausibleCertAt(t, 890) + }, + request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"tag":"safe"}],"id":1}`, + wantTipSet: tipSetAtHeight(890), + wantResponseStatus: http.StatusOK, + }, { name: "finalized tag when f3 not ready falls back to ec", when: func(t *testing.T) { @@ -196,7 +226,7 @@ func TestAPIV2_ThroughRPC(t *testing.T) { name: "height with no anchor before finalized epoch is ok", when: func(t *testing.T) { mockF3.running = true - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) mockF3.latestCertErr = nil }, request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":111}}],"id":1}`, @@ -207,7 +237,7 @@ func TestAPIV2_ThroughRPC(t *testing.T) { name: "height with no anchor after finalized epoch is error", when: func(t *testing.T) { mockF3.running = true - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) mockF3.latestCertErr = nil }, request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":145}}],"id":1}`, @@ -240,7 +270,7 @@ func TestAPIV2_ThroughRPC(t *testing.T) { name: "height with anchor to latest", when: func(t *testing.T) { mockF3.running = true - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) mockF3.latestCertErr = nil }, request: `{"jsonrpc":"2.0","method":"Filecoin.ChainGetTipSet","params":[{"height":{"at":890,"anchor":{"tag":"latest"}}}],"id":1}`, @@ -315,7 +345,7 @@ func TestAPIV2_ThroughRPC(t *testing.T) { when: func(t *testing.T) { mockF3.running = true mockF3.latestCertErr = nil - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) }, request: `{"jsonrpc":"2.0","method":"Filecoin.StateGetActor","params":["f01000",{"tag":"finalized"}],"id":1}`, wantResponseStatus: http.StatusOK, @@ -325,7 +355,7 @@ func TestAPIV2_ThroughRPC(t *testing.T) { name: "height with anchor to latest", when: func(t *testing.T) { mockF3.running = true - mockF3.latestCert = plausibleCert(t) + mockF3.latestCert = plausibleCertAt(t, f3FinalizedEpoch) mockF3.latestCertErr = nil }, request: `{"jsonrpc":"2.0","method":"Filecoin.StateGetActor","params":["f01000",{"height":{"at":15,"anchor":{"tag":"latest"}}}],"id":1}`, diff --git a/node/impl/full/chain_v2.go b/node/impl/full/chain_v2.go index 0cd4a3b2918..5d538789fe2 100644 --- a/node/impl/full/chain_v2.go +++ b/node/impl/full/chain_v2.go @@ -10,6 +10,7 @@ import ( "github.com/filecoin-project/go-f3" "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/build" "github.com/filecoin-project/lotus/chain/actors/policy" "github.com/filecoin-project/lotus/chain/lf3" "github.com/filecoin-project/lotus/chain/store" @@ -57,13 +58,35 @@ func (cm *ChainModuleV2) ChainGetTipSet(ctx context.Context, selector types.TipS } func (cm *ChainModuleV2) getTipSetByTag(ctx context.Context, tag types.TipSetTag) (*types.TipSet, error) { - if tag == types.TipSetTags.Latest { + switch tag { + case types.TipSetTags.Latest: return cm.Chain.GetHeaviestTipSet(), nil - } - if tag != types.TipSetTags.Finalized { + case types.TipSetTags.Finalized: + return cm.getLatestFinalizedTipset(ctx) + case types.TipSetTags.Safe: + return cm.getLatestSafeTipSet(ctx) + default: return nil, xerrors.Errorf("unknown tipset tag: %s", tag) } +} + +func (cm *ChainModuleV2) getLatestSafeTipSet(ctx context.Context) (*types.TipSet, error) { + finalized, err := cm.getLatestFinalizedTipset(ctx) + if err != nil { + return nil, xerrors.Errorf("getting latest finalized tipset: %w", err) + } + heaviest := cm.Chain.GetHeaviestTipSet() + if heaviest == nil { + return nil, xerrors.Errorf("no known heaviest tipset") + } + safeHeight := max(0, heaviest.Height()-build.SafeHeightDistance) + if finalized != nil && finalized.Height() >= safeHeight { + return finalized, nil + } + return cm.Chain.GetTipsetByHeight(ctx, safeHeight, heaviest, true) +} +func (cm *ChainModuleV2) getLatestFinalizedTipset(ctx context.Context) (*types.TipSet, error) { if cm.F3 == nil { // F3 is disabled; fall back to EC finality. return cm.getECFinalized(ctx)