Skip to content

feat: implement safe tag in tipset selection #13034

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 16, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 14 additions & 8 deletions api/v2api/full.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion build/openrpc/v2/full.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 2 additions & 0 deletions build/parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions chain/types/tipset_selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}}
},
Expand All @@ -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} },
}
)
Expand Down
5 changes: 5 additions & 0 deletions chain/types/tipset_selector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 14 additions & 8 deletions documentation/en/api-v2-unstable-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
46 changes: 38 additions & 8 deletions itests/api_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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{{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`,
Expand All @@ -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}`,
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
Expand Down
32 changes: 29 additions & 3 deletions node/impl/full/chain_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -57,13 +58,38 @@ 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()
switch {
case finalized == nil:
return heaviest, nil
case heaviest == nil:
return finalized, nil
case finalized.Height() >= heaviest.Height()-build.SafeHeightDistance:
return finalized, nil
default:
safeAt := max(0, heaviest.Height()-build.SafeHeightDistance)
return cm.Chain.GetTipsetByHeight(ctx, safeAt, 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)
Expand Down