diff --git a/core/txpool/blobpool/limbo.go b/core/txpool/blobpool/limbo.go index b8bee2f22a..97654dfcbf 100644 --- a/core/txpool/blobpool/limbo.go +++ b/core/txpool/blobpool/limbo.go @@ -202,16 +202,37 @@ func (l *limbo) update(txhash common.Hash, block uint64) { return } // Retrieve the old blobs from the data store and write them back with a new - // block number. IF anything fails, there's not much to do, go on. - item, err := l.getAndDrop(id) + // block number. The new entry is written before the old one is dropped: blob + // sidecars are not stored in chain state, so the limbo is the only place + // from which a re-injection on a reorg-rollback can recover them. Dropping + // the existing entry first (as the previous implementation did) would lose + // the blob outright if the subsequent setAndIndex call failed. + data, err := l.store.Get(id) if err != nil { - log.Error("Failed to get and drop limboed blobs", "tx", txhash, "id", id, "err", err) + log.Error("Failed to load limboed blobs", "tx", txhash, "id", id, "err", err) + return + } + item := new(limboBlob) + if err := rlp.DecodeBytes(data, item); err != nil { + log.Error("Failed to decode limboed blobs", "tx", txhash, "id", id, "err", err) return } if err := l.setAndIndex(item.Ptx, block); err != nil { log.Error("Failed to set and index limboed blobs", "tx", txhash, "err", err) return } + // The new entry is now durable in the store and indexed under the new + // block; setAndIndex has overwritten l.index[txhash] with the new id, so + // clean up the old store entry and group mapping. A failure to drop the + // old store id only leaks a billy slot - the blob is still preserved + // under the new id. + if err := l.store.Delete(id); err != nil { + log.Error("Failed to drop superseded limbo entry", "tx", txhash, "old-id", id, "err", err) + } + delete(l.groups[item.Block], id) + if len(l.groups[item.Block]) == 0 { + delete(l.groups, item.Block) + } log.Trace("Blob transaction updated in limbo", "tx", txhash, "old-block", item.Block, "new-block", block) } diff --git a/core/txpool/blobpool/limbo_test.go b/core/txpool/blobpool/limbo_test.go new file mode 100644 index 0000000000..91bab56eeb --- /dev/null +++ b/core/txpool/blobpool/limbo_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package blobpool + +import ( + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" +) + +// TestLimboUpdateRoundTrip checks that update() relocates a tracked blob +// transaction to a new block while keeping it pullable. +// +// This is the regression test for #34944. The previous implementation deleted +// the existing limbo entry before inserting the relocated one, so a failure of +// the second step would have permanently dropped the blob sidecar. The current +// implementation writes the new entry first and only drops the old one once +// the new one is durable; the round-trip below exercises that happy path. +func TestLimboUpdateRoundTrip(t *testing.T) { + limbo, err := newLimbo(params.MainnetChainConfig, t.TempDir()) + if err != nil { + t.Fatalf("failed to open limbo: %v", err) + } + defer limbo.Close() + + key, _ := crypto.GenerateKey() + tx := makeTx(0, 1, 1, 1, key) + hash := tx.Hash() + + if err := limbo.push(newBlobTxForPool(tx), 100); err != nil { + t.Fatalf("push failed: %v", err) + } + if _, ok := limbo.index[hash]; !ok { + t.Fatalf("tx not indexed after push") + } + oldID := limbo.index[hash] + if _, ok := limbo.groups[100][oldID]; !ok { + t.Fatalf("tx not in groups[100] after push") + } + + limbo.update(hash, 101) + + if _, ok := limbo.groups[100]; ok { + t.Fatalf("old block group not cleaned up after update") + } + newID, ok := limbo.index[hash] + if !ok { + t.Fatalf("tx no longer indexed after update") + } + if _, ok := limbo.groups[101][newID]; !ok { + t.Fatalf("tx not in groups[101] after update") + } + + pulled, err := limbo.pull(hash) + if err != nil { + t.Fatalf("pull after update failed: %v", err) + } + if pulled.Tx.Hash() != hash { + t.Fatalf("pulled tx hash mismatch: got %x want %x", pulled.Tx.Hash(), hash) + } +}