diff --git a/eth/fetcher/tx_fetcher.go b/eth/fetcher/tx_fetcher.go index efab9af6c..8fd9d15d7 100644 --- a/eth/fetcher/tx_fetcher.go +++ b/eth/fetcher/tx_fetcher.go @@ -25,11 +25,13 @@ import ( "time" mapset "github.com/deckarep/golang-set" + lru "github.com/hashicorp/golang-lru" "github.com/morph-l2/go-ethereum/common" "github.com/morph-l2/go-ethereum/common/mclock" "github.com/morph-l2/go-ethereum/core" "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/event" "github.com/morph-l2/go-ethereum/log" "github.com/morph-l2/go-ethereum/metrics" ) @@ -54,6 +56,10 @@ const ( // re-request them. maxTxUnderpricedSetSize = 32768 + // txOnChainCacheLimit is the number of recently confirmed transactions to + // keep around for announce pre-filtering. + txOnChainCacheLimit = 32768 + // txArriveTimeout is the time allowance before an announced transaction is // explicitly requested. txArriveTimeout = 500 * time.Millisecond @@ -72,6 +78,7 @@ var ( var ( txAnnounceInMeter = metrics.NewRegisteredMeter("eth/fetcher/transaction/announces/in", nil) txAnnounceKnownMeter = metrics.NewRegisteredMeter("eth/fetcher/transaction/announces/known", nil) + txAnnounceOnchainMeter = metrics.NewRegisteredMeter("eth/fetcher/transaction/announces/onchain", nil) txAnnounceUnderpricedMeter = metrics.NewRegisteredMeter("eth/fetcher/transaction/announces/underpriced", nil) txAnnounceDOSMeter = metrics.NewRegisteredMeter("eth/fetcher/transaction/announces/dos", nil) @@ -126,6 +133,10 @@ type txDrop struct { peer string } +type txFetcherChain interface { + SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription +} + // TxFetcher is responsible for retrieving new transaction based on announcements. // // The fetcher operates in 3 stages: @@ -174,15 +185,26 @@ type TxFetcher struct { addTxs func([]*types.Transaction) []error // Insert a batch of transactions into local txpool fetchTxs func(string, []common.Hash) error // Retrieves a set of txs from a remote peer - step chan struct{} // Notification channel when the fetcher loop iterates - clock mclock.Clock // Time wrapper to simulate in tests - rand *mrand.Rand // Randomizer to use in tests instead of map range loops (soft-random) + step chan struct{} // Notification channel when the fetcher loop iterates + clock mclock.Clock // Time wrapper to simulate in tests + rand *mrand.Rand // Randomizer to use in tests instead of map range loops (soft-random) + chain txFetcherChain // Blockchain used to track recently confirmed txs + + txOnChainCache *lru.Cache // Cache of recently confirmed tx hashes + headEventCh chan core.ChainEvent + headSub event.Subscription } // NewTxFetcher creates a transaction fetcher to retrieve transaction // based on hash announcements. func NewTxFetcher(hasTx func(common.Hash) bool, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error) *TxFetcher { - return NewTxFetcherForTests(hasTx, addTxs, fetchTxs, mclock.System{}, nil) + return newTxFetcher(nil, hasTx, addTxs, fetchTxs, mclock.System{}, nil) +} + +// NewTxFetcherWithChain creates a transaction fetcher that also tracks recently +// confirmed transactions from canonical chain events. +func NewTxFetcherWithChain(chain *core.BlockChain, hasTx func(common.Hash) bool, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error) *TxFetcher { + return newTxFetcher(chain, hasTx, addTxs, fetchTxs, mclock.System{}, nil) } // NewTxFetcherForTests is a testing method to mock out the realtime clock with @@ -190,25 +212,38 @@ func NewTxFetcher(hasTx func(common.Hash) bool, addTxs func([]*types.Transaction func NewTxFetcherForTests( hasTx func(common.Hash) bool, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, clock mclock.Clock, rand *mrand.Rand) *TxFetcher { + return newTxFetcher(nil, hasTx, addTxs, fetchTxs, clock, rand) +} + +// newTxFetcher wires up the fetcher with an optional chain event source. +func newTxFetcher( + chain txFetcherChain, hasTx func(common.Hash) bool, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, + clock mclock.Clock, rand *mrand.Rand) *TxFetcher { + txOnChainCache, err := lru.New(txOnChainCacheLimit) + if err != nil { + panic(err) + } return &TxFetcher{ - notify: make(chan *txAnnounce), - cleanup: make(chan *txDelivery), - drop: make(chan *txDrop), - quit: make(chan struct{}), - waitlist: make(map[common.Hash]map[string]struct{}), - waittime: make(map[common.Hash]mclock.AbsTime), - waitslots: make(map[string]map[common.Hash]struct{}), - announces: make(map[string]map[common.Hash]struct{}), - announced: make(map[common.Hash]map[string]struct{}), - fetching: make(map[common.Hash]string), - requests: make(map[string]*txRequest), - alternates: make(map[common.Hash]map[string]struct{}), - underpriced: mapset.NewSet(), - hasTx: hasTx, - addTxs: addTxs, - fetchTxs: fetchTxs, - clock: clock, - rand: rand, + notify: make(chan *txAnnounce), + cleanup: make(chan *txDelivery), + drop: make(chan *txDrop), + quit: make(chan struct{}), + waitlist: make(map[common.Hash]map[string]struct{}), + waittime: make(map[common.Hash]mclock.AbsTime), + waitslots: make(map[string]map[common.Hash]struct{}), + announces: make(map[string]map[common.Hash]struct{}), + announced: make(map[common.Hash]map[string]struct{}), + fetching: make(map[common.Hash]string), + requests: make(map[string]*txRequest), + alternates: make(map[common.Hash]map[string]struct{}), + underpriced: mapset.NewSet(), + hasTx: hasTx, + addTxs: addTxs, + fetchTxs: fetchTxs, + clock: clock, + rand: rand, + chain: chain, + txOnChainCache: txOnChainCache, } } @@ -219,19 +254,23 @@ func (f *TxFetcher) Notify(peer string, hashes []common.Hash) error { txAnnounceInMeter.Mark(int64(len(hashes))) // Skip any transaction announcements that we already know of, or that we've - // previously marked as cheap and discarded. This check is of course racey, - // because multiple concurrent notifies will still manage to pass it, but it's - // still valuable to check here because it runs concurrent to the internal - // loop, so anything caught here is time saved internally. + // seen recently land on chain, or that we've previously marked as cheap and + // discarded. This check is of course racey, because multiple concurrent + // notifies will still manage to pass it, but it's still valuable to check + // here because it runs concurrent to the internal loop, so anything caught + // here is time saved internally. var ( - unknowns = make([]common.Hash, 0, len(hashes)) - duplicate, underpriced int64 + unknowns = make([]common.Hash, 0, len(hashes)) + duplicate, onchain, underpriced int64 ) for _, hash := range hashes { switch { case f.hasTx(hash): duplicate++ + case f.txOnChainCache.Contains(hash): + onchain++ + case f.underpriced.Contains(hash): underpriced++ @@ -240,6 +279,7 @@ func (f *TxFetcher) Notify(peer string, hashes []common.Hash) error { } } txAnnounceKnownMeter.Mark(duplicate) + txAnnounceOnchainMeter.Mark(onchain) txAnnounceUnderpricedMeter.Mark(underpriced) // If anything's left to announce, push it into the internal loop @@ -352,6 +392,10 @@ func (f *TxFetcher) Drop(peer string) error { // Start boots up the announcement based synchroniser, accepting and processing // hash notifications and block fetches until termination requested. func (f *TxFetcher) Start() { + if f.chain != nil && f.headEventCh == nil { + f.headEventCh = make(chan core.ChainEvent, 10) + f.headSub = f.chain.SubscribeChainEvent(f.headEventCh) + } go f.loop() } @@ -362,15 +406,34 @@ func (f *TxFetcher) Stop() { } func (f *TxFetcher) loop() { + if f.headSub != nil { + defer f.headSub.Unsubscribe() + } var ( waitTimer = new(mclock.Timer) timeoutTimer = new(mclock.Timer) waitTrigger = make(chan struct{}, 1) timeoutTrigger = make(chan struct{}, 1) + oldHead common.Hash + haveOldHead bool ) for { select { + case ev := <-f.headEventCh: + if ev.Block == nil { + break + } + if haveOldHead && ev.Block.ParentHash() != oldHead { + f.txOnChainCache.Purge() + } + oldHead = ev.Block.Hash() + haveOldHead = true + + for _, tx := range ev.Block.Transactions() { + f.txOnChainCache.Add(tx.Hash(), struct{}{}) + } + case ann := <-f.notify: // Drop part of the new announcements if there are too many accumulated. // Note, we could but do not filter already known transactions here as diff --git a/eth/fetcher/tx_fetcher_test.go b/eth/fetcher/tx_fetcher_test.go index 4d0ff8afe..b82eca34b 100644 --- a/eth/fetcher/tx_fetcher_test.go +++ b/eth/fetcher/tx_fetcher_test.go @@ -27,6 +27,8 @@ import ( "github.com/morph-l2/go-ethereum/common/mclock" "github.com/morph-l2/go-ethereum/core" "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/event" + "github.com/morph-l2/go-ethereum/trie" ) var ( @@ -54,6 +56,10 @@ type doWait struct { time time.Duration step bool } +type doChainEvent struct { + chain *testTxFetcherChain + block *types.Block +} type doDrop string type doFunc func() @@ -63,8 +69,28 @@ type isScheduled struct { fetching map[string][]common.Hash dangling map[string][]common.Hash } +type isOnChain []common.Hash type isUnderpriced int +type testTxFetcherChain struct { + feed event.Feed +} + +func (c *testTxFetcherChain) SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription { + return c.feed.Subscribe(ch) +} + +func (c *testTxFetcherChain) sendBlock(block *types.Block) { + c.feed.Send(core.ChainEvent{Block: block, Hash: block.Hash()}) +} + +func newTestChainBlock(number int64, parent common.Hash, txs []*types.Transaction) *types.Block { + return types.NewBlock(&types.Header{ + Number: big.NewInt(number), + ParentHash: parent, + }, txs, nil, nil, trie.NewStackTrie(nil)) +} + // txFetcherTest represents a test scenario that can be executed by the test // runner. type txFetcherTest struct { @@ -584,6 +610,105 @@ func TestTransactionFetcherBroadcasts(t *testing.T) { }) } +// TestTransactionFetcherOnchainFiltered verifies that chain events populate the +// on-chain cache and already confirmed transaction announcements get filtered +// before ever reaching the waitlist. +func TestTransactionFetcherOnchainFiltered(t *testing.T) { + chain := new(testTxFetcherChain) + block := newTestChainBlock(1, common.Hash{}, []*types.Transaction{testTxs[1]}) + + testTransactionFetcherParallel(t, txFetcherTest{ + init: func() *TxFetcher { + return newTxFetcher( + chain, + func(hash common.Hash) bool { return hash == testTxsHashes[0] }, + nil, + func(string, []common.Hash) error { return nil }, + mclock.System{}, nil, + ) + }, + steps: []interface{}{ + doChainEvent{chain: chain, block: block}, + isOnChain{testTxsHashes[1]}, + + // Announce a brand new hash, a txpool-known hash, and an on-chain hash. + // Only the new hash may land in the waitlist. + doTxNotify{ + peer: "A", + hashes: []common.Hash{{0x01}, testTxsHashes[0], testTxsHashes[1]}, + }, + isWaiting(map[string][]common.Hash{ + "A": {{0x01}}, + }), + isScheduled{tracking: nil, fetching: nil}, + + // Driving the wait timer must not resurrect the filtered + // hashes: they were never tracked, so the scheduler should + // only promote the single new hash. + doWait{time: txArriveTimeout, step: true}, + isWaiting(nil), + isScheduled{ + tracking: map[string][]common.Hash{ + "A": {{0x01}}, + }, + fetching: map[string][]common.Hash{ + "A": {{0x01}}, + }, + }, + }, + }) +} + +// TestTransactionFetcherOnchainCacheReorg verifies that a non-contiguous chain +// event flushes stale on-chain entries so reorged-out transactions can be +// fetched again. +func TestTransactionFetcherOnchainCacheReorg(t *testing.T) { + chain := new(testTxFetcherChain) + oldHead := newTestChainBlock(1, common.Hash{}, []*types.Transaction{testTxs[0]}) + newHead := newTestChainBlock(2, common.Hash{0xff}, []*types.Transaction{testTxs[1]}) + + testTransactionFetcherParallel(t, txFetcherTest{ + init: func() *TxFetcher { + return newTxFetcher( + chain, + func(common.Hash) bool { return false }, + nil, + func(string, []common.Hash) error { return nil }, + mclock.System{}, nil, + ) + }, + steps: []interface{}{ + doChainEvent{chain: chain, block: oldHead}, + isOnChain{testTxsHashes[0]}, + + doChainEvent{chain: chain, block: newHead}, + isOnChain{testTxsHashes[1]}, + + // The old on-chain hash must be forgotten after the reorg, while the new + // canonical one is still filtered out. + doTxNotify{ + peer: "A", + hashes: []common.Hash{testTxsHashes[0], testTxsHashes[1]}, + }, + isWaiting(map[string][]common.Hash{ + "A": {testTxsHashes[0]}, + }), + isScheduled{tracking: nil, fetching: nil}, + + doWait{time: txArriveTimeout, step: true}, + isWaiting(nil), + isScheduled{ + tracking: map[string][]common.Hash{ + "A": {testTxsHashes[0]}, + }, + fetching: map[string][]common.Hash{ + "A": {testTxsHashes[0]}, + }, + }, + }, + }) +} + // Tests that the waiting list timers properly reset and reschedule. func TestTransactionFetcherWaitTimerResets(t *testing.T) { testTransactionFetcherParallel(t, txFetcherTest{ @@ -1289,6 +1414,10 @@ func testTransactionFetcher(t *testing.T, tt txFetcherTest) { <-wait // Fetcher supposed to do something, wait until it's done } + case doChainEvent: + step.chain.sendBlock(step.block) + <-wait // Fetcher needs to process the chain event, wait until it's done + case doDrop: if err := fetcher.Drop(string(step)); err != nil { t.Errorf("step %d: %v", i, err) @@ -1298,6 +1427,13 @@ func testTransactionFetcher(t *testing.T, tt txFetcherTest) { case doFunc: step() + case isOnChain: + for _, hash := range step { + if !fetcher.txOnChainCache.Contains(hash) { + t.Errorf("step %d: hash %x missing from txOnChainCache", i, hash) + } + } + case isWaiting: // We need to check that the waiting list (stage 1) internals // match with the expected set. Check the peer->hash mappings diff --git a/eth/handler.go b/eth/handler.go index b03a4bb09..8a98f6dc4 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -244,7 +244,10 @@ func newHandler(config *handlerConfig) (*handler, error) { } return p.RequestTxs(hashes) } - h.txFetcher = fetcher.NewTxFetcher(h.txpool.Has, h.txpool.AddRemotes, fetchTx) + // The fetcher tracks recently confirmed transactions from chain events, so + // the pre-filter here only needs to answer whether a tx is already pending in + // the local txpool. + h.txFetcher = fetcher.NewTxFetcherWithChain(h.chain, h.txpool.Has, h.txpool.AddRemotes, fetchTx) h.chainSync = newChainSyncer(h) return h, nil } diff --git a/trie/proof.go b/trie/proof.go index b89b01e0c..7831efa0b 100644 --- a/trie/proof.go +++ b/trie/proof.go @@ -483,12 +483,38 @@ func VerifyRangeProof(rootHash common.Hash, firstKey []byte, lastKey []byte, key if bytes.Compare(keys[i], keys[i+1]) >= 0 { return false, errors.New("range is not monotonically increasing") } + // Reject same-length key pairs where one is a byte-prefix of the + // other; for fixed-width key spaces (e.g. 32-byte state keys) this + // would be identical to the key itself and is already caught above, + // but an explicit check documents the invariant. Variable-length + // keys where a shorter key happens to share a byte prefix with a + // longer key are valid MPT entries (each leaf carries a terminator + // in its nibble path) and must not be rejected here. + if len(keys[i]) == len(keys[i+1]) && bytes.HasPrefix(keys[i+1], keys[i]) { + return false, errors.New("range contains path prefixes") + } } for _, value := range values { if len(value) == 0 { return false, errors.New("range contains deletion") } } + // Reject responses whose first/last keys fall outside the requested + // [firstKey, lastKey] window. The trie-hash check below would also + // eventually reject such cases, but doing it here avoids wasting CPU + // on reconstructing a trie for clearly out-of-range data and makes + // the failure mode explicit. + if len(keys) > 0 { + if bytes.Compare(keys[0], firstKey) < 0 { + return false, errors.New("first returned key is before the requested start") + } + // morph keeps the upstream-removed `lastKey` parameter; guard with + // `lastKey != nil` so the "no right bound" callers (proof == nil + // whole-trie path) are not spuriously rejected. + if lastKey != nil && bytes.Compare(keys[len(keys)-1], lastKey) > 0 { + return false, errors.New("last returned key is after the requested end") + } + } // Special case, there is no edge proof at all. The given range is expected // to be the whole leaf-set in the trie. if proof == nil { @@ -569,7 +595,12 @@ func VerifyRangeProof(rootHash common.Hash, firstKey []byte, lastKey []byte, key if tr.Hash() != rootHash { return false, fmt.Errorf("invalid proof, want hash %x, got %x", rootHash, tr.Hash()) } - return hasRightElement(root, keys[len(keys)-1]), nil + // Use tr.root rather than the stale local `root`: TryUpdate above may + // replace the root node (e.g. shortNode -> fullNode promotion), in + // which case `root` still points at the pre-update node and would make + // hasRightElement return a wrong "more elements" indicator on small + // tries. This aligns morph with upstream v1.10.26+ behavior. + return hasRightElement(tr.root, keys[len(keys)-1]), nil } // get returns the child of the given node. Return nil if the diff --git a/trie/proof_test.go b/trie/proof_test.go index 8d34e03d6..59f9da8af 100644 --- a/trie/proof_test.go +++ b/trie/proof_test.go @@ -892,6 +892,175 @@ func TestAllElementsEmptyValueRangeProof(t *testing.T) { } } +// TestRangeProofKeysOutOfRange verifies that VerifyRangeProof rejects a +// response whose first or last key falls outside the requested +// [firstKey, lastKey] window. This mirrors the defense-in-depth added by +// upstream PR #33898 (v1.17.1). The test also exercises the guard +// conditions: +// +// - len(keys) == 0 must skip the boundary check so the dedicated +// empty-range branch still runs +// - lastKey == nil must skip the upper-bound check (morph keeps the +// upstream-removed `lastKey` parameter so whole-trie callers pass nil) +// - the check runs before the proof == nil whole-trie branch, so +// out-of-range full-trie responses are rejected early +func TestRangeProofKeysOutOfRange(t *testing.T) { + trie, values := randomTrie(4096) + var entries entrySlice + for _, kv := range values { + entries = append(entries, kv) + } + sort.Sort(entries) + + const ( + start = 100 + end = 200 + ) + // newProof builds a fresh edge proof covering entries[start..end-1]. + newProof := func() *memorydb.Database { + p := memorydb.New() + if err := trie.Prove(entries[start].k, 0, p); err != nil { + t.Fatalf("failed to prove first key: %v", err) + } + if err := trie.Prove(entries[end-1].k, 0, p); err != nil { + t.Fatalf("failed to prove last key: %v", err) + } + return p + } + // newKeysVals returns a fresh, independent copy of the slice pair so + // each subtest can mutate its own firstKey/lastKey without leaking into + // sibling subtests. + newKeysVals := func() (keys, vals [][]byte) { + for i := start; i < end; i++ { + keys = append(keys, common.CopyBytes(entries[i].k)) + vals = append(vals, common.CopyBytes(entries[i].v)) + } + return + } + + t.Run("first_before_start", func(t *testing.T) { + keys, vals := newKeysVals() + if len(keys) <= 1 { + t.Skip("need at least 2 keys") + } + // keys[1] is the requested start; keys[0] is strictly before it. + firstKey := common.CopyBytes(keys[1]) + lastKey := common.CopyBytes(keys[len(keys)-1]) + _, err := VerifyRangeProof(trie.Hash(), firstKey, lastKey, keys, vals, newProof()) + if err == nil { + t.Fatalf("expected error, got nil") + } + if want := "first returned key is before the requested start"; err.Error() != want { + t.Fatalf("unexpected error: got %q, want %q", err, want) + } + }) + + t.Run("last_after_end", func(t *testing.T) { + keys, vals := newKeysVals() + if len(keys) <= 1 { + t.Skip("need at least 2 keys") + } + firstKey := common.CopyBytes(keys[0]) + // keys[len-2] is the requested end; keys[len-1] is strictly after it. + lastKey := common.CopyBytes(keys[len(keys)-2]) + _, err := VerifyRangeProof(trie.Hash(), firstKey, lastKey, keys, vals, newProof()) + if err == nil { + t.Fatalf("expected error, got nil") + } + if want := "last returned key is after the requested end"; err.Error() != want { + t.Fatalf("unexpected error: got %q, want %q", err, want) + } + }) + + t.Run("exact_boundary_ok", func(t *testing.T) { + keys, vals := newKeysVals() + firstKey := common.CopyBytes(keys[0]) + lastKey := common.CopyBytes(keys[len(keys)-1]) + _, err := VerifyRangeProof(trie.Hash(), firstKey, lastKey, keys, vals, newProof()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("last_key_nil_skips_upper_bound", func(t *testing.T) { + // With lastKey == nil the upper-bound check must be bypassed. Use + // the whole-trie proof==nil path, which is exactly the morph + // caller shape that relies on the guard. + var keys, vals [][]byte + for _, e := range entries { + keys = append(keys, e.k) + vals = append(vals, e.v) + } + _, err := VerifyRangeProof(trie.Hash(), nil, nil, keys, vals, nil) + if err != nil { + t.Fatalf("expected no error when lastKey == nil, got %v", err) + } + }) + + t.Run("empty_keys_skips_check", func(t *testing.T) { + // When len(keys) == 0 the boundary check must not fire; control + // flow should fall through to the dedicated empty-range branch. + proof := memorydb.New() + first := increseKey(common.CopyBytes(entries[len(entries)-1].k)) + if err := trie.Prove(first, 0, proof); err != nil { + t.Fatalf("failed to prove non-existent tail key: %v", err) + } + _, err := VerifyRangeProof(trie.Hash(), first, nil, nil, nil, proof) + if err != nil { + t.Fatalf("expected no error when len(keys) == 0, got %v", err) + } + }) + + t.Run("proof_nil_rejects_out_of_range", func(t *testing.T) { + // The boundary check must run before the proof == nil whole-trie + // branch so out-of-range responses are rejected before trie + // reconstruction. + keys, vals := newKeysVals() + firstKey := increseKey(common.CopyBytes(keys[0])) + _, err := VerifyRangeProof(trie.Hash(), firstKey, nil, keys, vals, nil) + if err == nil { + t.Fatalf("expected out-of-range rejection, got nil") + } + if want := "first returned key is before the requested start"; err.Error() != want { + t.Fatalf("unexpected error: got %q, want %q", err, want) + } + }) +} + +// TestRangeProofPathPrefix verifies that VerifyRangeProof does not reject a +// batch solely because a shorter key is a byte-prefix of a longer key; such +// pairs are valid MPT entries. The pair is still rejected further down due to +// the current same-length edge-key requirement. +func TestRangeProofPathPrefix(t *testing.T) { + tr := new(Trie) + kShort := []byte{0x01, 0x02} + kLong := []byte{0x01, 0x02, 0x03} + tr.Update(kShort, []byte("short")) + tr.Update(kLong, []byte("long")) + + proof := memorydb.New() + if err := tr.Prove(kShort, 0, proof); err != nil { + t.Fatalf("failed to prove kShort: %v", err) + } + if err := tr.Prove(kLong, 0, proof); err != nil { + t.Fatalf("failed to prove kLong: %v", err) + } + _, err := VerifyRangeProof( + tr.Hash(), + kShort, + kLong, + [][]byte{kShort, kLong}, + [][]byte{[]byte("short"), []byte("long")}, + proof, + ) + if err == nil { + t.Fatalf("expected error due to unequal edge-key lengths, got nil") + } + if want := "inconsistent edge keys"; err.Error() != want { + t.Fatalf("unexpected error: got %q, want %q", err, want) + } +} + // mutateByte changes one byte in b. func mutateByte(b []byte) { for r := mrand.Intn(len(b)); ; {