Skip to content
Merged
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
13 changes: 13 additions & 0 deletions sidechain-orchestrator/api/bmm_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 40 additions & 6 deletions sidechain-orchestrator/engines/bmm_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
}
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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 == "" {
Expand All @@ -1109,27 +1124,46 @@ 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"
Comment thread
octobocto marked this conversation as resolved.
}

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
}
e.save(round)
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 {
Expand Down
74 changes: 74 additions & 0 deletions sidechain-orchestrator/engines/bmm_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Loading