Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@
- fix(eth): properly return vm error in all gas estimation methods ([filecoin-project/lotus#13389](https://github.com/filecoin-project/lotus/pull/13389))
- chore: all actor cmd support --actor ([filecoin-project/lotus#13391](https://github.com/filecoin-project/lotus/pull/13391))
- 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
- 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

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

Expand Down
22 changes: 22 additions & 0 deletions chain/consensus/compute_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
}
}

// Network version at the execution epoch, used for reservations activation
// and gating.
nv := sm.GetNetworkVersion(ctx, epoch)

vmEarlyDuration := partDone()
earlyCronGas := cronGas
cronGas = 0
Expand All @@ -206,6 +210,18 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
return cid.Undef, cid.Undef, xerrors.Errorf("making vm: %w", err)
}

// Start a tipset reservation session around explicit messages. A deferred
// call ensures the session is closed on all paths, while the explicit call
// before cron keeps the session scope limited to explicit messages.
if err := startReservations(ctx, vmi, bms, nv); err != nil {
return cid.Undef, cid.Undef, xerrors.Errorf("starting tipset reservations: %w", err)
}
defer func() {
if err := endReservations(ctx, vmi, nv); err != nil {
log.Warnw("ending tipset reservations failed", "error", err)
}
}()

var (
receipts []*types.MessageReceipt
storingEvents = sm.ChainStore().IsStoringEvents()
Expand Down Expand Up @@ -260,6 +276,12 @@ func (t *TipSetExecutor) ApplyBlocks(ctx context.Context,
}
}

// End the reservation session before running cron so that reservations
// strictly cover explicit messages only.
if err := endReservations(ctx, vmi, nv); err != nil {
return cid.Undef, cid.Undef, xerrors.Errorf("ending tipset reservations: %w", err)
}

vmMsgDuration := partDone()
partDone = metrics.Timer(ctx, metrics.VMApplyCron)

Expand Down
53 changes: 53 additions & 0 deletions chain/consensus/features.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package consensus

import "os"

// ReservationFeatureFlags holds feature toggles for tipset gas reservations.
//
// These flags are evaluated by consensus and the message pool when deciding
// whether to attempt tipset‑scope reservations pre‑activation, and how to
// interpret Begin/End reservation errors.
type ReservationFeatureFlags struct {
// MultiStageReservations enables tipset‑scope gas reservations
// pre‑activation. When false, ReservationsEnabled returns false for
// network versions before ReservationsActivationNetworkVersion and Lotus
// operates in legacy mode (no Begin/End calls).
//
// At or after activation, reservations are always enabled regardless of
// this flag.
MultiStageReservations bool

// MultiStageReservationsStrict controls how pre‑activation reservation
// failures are handled when MultiStageReservations is true:
//
// - When false (non‑strict), non‑NotImplemented Begin/End errors such
// as ErrReservationsInsufficientFunds and ErrReservationsPlanTooLarge
// are treated as best‑effort: Lotus logs and falls back to legacy
// mode for that tipset.
// - When true (strict), those reservation failures invalidate the
// tipset pre‑activation. Node‑error classes (e.g. overflow or
// invariant violations) always surface as errors regardless of this
// flag.
MultiStageReservationsStrict bool
}

// Feature exposes the current reservation feature flags.
//
// Defaults:
// - MultiStageReservations: enabled when LOTUS_ENABLE_TIPSET_RESERVATIONS=1.
// - MultiStageReservationsStrict: enabled when
// LOTUS_ENABLE_TIPSET_RESERVATIONS_STRICT=1.
//
// These defaults preserve the existing environment‑based gating while making
// the flags explicit and testable.
var Feature = ReservationFeatureFlags{
MultiStageReservations: os.Getenv("LOTUS_ENABLE_TIPSET_RESERVATIONS") == "1",
MultiStageReservationsStrict: os.Getenv("LOTUS_ENABLE_TIPSET_RESERVATIONS_STRICT") == "1",
}

// SetFeatures overrides the global reservation feature flags. This is intended
// for wiring from higher‑level configuration and for tests; callers should
// treat it as process‑wide and set it once during initialization.
func SetFeatures(flags ReservationFeatureFlags) {
Feature = flags
}
154 changes: 154 additions & 0 deletions chain/consensus/reservations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package consensus

import (
"context"
"errors"

"go.opencensus.io/stats"

"github.com/filecoin-project/go-address"
"github.com/filecoin-project/go-state-types/abi"
"github.com/filecoin-project/go-state-types/big"
"github.com/filecoin-project/go-state-types/network"
cid "github.com/ipfs/go-cid"
logging "github.com/ipfs/go-log/v2"

"github.com/filecoin-project/lotus/chain/types"
"github.com/filecoin-project/lotus/chain/vm"
"github.com/filecoin-project/lotus/metrics"
)

var rlog = logging.Logger("reservations")

// ReservationsEnabled returns true when tipset reservations should be attempted.
// Before the reservations activation network version, this helper consults the
// MultiStageReservations feature flag. At or after the activation network
// version, reservations are always enabled and become consensus-critical.
func ReservationsEnabled(nv network.Version) bool {
// After activation, reservations are required regardless of feature flags.
if nv >= vm.ReservationsActivationNetworkVersion() {
return true
}

// Pre-activation: best-effort mode controlled by the feature flag.
return Feature.MultiStageReservations
}

// buildReservationPlan aggregates per-sender gas reservations across the full
// tipset. The amount reserved for each message is gas_limit * gas_fee_cap, and
// messages are deduplicated by CID across all blocks in canonical order,
// matching processedMsgs handling in ApplyBlocks.
func buildReservationPlan(bms []FilecoinBlockMessages) map[address.Address]abi.TokenAmount {
plan := make(map[address.Address]abi.TokenAmount)
seen := make(map[cid.Cid]struct{})

for _, b := range bms {
// canonical order is preserved in the combined slices append below
for _, cm := range append(b.BlsMessages, b.SecpkMessages...) {
m := cm.VMMessage()
mcid := m.Cid()
if _, ok := seen[mcid]; ok {
continue
}
seen[mcid] = struct{}{}
// Only explicit messages are included in blocks; implicit messages are applied separately.
cost := types.BigMul(types.NewInt(uint64(m.GasLimit)), m.GasFeeCap)
if prev, ok := plan[m.From]; ok {
plan[m.From] = types.BigAdd(prev, cost)
} else {
plan[m.From] = cost
}
}
}
return plan
}

// startReservations is a helper that starts a reservation session on the VM if enabled.
// If the computed plan is empty (no explicit messages), Begin is skipped entirely.
func startReservations(ctx context.Context, vmi vm.Interface, bms []FilecoinBlockMessages, nv network.Version) error {
if !ReservationsEnabled(nv) {
return nil
}

plan := buildReservationPlan(bms)
if len(plan) == 0 {
rlog.Debugw("skipping tipset reservations for empty plan")
return nil
}

total := abi.NewTokenAmount(0)
for _, amt := range plan {
total = big.Add(total, amt)
}

stats.Record(ctx,
metrics.ReservationPlanSenders.M(int64(len(plan))),
metrics.ReservationPlanTotal.M(total.Int64()),
)

rlog.Infow("starting tipset reservations", "senders", len(plan), "total", total)
if err := vmi.StartTipsetReservations(ctx, plan); err != nil {
return handleReservationError("begin", err, nv)
}
return nil
}

// endReservations ends the active reservation session if enabled.
func endReservations(ctx context.Context, vmi vm.Interface, nv network.Version) error {
if !ReservationsEnabled(nv) {
return nil
}
if err := vmi.EndTipsetReservations(ctx); err != nil {
return handleReservationError("end", err, nv)
}
return nil
}

// handleReservationError interprets Begin/End reservation errors according to
// network version and feature flags, deciding whether to fall back to legacy
// mode (pre-activation, non-strict) or surface the error.
func handleReservationError(stage string, err error, nv network.Version) error {
if err == nil {
return nil
}

// Post-activation: reservations are consensus-critical; all Begin/End
// errors surface to the caller. ErrReservationsNotImplemented becomes a
// node error (engine too old) under active rules.
if nv >= vm.ReservationsActivationNetworkVersion() {
return err
}

// Pre-activation: ErrNotImplemented is always treated as a benign signal
// that the engine does not support reservations yet; fall back to legacy
// mode regardless of strictness.
if errors.Is(err, vm.ErrReservationsNotImplemented) {
rlog.Debugw("tipset reservations not implemented; continuing in legacy mode",
"stage", stage, "error", err)
return nil
}

// Node-error classes: always surface as errors, even pre-activation.
if errors.Is(err, vm.ErrReservationsSessionOpen) ||
errors.Is(err, vm.ErrReservationsSessionClosed) ||
errors.Is(err, vm.ErrReservationsNonZeroRemainder) ||
errors.Is(err, vm.ErrReservationsOverflow) ||
errors.Is(err, vm.ErrReservationsInvariantViolation) {
return err
}

// Reservation failures toggled by strict mode. When strict is disabled,
// treat these as best-effort pre-activation and fall back to legacy mode.
switch {
case errors.Is(err, vm.ErrReservationsInsufficientFunds), errors.Is(err, vm.ErrReservationsPlanTooLarge):
if Feature.MultiStageReservationsStrict {
return err
}
rlog.Debugw("tipset reservations failed pre-activation; continuing in legacy mode (non-strict)",
"stage", stage, "error", err)
return nil
default:
// Unknown errors pre-activation are treated as node errors.
return err
}
}
62 changes: 62 additions & 0 deletions chain/consensus/reservations_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package consensus

import (
"testing"

"github.com/filecoin-project/go-address"
"github.com/filecoin-project/go-state-types/abi"

"github.com/filecoin-project/lotus/chain/store"
"github.com/filecoin-project/lotus/chain/types"
)

// BenchmarkBuildReservationPlan measures the cost of aggregating per-sender
// reservations across a large synthetic tipset. This provides an upper bound
// on the Stage-1 host-side overhead for tipset reservations.
func BenchmarkBuildReservationPlan(b *testing.B) {
addr1, err := address.NewIDAddress(100)
if err != nil {
b.Fatalf("creating addr1: %v", err)
}
addr2, err := address.NewIDAddress(200)
if err != nil {
b.Fatalf("creating addr2: %v", err)
}

const numBlocks = 5
const msgsPerBlock = 2000 // 10k messages total.

bms := make([]FilecoinBlockMessages, numBlocks)
for i := range bms {
bls := make([]types.ChainMsg, 0, msgsPerBlock)
for j := 0; j < msgsPerBlock; j++ {
from := addr1
if j%2 == 1 {
from = addr2
}
msg := &types.Message{
From: from,
To: addr2,
Nonce: uint64(j),
Value: abi.NewTokenAmount(0),
GasFeeCap: abi.NewTokenAmount(1),
GasLimit: 1_000_000,
}
bls = append(bls, msg)
}
bms[i] = FilecoinBlockMessages{
BlockMessages: store.BlockMessages{
BlsMessages: bls,
},
WinCount: 1,
}
}

b.ResetTimer()
for i := 0; i < b.N; i++ {
plan := buildReservationPlan(bms)
if len(plan) == 0 {
b.Fatalf("unexpected empty reservation plan")
}
}
}
Loading
Loading