Skip to content

Commit 9cf6fb1

Browse files
committed
feat(consensus): add tipset gas reservations orchestration
1 parent e2bcac6 commit 9cf6fb1

File tree

13 files changed

+1282
-27
lines changed

13 files changed

+1282
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- fix(eth): properly return vm error in all gas estimation methods ([filecoin-project/lotus#13389](https://github.com/filecoin-project/lotus/pull/13389))
1818
- chore: all actor cmd support --actor ([filecoin-project/lotus#13391](https://github.com/filecoin-project/lotus/pull/13391))
1919
- feat(spcli): add a `deposit-margin-factor` option to `lotus-miner init` so the sent deposit still covers the on-chain requirement if it rises between lookup and execution
20+
- feat(consensus): wire tipset gas reservations and reservation-aware mempool pre-pack to activate at network version 28 (UpgradeXxHeight), keeping receipts and gas accounting identical while preventing miner penalties from underfunded intra-tipset messages
2021

2122
# Node and Miner v1.34.1 / 2025-09-15
2223

chain/consensus/compute_state.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
196196
}
197197
}
198198

199+
// Network version at the execution epoch, used for reservations activation
200+
// and gating.
201+
nv := sm.GetNetworkVersion(ctx, epoch)
202+
199203
vmEarlyDuration := partDone()
200204
earlyCronGas := cronGas
201205
cronGas = 0
@@ -206,6 +210,18 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
206210
return cid.Undef, cid.Undef, xerrors.Errorf("making vm: %w", err)
207211
}
208212

213+
// Start a tipset reservation session around explicit messages. A deferred
214+
// call ensures the session is closed on all paths, while the explicit call
215+
// before cron keeps the session scope limited to explicit messages.
216+
if err := startReservations(ctx, vmi, bms, nv); err != nil {
217+
return cid.Undef, cid.Undef, xerrors.Errorf("starting tipset reservations: %w", err)
218+
}
219+
defer func() {
220+
if err := endReservations(ctx, vmi, nv); err != nil {
221+
log.Warnw("ending tipset reservations failed", "error", err)
222+
}
223+
}()
224+
209225
var (
210226
receipts []*types.MessageReceipt
211227
storingEvents = sm.ChainStore().IsStoringEvents()
@@ -260,6 +276,12 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
260276
}
261277
}
262278

279+
// End the reservation session before running cron so that reservations
280+
// strictly cover explicit messages only.
281+
if err := endReservations(ctx, vmi, nv); err != nil {
282+
return cid.Undef, cid.Undef, xerrors.Errorf("ending tipset reservations: %w", err)
283+
}
284+
263285
vmMsgDuration := partDone()
264286
partDone = metrics.Timer(ctx, metrics.VMApplyCron)
265287

chain/consensus/features.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package consensus
2+
3+
import "os"
4+
5+
// ReservationFeatureFlags holds feature toggles for tipset gas reservations.
6+
//
7+
// These flags are evaluated by consensus and the message pool when deciding
8+
// whether to attempt tipset‑scope reservations pre‑activation, and how to
9+
// interpret Begin/End reservation errors.
10+
type ReservationFeatureFlags struct {
11+
// MultiStageReservations enables tipset‑scope gas reservations
12+
// pre‑activation. When false, ReservationsEnabled returns false for
13+
// network versions before ReservationsActivationNetworkVersion and Lotus
14+
// operates in legacy mode (no Begin/End calls).
15+
//
16+
// At or after activation, reservations are always enabled regardless of
17+
// this flag.
18+
MultiStageReservations bool
19+
20+
// MultiStageReservationsStrict controls how pre‑activation reservation
21+
// failures are handled when MultiStageReservations is true:
22+
//
23+
// - When false (non‑strict), non‑NotImplemented Begin/End errors such
24+
// as ErrReservationsInsufficientFunds and ErrReservationsPlanTooLarge
25+
// are treated as best‑effort: Lotus logs and falls back to legacy
26+
// mode for that tipset.
27+
// - When true (strict), those reservation failures invalidate the
28+
// tipset pre‑activation. Node‑error classes (e.g. overflow or
29+
// invariant violations) always surface as errors regardless of this
30+
// flag.
31+
MultiStageReservationsStrict bool
32+
}
33+
34+
// Feature exposes the current reservation feature flags.
35+
//
36+
// Defaults:
37+
// - MultiStageReservations: enabled when LOTUS_ENABLE_TIPSET_RESERVATIONS=1.
38+
// - MultiStageReservationsStrict: enabled when
39+
// LOTUS_ENABLE_TIPSET_RESERVATIONS_STRICT=1.
40+
//
41+
// These defaults preserve the existing environment‑based gating while making
42+
// the flags explicit and testable.
43+
var Feature = ReservationFeatureFlags{
44+
MultiStageReservations: os.Getenv("LOTUS_ENABLE_TIPSET_RESERVATIONS") == "1",
45+
MultiStageReservationsStrict: os.Getenv("LOTUS_ENABLE_TIPSET_RESERVATIONS_STRICT") == "1",
46+
}
47+
48+
// SetFeatures overrides the global reservation feature flags. This is intended
49+
// for wiring from higher‑level configuration and for tests; callers should
50+
// treat it as process‑wide and set it once during initialization.
51+
func SetFeatures(flags ReservationFeatureFlags) {
52+
Feature = flags
53+
}

