From 3396894906ce89f31d3a104c61a0aa6f889b6f45 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jun 2026 01:41:46 +0000 Subject: [PATCH] feat(fastsync): verifiable anchor bundles, captured at scan time (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second piece of the ION fast-sync overlay (epic #111), on top of the #112 merkle proofs. A node now can retain, per kept anchor, a self-contained PoW-verifiable bundle so it can later serve fast-sync inclusion proofs without retaining or re-fetching full blocks. - internal/fastsync/bundle.go: Bundle{Height, TxIndex, RawTx, Branch}. Verify(root) recomputes the txid from the non-witness RawTx (rejecting trailing garbage) and checks the merkle branch against a caller-supplied, PoW-verified header merkle root, returning the proven txid. Compact binary encode/decode. - internal/fastsync/store.go: a bbolt Store keyed by txnum (height<<32|txIndex) — Put/Get/Range/Close. Auxiliary serving data in its own file (like the txid index), so a non-serving node pays nothing. - internal/index: BundleWriter interface + WithBundleWriter + BundlesEnabled(); at scan time scanBlock computes the merkle branch (it still holds the full block) and records a bundle for each kept anchor. Best-effort (a capture failure is logged, never fatal — bundles are auxiliary, not part of the index). A fast-sync peer is untrusted, but a Bundle verified against the receiving node's OWN header merkle root makes "this anchor exists at block H, index I" impossible to forge (omission is handled later by trust-then-verify, #116). The cmd flag to enable capture ships with the serving protocol (#115); there is no consumer until then. Tests: bundle Verify against a real CalcMerkleRoot root (+ tampered branch / wrong root / trailing-garbage rejection); encode round-trip + truncation/over-long-branch errors; store Put/Get/Range; and an end-to-end indexer test that a captured bundle verifies against the block's header merkle root (mutation-verified: an off-by-one capture index fails). go test -race ./... green. Co-authored-by: Liran Cohen Co-authored-by: Claude Opus 4.8 (1M context) --- internal/fastsync/bundle.go | 94 ++++++++++++++++++++++++++++++ internal/fastsync/bundle_test.go | 98 ++++++++++++++++++++++++++++++++ internal/fastsync/store.go | 93 ++++++++++++++++++++++++++++++ internal/fastsync/store_test.go | 56 ++++++++++++++++++ internal/index/indexer.go | 63 ++++++++++++++++++++ internal/index/indexer_test.go | 57 ++++++++++++++++++- 6 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 internal/fastsync/bundle.go create mode 100644 internal/fastsync/bundle_test.go create mode 100644 internal/fastsync/store.go create mode 100644 internal/fastsync/store_test.go diff --git a/internal/fastsync/bundle.go b/internal/fastsync/bundle.go new file mode 100644 index 0000000..407ea6d --- /dev/null +++ b/internal/fastsync/bundle.go @@ -0,0 +1,94 @@ +// Package fastsync holds the ION fast-sync overlay's verifiable anchor bundles +// (epic #111). A Bundle is a self-contained, PoW-verifiable record of one ION anchor: +// the raw anchoring transaction plus a merkle branch proving it is included at a +// known index in a known block. A fast-sync peer is untrusted, but a Bundle verified +// against the receiving node's own header merkle root makes "this anchor exists at +// block H, index I" impossible to forge — the trustless core of fast-sync. (A peer +// can still omit bundles; that is handled by trust-then-verify, not here.) +package fastsync + +import ( + "bytes" + "encoding/binary" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + + "github.com/13x-tech/ion-node/internal/chain" +) + +// Bundle is the verifiable proof-of-inclusion for one anchoring transaction. +type Bundle struct { + Height int32 // confirming block height (selects the header merkle root) + TxIndex int // the tx's position in that block + RawTx []byte // non-witness serialization; txid = double-SHA256(RawTx) + Branch []chainhash.Hash // merkle branch, bottom level first +} + +// Verify recomputes the txid from RawTx and checks the merkle branch against +// merkleRoot (the confirming block header's merkle root, which the caller has +// PoW-verified). It returns the proven txid and whether the proof holds. A caller +// MUST source merkleRoot from its own verified header chain — never from the peer +// that supplied the Bundle — and should then apply its own validity rules to the tx. +func (b Bundle) Verify(merkleRoot chainhash.Hash) (chainhash.Hash, bool) { + var tx wire.MsgTx + if err := tx.DeserializeNoWitness(bytes.NewReader(b.RawTx)); err != nil { + return chainhash.Hash{}, false + } + // Reject trailing garbage: the non-witness serialization must consume RawTx + // exactly (a sloppy parse must not be passed off as a valid tx). + var reser bytes.Buffer + if err := tx.SerializeNoWitness(&reser); err != nil || !bytes.Equal(reser.Bytes(), b.RawTx) { + return chainhash.Hash{}, false + } + txid := tx.TxHash() // double-SHA256 of the non-witness serialization + if !chain.VerifyMerkleProof(txid, b.TxIndex, b.Branch, merkleRoot) { + return chainhash.Hash{}, false + } + return txid, true +} + +// Tx deserializes the bundled transaction (non-witness). Callers use it to read the +// OP_RETURN anchor and the first input (for writer derivation). +func (b Bundle) Tx() (*wire.MsgTx, error) { + var tx wire.MsgTx + if err := tx.DeserializeNoWitness(bytes.NewReader(b.RawTx)); err != nil { + return nil, fmt.Errorf("fastsync: bundle tx: %w", err) + } + return &tx, nil +} + +// encode serializes a Bundle for storage / the wire: +// +// height(4 BE) | txIndex(4 BE) | branchLen(4 BE) | branch(32*n) | rawTx(rest) +func (b Bundle) encode() []byte { + out := make([]byte, 12, 12+chainhash.HashSize*len(b.Branch)+len(b.RawTx)) + binary.BigEndian.PutUint32(out[0:4], uint32(b.Height)) + binary.BigEndian.PutUint32(out[4:8], uint32(b.TxIndex)) + binary.BigEndian.PutUint32(out[8:12], uint32(len(b.Branch))) + for _, h := range b.Branch { + out = append(out, h[:]...) + } + return append(out, b.RawTx...) +} + +func decodeBundle(data []byte) (Bundle, error) { + if len(data) < 12 { + return Bundle{}, fmt.Errorf("fastsync: short bundle (%d bytes)", len(data)) + } + height := int32(binary.BigEndian.Uint32(data[0:4])) + txIndex := int(binary.BigEndian.Uint32(data[4:8])) + nBranch := int(binary.BigEndian.Uint32(data[8:12])) + off := 12 + if nBranch < 0 || nBranch > (len(data)-off)/chainhash.HashSize { + return Bundle{}, fmt.Errorf("fastsync: bundle branch length %d exceeds payload", nBranch) + } + branch := make([]chainhash.Hash, nBranch) + for i := 0; i < nBranch; i++ { + copy(branch[i][:], data[off:off+chainhash.HashSize]) + off += chainhash.HashSize + } + raw := append([]byte(nil), data[off:]...) + return Bundle{Height: height, TxIndex: txIndex, Branch: branch, RawTx: raw}, nil +} diff --git a/internal/fastsync/bundle_test.go b/internal/fastsync/bundle_test.go new file mode 100644 index 0000000..0c200af --- /dev/null +++ b/internal/fastsync/bundle_test.go @@ -0,0 +1,98 @@ +package fastsync + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/13x-tech/ion-node/internal/chain" +) + +func mkTx(i int) *wire.MsgTx { + mt := wire.NewMsgTx(1) + mt.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Index: uint32(i)}, + SignatureScript: []byte{byte(i), byte(i >> 8), 0x51}, + }) + mt.AddTxOut(wire.NewTxOut(int64(i+1), []byte{txscript.OP_TRUE})) + return mt +} + +// TestBundleVerify builds a real block tree, makes a Bundle for one tx, and confirms +// it verifies against the block's real (CalcMerkleRoot) merkle root — and that a +// tampered branch, wrong root, or trailing garbage in the raw tx all fail. +func TestBundleVerify(t *testing.T) { + const n, idx = 7, 3 + txs := make([]*btcutil.Tx, n) + ids := make([]chainhash.Hash, n) + for i := 0; i < n; i++ { + txs[i] = btcutil.NewTx(mkTx(i)) + ids[i] = *txs[i].Hash() + } + root := blockchain.CalcMerkleRoot(txs, false) + branch, err := chain.MerkleProof(ids, idx) + if err != nil { + t.Fatal(err) + } + var raw bytes.Buffer + if err := mkTx(idx).SerializeNoWitness(&raw); err != nil { + t.Fatal(err) + } + b := Bundle{Height: 700000, TxIndex: idx, RawTx: raw.Bytes(), Branch: branch} + + txid, ok := b.Verify(root) + if !ok { + t.Fatal("a valid bundle must verify against the block merkle root") + } + if txid != ids[idx] { + t.Errorf("verified txid = %s, want %s", txid, ids[idx]) + } + + tampered := Bundle{Height: b.Height, TxIndex: idx, RawTx: raw.Bytes(), Branch: append([]chainhash.Hash(nil), branch...)} + tampered.Branch[0][0] ^= 0xff + if _, ok := tampered.Verify(root); ok { + t.Error("a tampered branch must not verify") + } + var wrongRoot chainhash.Hash + wrongRoot[0] = 0x01 + if _, ok := b.Verify(wrongRoot); ok { + t.Error("the wrong merkle root must not verify") + } + garbage := Bundle{Height: b.Height, TxIndex: idx, RawTx: append(raw.Bytes(), 0xde, 0xad), Branch: branch} + if _, ok := garbage.Verify(root); ok { + t.Error("trailing garbage in RawTx must not verify (sloppy parse rejected)") + } +} + +func TestBundleEncodeRoundTrip(t *testing.T) { + b := Bundle{ + Height: 952901, + TxIndex: 5, + RawTx: []byte{0x01, 0x02, 0x03, 0x04}, + Branch: []chainhash.Hash{{0xaa, 0x01}, {0xbb, 0x02}}, + } + got, err := decodeBundle(b.encode()) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.Height != b.Height || got.TxIndex != b.TxIndex || !bytes.Equal(got.RawTx, b.RawTx) { + t.Errorf("round-trip header mismatch: %+v vs %+v", got, b) + } + if len(got.Branch) != 2 || got.Branch[0] != b.Branch[0] || got.Branch[1] != b.Branch[1] { + t.Errorf("round-trip branch mismatch: %v vs %v", got.Branch, b.Branch) + } + if _, err := decodeBundle([]byte{0x00, 0x01}); err == nil { + t.Error("a short bundle must error, not panic") + } + // A branch length claiming more hashes than the payload holds must error. + bad := make([]byte, 12) + bad[11] = 0xff // claim 255 branch hashes with no payload + if _, err := decodeBundle(bad); err == nil { + t.Error("an over-long branch length must error") + } +} diff --git a/internal/fastsync/store.go b/internal/fastsync/store.go new file mode 100644 index 0000000..15521c3 --- /dev/null +++ b/internal/fastsync/store.go @@ -0,0 +1,93 @@ +package fastsync + +import ( + "encoding/binary" + "fmt" + + bolt "go.etcd.io/bbolt" +) + +var bucketBundles = []byte("bundles") + +// Store is a persistent map from an anchor's transaction number (height<<32|txIndex) +// to its verifiable Bundle. A node that opts into serving fast-sync writes bundles +// here at scan time (when it still has the full block to build the merkle branch), +// so it can later serve PoW-verifiable proofs without retaining or re-fetching full +// blocks. It is auxiliary serving data, kept in its own file (like the txid index), +// so a node that does not serve fast-sync incurs no cost. +type Store struct { + db *bolt.DB +} + +// Open opens (creating if needed) the bundle store at path. +func Open(path string) (*Store, error) { + db, err := bolt.Open(path, 0o600, nil) + if err != nil { + return nil, fmt.Errorf("fastsync: open %s: %w", path, err) + } + if err := db.Update(func(tx *bolt.Tx) error { + _, e := tx.CreateBucketIfNotExists(bucketBundles) + return e + }); err != nil { + _ = db.Close() + return nil, fmt.Errorf("fastsync: init bucket: %w", err) + } + return &Store{db: db}, nil +} + +func txnumKey(txnum uint64) []byte { + k := make([]byte, 8) + binary.BigEndian.PutUint64(k, txnum) + return k +} + +// Put stores b under txnum (idempotent — re-indexing a height overwrites, matching +// the txid index's self-healing-after-reorg behavior). +func (s *Store) Put(txnum uint64, b Bundle) error { + return s.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(bucketBundles).Put(txnumKey(txnum), b.encode()) + }) +} + +// Get returns the bundle for txnum, ok=false if absent. +func (s *Store) Get(txnum uint64) (Bundle, bool, error) { + var ( + b Bundle + found bool + ) + err := s.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(bucketBundles).Get(txnumKey(txnum)) + if v == nil { + return nil + } + decoded, derr := decodeBundle(v) + if derr != nil { + return derr + } + b, found = decoded, true + return nil + }) + return b, found, err +} + +// Range invokes fn for every bundle with txnum in [fromTxnum, toTxnum], in ascending +// txnum order, stopping early if fn returns false. Used to serve a height range. +func (s *Store) Range(fromTxnum, toTxnum uint64, fn func(txnum uint64, b Bundle) bool) error { + return s.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket(bucketBundles).Cursor() + lo, hi := txnumKey(fromTxnum), txnumKey(toTxnum) + for k, v := c.Seek(lo); k != nil && string(k) <= string(hi); k, v = c.Next() { + b, derr := decodeBundle(v) + if derr != nil { + return derr + } + if !fn(binary.BigEndian.Uint64(k), b) { + return nil + } + } + return nil + }) +} + +// Close releases the underlying database. +func (s *Store) Close() error { return s.db.Close() } diff --git a/internal/fastsync/store_test.go b/internal/fastsync/store_test.go new file mode 100644 index 0000000..c61b58c --- /dev/null +++ b/internal/fastsync/store_test.go @@ -0,0 +1,56 @@ +package fastsync + +import ( + "path/filepath" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +func TestStorePutGetRange(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "bundles.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + mk := func(h int32, ti int) (uint64, Bundle) { + txnum := uint64(uint32(h))<<32 | uint64(uint32(ti)) + return txnum, Bundle{Height: h, TxIndex: ti, RawTx: []byte{byte(h), byte(ti)}, Branch: []chainhash.Hash{{byte(h)}}} + } + + n1, b1 := mk(100, 0) + n2, b2 := mk(100, 2) + n3, b3 := mk(105, 1) + for _, e := range []struct { + n uint64 + b Bundle + }{{n1, b1}, {n2, b2}, {n3, b3}} { + if err := s.Put(e.n, e.b); err != nil { + t.Fatalf("Put: %v", err) + } + } + + got, ok, err := s.Get(n2) + if err != nil || !ok { + t.Fatalf("Get(n2): ok=%v err=%v", ok, err) + } + if got.Height != 100 || got.TxIndex != 2 { + t.Errorf("Get(n2) = %+v, want height 100 txIndex 2", got) + } + if _, ok, _ := s.Get(uint64(1) << 40); ok { + t.Error("a missing txnum must return ok=false") + } + + // Range over height 100 only (txnum [100<<32, 101<<32)). + var seen []int + if err := s.Range(uint64(100)<<32, uint64(101)<<32-1, func(_ uint64, b Bundle) bool { + seen = append(seen, b.TxIndex) + return true + }); err != nil { + t.Fatalf("Range: %v", err) + } + if len(seen) != 2 || seen[0] != 0 || seen[1] != 2 { + t.Errorf("Range over height 100 = %v, want [0 2] in order", seen) + } +} diff --git a/internal/index/indexer.go b/internal/index/indexer.go index bc0ff75..1496326 100644 --- a/internal/index/indexer.go +++ b/internal/index/indexer.go @@ -6,13 +6,17 @@ package index import ( + "bytes" "context" "errors" "fmt" "log/slog" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/13x-tech/ion-node/internal/chain" "github.com/13x-tech/ion-node/internal/config" + "github.com/13x-tech/ion-node/internal/fastsync" "github.com/13x-tech/ion-node/internal/scan" "github.com/13x-tech/ion-node/internal/store" "github.com/13x-tech/ion-node/internal/writer" @@ -45,6 +49,8 @@ type Indexer struct { txIndex TxIndexer // optional; nil disables transaction indexing writerResolve writer.TxResolver // optional; nil disables writer verification + + bundles BundleWriter // optional; nil disables fast-sync bundle capture } // Option configures an Indexer. @@ -66,6 +72,22 @@ func WithWriterCheck(resolve writer.TxResolver) Option { return func(ix *Indexer) { ix.writerResolve = resolve } } +// BundleWriter persists a verifiable anchor bundle at scan time so the node can later +// serve fast-sync inclusion proofs (epic #111) without retaining or re-fetching full +// blocks. Optional — nil disables bundle capture. Implemented by *fastsync.Store. +type BundleWriter interface { + Put(txnum uint64, b fastsync.Bundle) error +} + +// WithBundleWriter enables fast-sync bundle capture: for each kept anchor the indexer +// records the anchoring tx and a merkle branch proving its inclusion in the block. +func WithBundleWriter(w BundleWriter) Option { + return func(ix *Indexer) { ix.bundles = w } +} + +// BundlesEnabled reports whether fast-sync bundle capture is wired. +func (ix *Indexer) BundlesEnabled() bool { return ix.bundles != nil } + // TxIndexEnabled reports whether the bitcoind-free transaction index is wired (a // TxIndexer was supplied via WithTxIndex). When false, every writer/lock prevout // resolution falls through to the untrusted Esplora locator, so any node that @@ -195,6 +217,7 @@ func (ix *Indexer) indexHeight(h int32) error { // scanBlock returns the ION anchors in a block, tagged with their coordinates. func (ix *Indexer) scanBlock(block chain.Block, blockHash string) []operations.Anchor { var anchors []operations.Anchor + var txids []chainhash.Hash // computed lazily, only if we capture a fast-sync bundle for txIndex, tx := range block.Transactions() { anchorStr, ok := ix.scanner.ScanTx(tx) if !ok { @@ -215,10 +238,50 @@ func (ix *Indexer) scanBlock(block chain.Block, blockHash string) []operations.A anchors = append(anchors, operations.NewAnchorOp( anchorStr, int(block.Height()), blockHash, txIndex, tx.Hash().String(), )) + // Fast-sync (#111): capture a verifiable inclusion bundle for this kept anchor + // while we still hold the full block to build the merkle branch. + if ix.bundles != nil { + if txids == nil { + txids = blockTxids(block) + } + ix.captureBundle(block, txids, txIndex) + } } return anchors } +// blockTxids returns the block's legacy (witness=false) txids — the leaves of the +// merkle tree the header commits to. +func blockTxids(block chain.Block) []chainhash.Hash { + msg := chain.MsgBlock(block) + ids := make([]chainhash.Hash, len(msg.Transactions)) + for i, t := range msg.Transactions { + ids[i] = t.TxHash() + } + return ids +} + +// captureBundle records a fast-sync bundle (the raw tx + a merkle branch proving its +// inclusion) for the anchor at txIndex. Best-effort: a failure is logged, never +// fatal — bundle capture is auxiliary serving data, not part of the index itself. +func (ix *Indexer) captureBundle(block chain.Block, txids []chainhash.Hash, txIndex int) { + branch, err := chain.MerkleProof(txids, txIndex) + if err != nil { + ix.log.Warn("fast-sync: merkle proof failed", "height", block.Height(), "txIndex", txIndex, "err", err) + return + } + var raw bytes.Buffer + if err := chain.MsgBlock(block).Transactions[txIndex].SerializeNoWitness(&raw); err != nil { + ix.log.Warn("fast-sync: serialize tx failed", "height", block.Height(), "txIndex", txIndex, "err", err) + return + } + txnum := uint64(uint32(block.Height()))<<32 | uint64(uint32(txIndex)) + b := fastsync.Bundle{Height: block.Height(), TxIndex: txIndex, RawTx: raw.Bytes(), Branch: branch} + if err := ix.bundles.Put(txnum, b); err != nil { + ix.log.Warn("fast-sync: bundle write failed", "height", block.Height(), "txIndex", txIndex, "err", err) + } +} + // reconcileReorg detects whether the active chain diverged from committed data // and, if so, rolls the store back to the fork (or fails-stop if too deep). // diff --git a/internal/index/indexer_test.go b/internal/index/indexer_test.go index 61d755f..78e9b10 100644 --- a/internal/index/indexer_test.go +++ b/internal/index/indexer_test.go @@ -16,6 +16,7 @@ import ( "github.com/13x-tech/ion-node/internal/chain" "github.com/13x-tech/ion-node/internal/config" + "github.com/13x-tech/ion-node/internal/fastsync" "github.com/13x-tech/ion-node/internal/log" "github.com/13x-tech/ion-node/internal/store" "github.com/13x-tech/ion-node/internal/txindex" @@ -124,7 +125,59 @@ func TestIndexHashBlockTOCTOU(t *testing.T) { } } -func newIndexer(t *testing.T, svc chain.Service, confDepth, maxReorg int32) (*Indexer, store.Store) { +// captureBundles is a BundleWriter that records bundles in memory for assertions. +type captureBundles struct{ m map[uint64]fastsync.Bundle } + +func (c *captureBundles) Put(txnum uint64, b fastsync.Bundle) error { + c.m[txnum] = b + return nil +} + +// TestIndexerCapturesVerifiableBundle: with a BundleWriter wired, indexing a block +// that carries an anchor records a fast-sync bundle whose merkle proof verifies +// against the block's real header merkle root (#113). +func TestIndexerCapturesVerifiableBundle(t *testing.T) { + blocks := buildChain(blockSpec{}, blockSpec{anchor: cidV0}, blockSpec{}, blockSpec{}) + svc := &fakeService{blocks: blocks} + bw := &captureBundles{m: map[uint64]fastsync.Bundle{}} + ix, _ := newIndexer(t, svc, 1, 100, WithBundleWriter(bw)) // frontier = tip(3)-1 = 2 + if !ix.BundlesEnabled() { + t.Fatal("BundlesEnabled() should be true with a BundleWriter") + } + mustSync(t, ix) + + // The anchor is at height 1, txIndex 1 (coinbase is 0, the anchor tx is 1). + txnum := uint64(1)<<32 | 1 + b, ok := bw.m[txnum] + if !ok { + t.Fatalf("no bundle captured for the anchor at txnum %d (captured: %d)", txnum, len(bw.m)) + } + if b.Height != 1 || b.TxIndex != 1 { + t.Errorf("bundle coords = height %d txIndex %d, want 1/1", b.Height, b.TxIndex) + } + // Verify against the block's REAL header merkle root — the trustless check a + // fast-sync client would perform. + txid, verified := b.Verify(blocks[1].Header.MerkleRoot) + if !verified { + t.Fatal("captured bundle must verify against the block's header merkle root") + } + if txid != blocks[1].Transactions[1].TxHash() { + t.Errorf("bundle txid = %s, want the anchor tx's %s", txid, blocks[1].Transactions[1].TxHash()) + } + + // A plain indexer (no BundleWriter) captures nothing. + plainBW := &captureBundles{m: map[uint64]fastsync.Bundle{}} + pix, _ := newIndexer(t, &fakeService{blocks: blocks}, 1, 100) + if pix.BundlesEnabled() { + t.Error("BundlesEnabled() should be false without a BundleWriter") + } + mustSync(t, pix) + if len(plainBW.m) != 0 { + t.Error("no bundles should be captured without a BundleWriter") + } +} + +func newIndexer(t *testing.T, svc chain.Service, confDepth, maxReorg int32, opts ...Option) (*Indexer, store.Store) { t.Helper() cfg, err := config.New("regtest", config.WithGenesisHeight(0), @@ -139,7 +192,7 @@ func newIndexer(t *testing.T, svc chain.Service, confDepth, maxReorg int32) (*In t.Fatalf("store open: %v", err) } t.Cleanup(func() { _ = st.Close() }) - return New(cfg, svc, st, log.Noop()), st + return New(cfg, svc, st, log.Noop(), opts...), st } func mustSync(t *testing.T, ix *Indexer) {