From 6b976ff2a05144ec30a55c985a5cd8fbeb4b3ca1 Mon Sep 17 00:00:00 2001 From: octobocto Date: Sun, 4 Oct 2026 04:51:25 -0700 Subject: [PATCH] orchestrator: rebuild bmm bid when fees rise --- sidechain-orchestrator/api/bmm_handler.go | 13 ++++ sidechain-orchestrator/engines/bmm_engine.go | 46 ++++++++++-- .../engines/bmm_engine_test.go | 74 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/sidechain-orchestrator/api/bmm_handler.go b/sidechain-orchestrator/api/bmm_handler.go index c28a39957..5edc47a37 100644 --- a/sidechain-orchestrator/api/bmm_handler.go +++ b/sidechain-orchestrator/api/bmm_handler.go @@ -398,6 +398,19 @@ func (h *BMMHandler) TemplateOnTip( return node.TemplateOnTip(ctx, json.RawMessage(blockJSON)) } +// TemplateWorth reports the fees a fresh block template collects, in sats. +func (h *BMMHandler) TemplateWorth(ctx context.Context, sidechainType pb.BinaryType) (int64, error) { + _, node, err := h.sidechainTarget(sidechainType) + if err != nil { + return 0, err + } + template, err := node.GetBlockTemplate(ctx) + if err != nil { + return 0, err + } + return template.FeesSats, nil +} + // CreateBid assembles a sidechain block and broadcasts an M8 bid for it. The // bid is the transaction's fee, which is the only thing a miner can collect. func (h *BMMHandler) CreateBid( diff --git a/sidechain-orchestrator/engines/bmm_engine.go b/sidechain-orchestrator/engines/bmm_engine.go index 7181d866d..470c1aa32 100644 --- a/sidechain-orchestrator/engines/bmm_engine.go +++ b/sidechain-orchestrator/engines/bmm_engine.go @@ -94,6 +94,8 @@ type BmmBackend interface { // TemplateOnTip reports whether a block template still builds on the // sidechain tip. TemplateOnTip(ctx context.Context, sidechain pb.BinaryType, blockJSON string) (bool, error) + // TemplateWorth reports the fees a fresh block template collects, in sats. + TemplateWorth(ctx context.Context, sidechain pb.BinaryType) (int64, error) } // MainchainTip reports the mainchain tip the enforcer has validated, and the @@ -152,6 +154,8 @@ type BmmEngine struct { preparedTip string // prepareTries counts the coin counts preparedTip already paid for. prepareTries int + // refusedRebuilds holds, per sidechain, the worth a fee rebuild failed at. + refusedRebuilds map[pb.BinaryType]refusedRebuild wake chan struct{} } @@ -176,6 +180,8 @@ func NewBmmEngine( unconnected: make(map[pb.BinaryType][]*bmmstate.Round), subs: make(map[chan struct{}]struct{}), wake: make(chan struct{}, 1), + + refusedRebuilds: make(map[pb.BinaryType]refusedRebuild), } } @@ -1088,16 +1094,25 @@ func (e *BmmEngine) maybeRaise(ctx context.Context, sidechain pb.BinaryType, tar } // rebuildStaleBid replaces the live bid when the sidechain tip moved after its -// template, and reports whether it tried. A sidechain that syncs from its peers -// refuses a won block on a tip it left. +// template, or when a fresh template collects more fees, and reports whether it +// tried. A sidechain that syncs from its peers refuses a won block on a tip it +// left. func (e *BmmEngine) rebuildStaleBid(ctx context.Context, sidechain pb.BinaryType, target bmmTarget) bool { e.mu.Lock() round := e.current[sidechain] - var live bmmstate.Bid + var ( + live bmmstate.Bid + worth int64 + ) if round != nil { if bid := liveBid(round); bid != nil { live = *bid } + worth = round.BlockWorthSats + // A refused replacement costs the same on the next tick. + if refused := e.refusedRebuilds[sidechain]; refused.round == round.PrevMainHash && refused.worth > worth { + worth = refused.worth + } } e.mu.Unlock() if live.Txid == "" { @@ -1109,20 +1124,34 @@ func (e *BmmEngine) rebuildStaleBid(ctx context.Context, sidechain pb.BinaryType e.log.Debug().Err(err).Stringer("sidechain", sidechain).Msg("read the sidechain tip") return false } + reason := "the sidechain tip moved, rebuilding the bmm bid" + var fresh int64 if onTip { - return false + fresh, err = e.backend.TemplateWorth(ctx, sidechain) + if err != nil { + e.log.Debug().Err(err).Stringer("sidechain", sidechain).Msg("read the sidechain block worth") + return false + } + if fresh <= worth { + return false + } + reason = "the sidechain block collects more fees, rebuilding the bmm bid" } walletID := live.WalletID if walletID == "" { walletID = target.walletID } - e.log.Info().Stringer("sidechain", sidechain).Str("txid", live.Txid). - Msg("the sidechain tip moved, rebuilding the bmm bid") + e.log.Info().Stringer("sidechain", sidechain).Str("txid", live.Txid).Msg(reason) if err := e.placeBid(ctx, sidechain, round, walletID, live.BidSats, 0, target.maxBidSats, live.Txid, target.capToBlockWorth); err != nil { e.log.Warn().Err(err).Stringer("sidechain", sidechain). Msg("rebuilding bmm bid failed, keeping the live bid") + if onTip { + e.mu.Lock() + e.refusedRebuilds[sidechain] = refusedRebuild{round: round.PrevMainHash, worth: fresh} + e.mu.Unlock() + } e.notify() return true } @@ -1130,6 +1159,11 @@ func (e *BmmEngine) rebuildStaleBid(ctx context.Context, sidechain pb.BinaryType return true } +type refusedRebuild struct { + round string + worth int64 +} + func liveBid(round *bmmstate.Round) *bmmstate.Bid { for i := len(round.OurBids) - 1; i >= 0; i-- { if round.OurBids[i].State == BidLive { diff --git a/sidechain-orchestrator/engines/bmm_engine_test.go b/sidechain-orchestrator/engines/bmm_engine_test.go index 118902233..e5a93af9a 100644 --- a/sidechain-orchestrator/engines/bmm_engine_test.go +++ b/sidechain-orchestrator/engines/bmm_engine_test.go @@ -215,6 +215,12 @@ func (f *fakeBackend) TemplateOnTip(_ context.Context, _ pb.BinaryType, blockJSO return blockJSON == "side:"+f.sideTip, nil } +func (f *fakeBackend) TemplateWorth(_ context.Context, _ pb.BinaryType) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.feesSats, nil +} + func (f *fakeBackend) moveSideTip(tip string) { f.mu.Lock() defer f.mu.Unlock() @@ -2018,3 +2024,71 @@ func TestBmmEngineKeepsTheBidWhenTheSidechainTipCannotBeRead(t *testing.T) { require.Equal(t, 2, backend.bids, "the raise still goes out") assert.Equal(t, int64(13_000), backend.lastBidSats, "a raise, not a rebuild") } + +// A transaction that enters the sidechain mempool mid-round must reach the +// block of this round, not the next one. +func TestBmmEngineRebuildsABidWhenTheFeesRise(t *testing.T) { + engine, backend, _, _ := newEngine(t) + require.NoError(t, engine.Start(context.Background(), testSidechain, "", 30_000, false)) + + ctx := context.Background() + engine.tick(ctx) + require.Equal(t, 1, backend.bids) + + backend.feesSats = 13_500 + engine.tick(ctx) + + require.Equal(t, 2, backend.bids, "the richer template rebuilds the bid") + assert.Equal(t, "txid-1", backend.lastReplace, "the rebuild replaces our own bid") + assert.Equal(t, "block-1", backend.lastExpectTip, "the rebuild stays in the same round") + round := engine.Current(testSidechain) + require.Len(t, round.OurBids, 2) + assert.Equal(t, BidReplaced, round.OurBids[0].State) + assert.Equal(t, int64(13_500), round.BlockWorthSats) + + engine.tick(ctx) + assert.Equal(t, 2, backend.bids, "a bid on the richest template stands") +} + +func TestBmmEngineKeepsTheBidWhenTheFeesDoNotRise(t *testing.T) { + engine, backend, _, _ := newEngine(t) + require.NoError(t, engine.Start(context.Background(), testSidechain, "", 30_000, false)) + + ctx := context.Background() + engine.tick(ctx) + require.Equal(t, 1, backend.bids) + + engine.tick(ctx) + assert.Equal(t, 1, backend.bids, "the same worth keeps the bid") + + backend.feesSats = 11_500 + engine.tick(ctx) + assert.Equal(t, 1, backend.bids, "a lower worth keeps the bid") +} + +func TestBmmEngineRetriesARefusedFeeRebuildOnlyWhenTheFeesRiseAgain(t *testing.T) { + engine, backend, _, _ := newEngine(t) + require.NoError(t, engine.Start(context.Background(), testSidechain, "", 30_000, false)) + + ctx := context.Background() + engine.tick(ctx) + require.Equal(t, 1, backend.bids) + + backend.feesSats = 13_500 + backend.bidErr = connect.NewError(connect.CodeFailedPrecondition, errors.New("over the ceiling")) + engine.tick(ctx) + engine.tick(ctx) + + round := engine.Current(testSidechain) + require.Len(t, round.OurBids, 2, "one refused rebuild, no retry every tick") + assert.Equal(t, BidLive, round.OurBids[0].State, "the live bid stands") + assert.Equal(t, BidFailed, round.OurBids[1].State) + + backend.feesSats = 14_500 + backend.bidErr = nil + engine.tick(ctx) + + require.Equal(t, 2, backend.bids, "a higher worth tries again") + assert.Equal(t, "txid-1", backend.lastReplace) + assert.Equal(t, int64(14_500), engine.Current(testSidechain).BlockWorthSats) +}