chain/consensus/reservations.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package consensus
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"go.opencensus.io/stats"
8+
9+
"github.com/filecoin-project/go-address"
10+
"github.com/filecoin-project/go-state-types/abi"
11+
"github.com/filecoin-project/go-state-types/big"
12+
"github.com/filecoin-project/go-state-types/network"
13+
cid "github.com/ipfs/go-cid"
14+
logging "github.com/ipfs/go-log/v2"
15+
16+
"github.com/filecoin-project/lotus/chain/types"
17+
"github.com/filecoin-project/lotus/chain/vm"
18+
"github.com/filecoin-project/lotus/metrics"
19+
)
20+
21+
var rlog = logging.Logger("reservations")
22+
23+
// ReservationsEnabled returns true when tipset reservations should be attempted.
24+
// Before the reservations activation network version, this helper consults the
25+
// MultiStageReservations feature flag. At or after the activation network
26+
// version, reservations are always enabled and become consensus-critical.
27+
func ReservationsEnabled(nv network.Version) bool {
28+
// After activation, reservations are required regardless of feature flags.
29+
if nv >= vm.ReservationsActivationNetworkVersion() {
30+
return true
31+
}
32+
33+
// Pre-activation: best-effort mode controlled by the feature flag.
34+
return Feature.MultiStageReservations
35+
}
36+
37+
// buildReservationPlan aggregates per-sender gas reservations across the full
38+
// tipset. The amount reserved for each message is gas_limit * gas_fee_cap, and
39+
// messages are deduplicated by CID across all blocks in canonical order,
40+
// matching processedMsgs handling in ApplyBlocks.
41+
func buildReservationPlan(bms []FilecoinBlockMessages) map[address.Address]abi.TokenAmount {
42+
plan := make(map[address.Address]abi.TokenAmount)
43+
seen := make(map[cid.Cid]struct{})
44+
45+
for _, b := range bms {
46+
// canonical order is preserved in the combined slices append below
47+
for _, cm := range append(b.BlsMessages, b.SecpkMessages...) {
48+
m := cm.VMMessage()
49+
mcid := m.Cid()
50+
if _, ok := seen[mcid]; ok {
51+
continue
52+
}
53+
seen[mcid] = struct{}{}
54+
// Only explicit messages are included in blocks; implicit messages are applied separately.
55+
cost := types.BigMul(types.NewInt(uint64(m.GasLimit)), m.GasFeeCap)
56+
if prev, ok := plan[m.From]; ok {
57+
plan[m.From] = types.BigAdd(prev, cost)
58+
} else {
59+
plan[m.From] = cost
60+
}
61+
}
62+
}
63+
return plan
64+
}
65+
66+
// startReservations is a helper that starts a reservation session on the VM if enabled.
67+
// If the computed plan is empty (no explicit messages), Begin is skipped entirely.
68+
func startReservations(ctx context.Context, vmi vm.Interface, bms []FilecoinBlockMessages, nv network.Version) error {
69+
if !ReservationsEnabled(nv) {
70+
return nil
71+
}
72+
73+
plan := buildReservationPlan(bms)
74+
if len(plan) == 0 {
75+
rlog.Debugw("skipping tipset reservations for empty plan")
76+
return nil
77+
}
78+
79+
total := abi.NewTokenAmount(0)
80+
for _, amt := range plan {
81+
total = big.Add(total, amt)
82+
}
83+
84+
stats.Record(ctx,
85+
metrics.ReservationPlanSenders.M(int64(len(plan))),
86+
metrics.ReservationPlanTotal.M(total.Int64()),
87+
)
88+
89+
rlog.Infow("starting tipset reservations", "senders", len(plan), "total", total)
90+
if err := vmi.StartTipsetReservations(ctx, plan); err != nil {
91+
return handleReservationError("begin", err, nv)
92+
}
93+
return nil
94+
}
95+
96+
// endReservations ends the active reservation session if enabled.
97+
func endReservations(ctx context.Context, vmi vm.Interface, nv network.Version) error {
98+
if !ReservationsEnabled(nv) {
99+
return nil
100+
}
101+
if err := vmi.EndTipsetReservations(ctx); err != nil {
102+
return handleReservationError("end", err, nv)
103+
}
104+
return nil
105+
}
106+
107+
// handleReservationError interprets Begin/End reservation errors according to
108+
// network version and feature flags, deciding whether to fall back to legacy
109+
// mode (pre-activation, non-strict) or surface the error.
110+
func handleReservationError(stage string, err error, nv network.Version) error {
111+
if err == nil {
112+
return nil
113+
}
114+
115+
// Post-activation: reservations are consensus-critical; all Begin/End
116+
// errors surface to the caller. ErrReservationsNotImplemented becomes a
117+
// node error (engine too old) under active rules.
118+
if nv >= vm.ReservationsActivationNetworkVersion() {
119+
return err
120+
}
121+
122+
// Pre-activation: ErrNotImplemented is always treated as a benign signal
123+
// that the engine does not support reservations yet; fall back to legacy
124+
// mode regardless of strictness.
125+
if errors.Is(err, vm.ErrReservationsNotImplemented) {
126+
rlog.Debugw("tipset reservations not implemented; continuing in legacy mode",
127+
"stage", stage, "error", err)
128+
return nil
129+
}
130+
131+
// Node-error classes: always surface as errors, even pre-activation.
132+
if errors.Is(err, vm.ErrReservationsSessionOpen) ||
133+
errors.Is(err, vm.ErrReservationsSessionClosed) ||
134+
errors.Is(err, vm.ErrReservationsNonZeroRemainder) ||
135+
errors.Is(err, vm.ErrReservationsOverflow) ||
136+
errors.Is(err, vm.ErrReservationsInvariantViolation) {
137+
return err
138+
}
139+
140+
// Reservation failures toggled by strict mode. When strict is disabled,
141+
// treat these as best-effort pre-activation and fall back to legacy mode.
142+
switch {
143+
case errors.Is(err, vm.ErrReservationsInsufficientFunds), errors.Is(err, vm.ErrReservationsPlanTooLarge):
144+
if Feature.MultiStageReservationsStrict {
145+
return err
146+
}
147+
rlog.Debugw("tipset reservations failed pre-activation; continuing in legacy mode (non-strict)",
148+
"stage", stage, "error", err)
149+
return nil
150+
default:
151+
// Unknown errors pre-activation are treated as node errors.
152+
return err
153+
}
154+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package consensus
2+
3+
import (
4+
"testing"
5+
6+
"github.com/filecoin-project/go-address"
7+
"github.com/filecoin-project/go-state-types/abi"
8+
9+
"github.com/filecoin-project/lotus/chain/store"
10+
"github.com/filecoin-project/lotus/chain/types"
11+
)
12+
13+
// BenchmarkBuildReservationPlan measures the cost of aggregating per-sender
14+
// reservations across a large synthetic tipset. This provides an upper bound
15+
// on the Stage-1 host-side overhead for tipset reservations.
16+
func BenchmarkBuildReservationPlan(b *testing.B) {
17+
addr1, err := address.NewIDAddress(100)
18+
if err != nil {
19+
b.Fatalf("creating addr1: %v", err)
20+
}
21+
addr2, err := address.NewIDAddress(200)
22+
if err != nil {
23+
b.Fatalf("creating addr2: %v", err)
24+
}
25+
26+
const numBlocks = 5
27+
const msgsPerBlock = 2000 // 10k messages total.
28+
29+
bms := make([]FilecoinBlockMessages, numBlocks)
30+
for i := range bms {
31+
bls := make([]types.ChainMsg, 0, msgsPerBlock)
32+
for j := 0; j < msgsPerBlock; j++ {
33+
from := addr1
34+
if j%2 == 1 {
35+
from = addr2
36+
}
37+
msg := &types.Message{
38+
From: from,
39+
To: addr2,
40+
Nonce: uint64(j),
41+
Value: abi.NewTokenAmount(0),
42+
GasFeeCap: abi.NewTokenAmount(1),
43+
GasLimit: 1_000_000,
44+
}
45+
bls = append(bls, msg)
46+
}
47+
bms[i] = FilecoinBlockMessages{
48+
BlockMessages: store.BlockMessages{
49+
BlsMessages: bls,
50+
},
51+
WinCount: 1,
52+
}
53+
}
54+
55+
b.ResetTimer()
56+
for i := 0; i < b.N; i++ {
57+
plan := buildReservationPlan(bms)
58+
if len(plan) == 0 {
59+
b.Fatalf("unexpected empty reservation plan")
60+
}
61+
}
62+
}
63+

0 commit comments

Comments
 (0)