diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 20bd84bd3c..98353caf16 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -11,6 +11,7 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/btcsuite/btcd/blockchain" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -19,14 +20,50 @@ import ( var logger = log.Logger("keep-maintainer-spv") -// The length of the Bitcoin difficulty epoch in blocks. -const difficultyEpochLength = 2016 - // The maximum number of block headers allowed in a single SPV proof. Bounds // the forward walk over headers when computing required confirmations // (relevant on testnet4 where long runs of minimum-difficulty blocks occur). +// +// 144 is one day's worth of blocks at Bitcoin's ~10-minute target spacing. In a +// normal epoch every header contributes the full epoch difficulty, so a proof +// needs only a handful of headers (txProofDifficultyFactor headers, typically +// 6); the bound leaves ample margin. It exists solely to cap the walk against a +// pathological run of leading minimum-difficulty (DIFF1) headers. Note the +// proof window is anchored at a fixed start block and does not slide, so a run +// of leading DIFF1 headers longer than this bound makes the transaction +// permanently unprovable rather than merely delayed (see proofSkipReason). const maxProofHeaders = 144 +// minDifficultyTarget is the Bitcoin minimum-difficulty target, decoded from +// compact bits 0x1d00ffff. It mirrors the Bridge's +// BitcoinTx.MIN_DIFFICULTY_TARGET and is used to detect testnet4 BIP94 +// minimum-difficulty (DIFF1) headers by exact target equality, matching the +// on-chain skip predicate. +var minDifficultyTarget = blockchain.CompactToBig(0x1d00ffff) + +// proofSkipReason explains why an SPV proof cannot be assembled for a +// transaction in the current cycle. It lets callers log and record metrics with +// the specific cause instead of collapsing every skip into one generic message. +type proofSkipReason int + +const ( + // proofSkipNone means the proof is within the relay's difficulty range and + // should be assembled once enough confirmations accumulate. + proofSkipNone proofSkipReason = iota + // proofSkipOutsideRelayRange means the decisive header matched neither the + // current nor the previous relay epoch difficulty. The Bridge would revert + // with "Not at current or previous difficulty". This is usually transient - + // the transaction's epoch is not yet proven in the relay - and resolves as + // the relay advances. + proofSkipOutsideRelayRange + // proofSkipExceededMaxHeaders means no decisive header was found and not + // enough difficulty accumulated within maxProofHeaders. Because the proof + // window is anchored at a fixed start block, a run of leading + // minimum-difficulty (DIFF1) headers longer than the bound is permanently + // unprovable rather than merely delayed, hence it is signalled separately. + proofSkipExceededMaxHeaders +) + func Initialize( ctx context.Context, config Config, @@ -208,7 +245,7 @@ func (sm *spvMaintainer) proveTransactions( transactionHashStr, ) - isProofWithinRelayRange, accumulatedConfirmations, requiredConfirmations, err := getProofInfo( + accumulatedConfirmations, requiredConfirmations, skipReason, err := getProofInfo( transaction.Hash(), sm.btcChain, sm.spvChain, @@ -218,17 +255,54 @@ func (sm *spvMaintainer) proveTransactions( return fmt.Errorf("failed to get proof info: [%v]", err) } - if !isProofWithinRelayRange { + switch skipReason { + case proofSkipOutsideRelayRange: // The required proof goes outside the previous and current // difficulty epochs as seen by the relay. Skip the transaction. It - // will most likely be proven later. + // will most likely be proven later, once the relay advances. logger.Warnf( "skipped proving transaction [%s]; the range "+ "of the required proof goes outside the previous and "+ "current difficulty epochs as seen by the relay", transactionHashStr, ) + if recorder := getMetricsRecorder(); recorder != nil { + recorder.IncrementCounter( + "spv_proof_skipped_outside_relay_range_total", + 1, + ) + } + continue + case proofSkipExceededMaxHeaders: + // No decisive header was found and not enough difficulty + // accumulated within maxProofHeaders. Unlike the range skip above, + // this transaction may be permanently unprovable if it is buried + // under a run of minimum-difficulty blocks longer than the bound. + logger.Errorf( + "skipped proving transaction [%s]; could not find a decisive "+ + "header or accumulate enough difficulty within [%d] "+ + "headers; the transaction may be permanently unprovable", + transactionHashStr, + maxProofHeaders, + ) + if recorder := getMetricsRecorder(); recorder != nil { + recorder.IncrementCounter( + "spv_proof_skipped_exceeded_max_headers_total", + 1, + ) + } continue + case proofSkipNone: + // The proof is within range and assemblable; proceed to the + // confirmation check and submission below. + default: + // Defensive: a skip reason getProofInfo does not currently emit + // must never silently fall through to proof submission. + return fmt.Errorf( + "unexpected proof skip reason [%d] for transaction [%s]", + skipReason, + transactionHashStr, + ) } if accumulatedConfirmations < requiredConfirmations { @@ -306,21 +380,22 @@ func isInputCurrentWalletsMainUTXO( return bytes.Equal(mainUtxoHash[:], wallet.MainUtxoHash[:]), nil } -// getProofInfo returns information about the SPV proof. It includes the -// information whether the transaction proof range is within the previous and -// current difficulty epochs as seen by the relay, the accumulated number of -// confirmations and the required number of confirmations. +// getProofInfo returns information about the SPV proof: the accumulated number +// of confirmations, the required number of confirmations, and a proofSkipReason +// indicating whether the proof can be assembled (proofSkipNone) or why it must +// be skipped this cycle. The confirmation counts are meaningful only when the +// reason is proofSkipNone. func getProofInfo( transactionHash bitcoin.Hash, btcChain bitcoin.Chain, spvChain Chain, btcDiffChain btcdiff.Chain, ) ( - bool, uint, uint, error, + uint, uint, proofSkipReason, error, ) { latestBlockHeight, err := btcChain.GetLatestBlockHeight() if err != nil { - return false, 0, 0, fmt.Errorf( + return 0, 0, proofSkipNone, fmt.Errorf( "failed to get latest block height: [%v]", err, ) @@ -330,7 +405,7 @@ func getProofInfo( transactionHash, ) if err != nil { - return false, 0, 0, fmt.Errorf( + return 0, 0, proofSkipNone, fmt.Errorf( "failed to get transaction confirmations: [%v]", err, ) @@ -338,7 +413,7 @@ func getProofInfo( txProofDifficultyFactor, err := spvChain.TxProofDifficultyFactor() if err != nil { - return false, 0, 0, fmt.Errorf( + return 0, 0, proofSkipNone, fmt.Errorf( "failed to get transaction proof difficulty factor: [%v]", err, ) @@ -347,7 +422,7 @@ func getProofInfo( currentEpochDifficulty, previousEpochDifficulty, err := btcDiffChain.GetCurrentAndPrevEpochDifficulty() if err != nil { - return false, 0, 0, fmt.Errorf( + return 0, 0, proofSkipNone, fmt.Errorf( "failed to get Bitcoin epoch difficulties: [%v]", err, ) @@ -371,15 +446,17 @@ func getProofInfo( previousEpochDifficulty.Cmp(one) > 0 var requestedDiff *big.Int + var totalDifficultyRequired *big.Int observedDiff := big.NewInt(0) headerCount := uint(0) for { if headerCount >= maxProofHeaders { // Could not find a decisive header or accumulate enough - // difficulty within a sane number of headers. Skip the - // transaction; it may become provable later. - return false, 0, 0, nil + // difficulty within the header bound. Signal the distinct cause; + // with a fixed proof window this may be permanent rather than + // merely delayed. + return 0, 0, proofSkipExceededMaxHeaders, nil } blockHeight := proofStartBlock + uint64(headerCount) @@ -387,12 +464,12 @@ func getProofInfo( // Not enough mined blocks yet to assemble the proof. Report the // number of headers needed so far plus one more; the caller will // see accumulated < required and skip the transaction for now. - return true, accumulatedConfirmations, headerCount + 1, nil + return accumulatedConfirmations, headerCount + 1, proofSkipNone, nil } header, err := btcChain.GetBlockHeader(uint(blockHeight)) if err != nil { - return false, 0, 0, fmt.Errorf( + return 0, 0, proofSkipNone, fmt.Errorf( "failed to get block header at height [%v]: [%v]", blockHeight, err, @@ -404,8 +481,12 @@ func getProofInfo( observedDiff.Add(observedDiff, headerDiff) if requestedDiff == nil { - // Still looking for the decisive header. - if skipMinDifficulty && headerDiff.Cmp(one) == 0 { + // Still looking for the decisive header. Skip minimum-difficulty + // (DIFF1) headers by exact target equality, mirroring the Bridge's + // target == MIN_DIFFICULTY_TARGET predicate. Their work is still + // added to observedDiff above. + if skipMinDifficulty && + header.Target().Cmp(minDifficultyTarget) == 0 { continue } @@ -418,16 +499,17 @@ func getProofInfo( // difficulty". The transaction is either too fresh (its epoch // is not yet proven in the relay) or too old. Skip it; it may // be proven in the future. - return false, 0, 0, nil + return 0, 0, proofSkipOutsideRelayRange, nil } + + totalDifficultyRequired = new(big.Int).Mul( + requestedDiff, + txProofDifficultyFactor, + ) } - totalDifficultyRequired := new(big.Int).Mul( - requestedDiff, - txProofDifficultyFactor, - ) if observedDiff.Cmp(totalDifficultyRequired) >= 0 { - return true, accumulatedConfirmations, headerCount, nil + return accumulatedConfirmations, headerCount, proofSkipNone, nil } } } diff --git a/pkg/maintainer/spv/spv_test.go b/pkg/maintainer/spv/spv_test.go index 088c619883..534d2462d9 100644 --- a/pkg/maintainer/spv/spv_test.go +++ b/pkg/maintainer/spv/spv_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/btcsuite/btcd/blockchain" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" @@ -28,7 +29,7 @@ func TestGetProofInfo(t *testing.T) { previousEpochDifficulty *big.Int headerDifficultyAt func(uint) *big.Int headersFrom, headersTo uint - expectedIsProofWithinRelayRange bool + expectedSkipReason proofSkipReason expectedAccumulatedConfirmations uint expectedRequiredConfirmations uint }{ @@ -42,7 +43,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 19, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 20, expectedRequiredConfirmations: 6, }, @@ -55,7 +56,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 19, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 20, expectedRequiredConfirmations: 6, }, @@ -75,7 +76,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 30, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 31, expectedRequiredConfirmations: 10, }, @@ -93,7 +94,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 30, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 31, expectedRequiredConfirmations: 4, }, @@ -114,7 +115,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 30, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 31, expectedRequiredConfirmations: 8, }, @@ -128,7 +129,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 19, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 20, expectedRequiredConfirmations: 6, }, @@ -143,7 +144,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 19, - expectedIsProofWithinRelayRange: false, + expectedSkipReason: proofSkipOutsideRelayRange, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, }, @@ -157,7 +158,7 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 149, - expectedIsProofWithinRelayRange: false, + expectedSkipReason: proofSkipExceededMaxHeaders, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, }, @@ -172,7 +173,111 @@ func TestGetProofInfo(t *testing.T) { headersFrom: proofStart, headersTo: proofStart + 2, - expectedIsProofWithinRelayRange: true, + expectedSkipReason: proofSkipNone, + expectedAccumulatedConfirmations: 3, + expectedRequiredConfirmations: 4, + }, + // The decisive header matches the current (not previous) epoch on an + // epoch-spanning proof. Complements the "difficulty drops/raises" cases + // (which bind to the previous epoch) by exercising the current-epoch + // binding branch on asymmetric difficulties. Proof starts in the current + // epoch (32) for two blocks, then drops to the previous epoch's value + // (16). Required total is 6*32=192; 2*32 + 8*16 = 192 -> 10 headers. + "decisive header binds current epoch": { + transactionConfirmations: 31, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(h uint) *big.Int { + if h < proofStart+2 { + return diff(32) + } + return diff(16) + }, + headersFrom: proofStart, + headersTo: proofStart + 30, + + expectedSkipReason: proofSkipNone, + expectedAccumulatedConfirmations: 31, + expectedRequiredConfirmations: 10, + }, + // A minimum-difficulty (DIFF1) header appearing after the decisive + // header is accumulated like any other header and does not re-enter the + // skip/binding logic (that runs only until the decisive header is + // found). Decisive header 32 binds requestedDiff; the interior DIFF1 + // contributes its work to the observed difficulty. Required total is + // 6*32=192; 32 + 1 + 5*32 = 193 >= 192 -> 7 headers. + "minimum difficulty header after decisive header is counted": { + transactionConfirmations: 20, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(h uint) *big.Int { + if h == proofStart+1 { + return diff(1) + } + return diff(32) + }, + headersFrom: proofStart, + headersTo: proofStart + 19, + + expectedSkipReason: proofSkipNone, + expectedAccumulatedConfirmations: 20, + expectedRequiredConfirmations: 7, + }, + // The decisive header sits exactly at the header bound: 143 leading + // DIFF1 headers (skipped for binding but contributing 1 each) followed + // by the decisive header at position maxProofHeaders. Required total is + // 6*16=96; 143*1 + 16 = 159 >= 96 -> exactly 144 headers, at the bound. + "decisive header exactly at header bound is proven": { + transactionConfirmations: maxProofHeaders, + currentEpochDifficulty: diff(16), + previousEpochDifficulty: diff(32), + headerDifficultyAt: func(h uint) *big.Int { + if h < proofStart+maxProofHeaders-1 { + return diff(1) + } + return diff(16) + }, + headersFrom: proofStart, + headersTo: proofStart + maxProofHeaders - 1, + + expectedSkipReason: proofSkipNone, + expectedAccumulatedConfirmations: maxProofHeaders, + expectedRequiredConfirmations: maxProofHeaders, + }, + // The decisive header sits one past the header bound: maxProofHeaders + // leading DIFF1 headers exhaust the walk before the decisive header at + // position maxProofHeaders+1 is ever examined. This is the off-by-one + // companion to the case above and must be signalled as exceeded. + "decisive header just past header bound is skipped": { + transactionConfirmations: maxProofHeaders + 1, + currentEpochDifficulty: diff(16), + previousEpochDifficulty: diff(32), + headerDifficultyAt: func(h uint) *big.Int { + if h < proofStart+maxProofHeaders { + return diff(1) + } + return diff(16) + }, + headersFrom: proofStart, + headersTo: proofStart + maxProofHeaders, + + expectedSkipReason: proofSkipExceededMaxHeaders, + expectedAccumulatedConfirmations: 0, + expectedRequiredConfirmations: 0, + }, + // The chain tip is reached while still skipping leading DIFF1 headers, + // before any decisive header is bound (requestedDiff is still nil). The + // proof is within range and the caller is told to wait for one more + // header than currently exists. + "chain tip reached before decisive header": { + transactionConfirmations: 3, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(1) }, + headersFrom: proofStart, + headersTo: proofStart + 2, + + expectedSkipReason: proofSkipNone, expectedAccumulatedConfirmations: 3, expectedRequiredConfirmations: 4, }, @@ -206,14 +311,15 @@ func TestGetProofInfo(t *testing.T) { localChain.setTxProofDifficultyFactor(big.NewInt(6)) localChain.setCurrentEpoch(392) + // Note the setter's parameter order is (previous, current). localChain.setCurrentAndPrevEpochDifficulty( - test.currentEpochDifficulty, test.previousEpochDifficulty, + test.currentEpochDifficulty, ) - isProofWithinRelayRange, - accumulatedConfirmations, + accumulatedConfirmations, requiredConfirmations, + skipReason, err := getProofInfo( transactionHash, @@ -225,11 +331,11 @@ func TestGetProofInfo(t *testing.T) { t.Fatal(err) } - testutils.AssertBoolsEqual( + testutils.AssertIntsEqual( t, - "is proof within range", - test.expectedIsProofWithinRelayRange, - isProofWithinRelayRange, + "skip reason", + int(test.expectedSkipReason), + int(skipReason), ) testutils.AssertUintsEqual( @@ -249,6 +355,249 @@ func TestGetProofInfo(t *testing.T) { } } +// TestGetProofInfo_MinDifficultyDetectedByExactTarget pins the DIFF1 skip +// predicate to exact target equality. The Bridge skips headers whose target +// equals MIN_DIFFICULTY_TARGET, not headers whose computed difficulty rounds +// to 1. These differ: any target in (maxTarget/2, maxTarget] yields +// Difficulty()==1, but only the exact maxTarget is the canonical +// minimum-difficulty target. A header with Difficulty()==1 yet a target below +// maxTarget must NOT be skipped - it is a decisive header. +// +// Here that decisive header matches neither relay epoch, so the Bridge would +// revert and getProofInfo must report proofSkipOutsideRelayRange. If the +// predicate regressed to Difficulty()==1, the header would be skipped as DIFF1 +// and the following current-epoch headers would prove the transaction +// (proofSkipNone) - so this case fails loudly on that regression. +func TestGetProofInfo_MinDifficultyDetectedByExactTarget(t *testing.T) { + const proofStart = 790270 + + // A target of 3/4 * maxTarget: Difficulty() floors to 1, but the target is + // strictly below the minimum-difficulty target. BigToCompact truncates + // toward zero, so the encoded target can never round up to maxTarget. + nonMinTarget := new(big.Int).Mul(minDifficultyTarget, big.NewInt(3)) + nonMinTarget.Div(nonMinTarget, big.NewInt(4)) + decisiveHeader := &bitcoin.BlockHeader{ + Bits: blockchain.BigToCompact(nonMinTarget), + } + + // Guard the construction; without both properties the test proves nothing. + if decisiveHeader.Difficulty().Cmp(big.NewInt(1)) != 0 { + t.Fatalf( + "test header must have difficulty 1, got [%v]", + decisiveHeader.Difficulty(), + ) + } + if decisiveHeader.Target().Cmp(minDifficultyTarget) == 0 { + t.Fatal( + "test header target must differ from the minimum-difficulty target", + ) + } + + transactionHash, err := bitcoin.NewHashFromString( + "44c568bc0eac07a2a9c2b46829be5b5d46e7d00e17bfb613f506a75ccf86a473", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + + btcChain := newLocalBitcoinChain() + // The first (decisive) header carries Difficulty()==1 with a non-minimum + // target; the remaining headers carry the current epoch difficulty. + if err := btcChain.addBlockHeader(proofStart, decisiveHeader); err != nil { + t.Fatal(err) + } + if err := populateBlockHeaders( + btcChain, + proofStart+1, + proofStart+19, + func(uint) *big.Int { return big.NewInt(32) }, + ); err != nil { + t.Fatal(err) + } + btcChain.addTransactionConfirmations(transactionHash, 20) + + localChain := newLocalChain() + localChain.setTxProofDifficultyFactor(big.NewInt(6)) + localChain.setCurrentEpoch(392) + // Note the setter's parameter order is (previous, current). + localChain.setCurrentAndPrevEpochDifficulty(big.NewInt(16), big.NewInt(32)) + + _, _, skipReason, err := getProofInfo( + transactionHash, + btcChain, + localChain, + localChain, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "skip reason", + int(proofSkipOutsideRelayRange), + int(skipReason), + ) +} + +// recordingMetricsRecorder captures IncrementCounter calls for assertions. +// proveTransactions invokes it synchronously, so no locking is needed. +type recordingMetricsRecorder struct { + counters map[string]float64 +} + +func (r *recordingMetricsRecorder) IncrementCounter(name string, value float64) { + r.counters[name] += value +} + +// TestProveTransactions covers the caller-side handling of each proofSkipReason +// in proveTransactions. The safety property under test is that a skip reason +// never results in a proof submission, and that an assemblable proof is +// submitted; the per-reason metric counter is asserted as a secondary check. +func TestProveTransactions(t *testing.T) { + const proofStart = 790270 + + // A concrete transaction so proveTransactions can derive a real hash. + rawTransaction, err := hex.DecodeString( + "0100000000010110a15e879b7e8b07df62772579a64bf2b409409bbcc8bc2c7f6e39" + + "31dc615e920100000000ffffffff02042900000000000017a9143ec459d0f3c29286" + + "ae5df5fcc421e2786024277e87b4121600000000001600148db50eb52063ea9d98b3" + + "eac91489a90f738986f6024830450221009740ad12d2e74c00ccb4741d533d2ecd69" + + "02289144c4626508afb61eed790c97022006e67179e8e2a63dc4f1ab758867d8bbfe" + + "0a2b67682be6dadfa8e07d3b7ba04d012103989d253b17a6a0f41838b84ff0d20e88" + + "98f9d7b1a98f2564da4cc29dcf8581d900000000", + ) + if err != nil { + t.Fatal(err) + } + transaction := new(bitcoin.Transaction) + if err := transaction.Deserialize(rawTransaction); err != nil { + t.Fatal(err) + } + transactionHash := transaction.Hash() + + tests := map[string]struct { + headerDifficultyAt func(uint) *big.Int + headersTo uint + transactionConfirmations uint + expectSubmitted bool + expectedCounter string + }{ + // Decisive header (difficulty 8) matches neither epoch -> skipped. + "outside relay range is skipped and metered": { + headerDifficultyAt: func(uint) *big.Int { return big.NewInt(8) }, + headersTo: proofStart + 19, + transactionConfirmations: 20, + expectSubmitted: false, + expectedCounter: "spv_proof_skipped_outside_relay_range_total", + }, + // A run of DIFF1 headers longer than the bound never binds -> skipped. + "exceeded max headers is skipped and metered": { + headerDifficultyAt: func(uint) *big.Int { return big.NewInt(1) }, + headersTo: proofStart + 149, + transactionConfirmations: 150, + expectSubmitted: false, + expectedCounter: "spv_proof_skipped_exceeded_max_headers_total", + }, + // All headers at the current epoch difficulty -> proof is submitted. + "assemblable proof is submitted": { + headerDifficultyAt: func(uint) *big.Int { return big.NewInt(32) }, + headersTo: proofStart + 19, + transactionConfirmations: 20, + expectSubmitted: true, + expectedCounter: "", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + btcChain := newLocalBitcoinChain() + if err := populateBlockHeaders( + btcChain, + proofStart, + test.headersTo, + test.headerDifficultyAt, + ); err != nil { + t.Fatal(err) + } + btcChain.addTransactionConfirmations( + transactionHash, + test.transactionConfirmations, + ) + + localChain := newLocalChain() + localChain.setTxProofDifficultyFactor(big.NewInt(6)) + localChain.setCurrentEpoch(392) + // Note the setter's parameter order is (previous, current). + localChain.setCurrentAndPrevEpochDifficulty( + big.NewInt(16), + big.NewInt(32), + ) + + recorder := &recordingMetricsRecorder{ + counters: make(map[string]float64), + } + SetMetricsRecorder(recorder) + defer SetMetricsRecorder(nil) + + sm := &spvMaintainer{ + spvChain: localChain, + btcDiffChain: localChain, + btcChain: btcChain, + } + + var submitted []bitcoin.Hash + getter := func( + uint64, + int, + bitcoin.Chain, + Chain, + ) ([]*bitcoin.Transaction, error) { + return []*bitcoin.Transaction{transaction}, nil + } + submitter := func( + hash bitcoin.Hash, + _ uint, + _ bitcoin.Chain, + _ Chain, + ) error { + submitted = append(submitted, hash) + return nil + } + + if err := sm.proveTransactions(getter, submitter); err != nil { + t.Fatal(err) + } + + if test.expectSubmitted { + if len(submitted) != 1 || submitted[0] != transactionHash { + t.Errorf( + "expected the transaction to be submitted, "+ + "got submissions [%v]", + submitted, + ) + } + } else if len(submitted) != 0 { + t.Errorf( + "expected no submission on skip, got [%d]", + len(submitted), + ) + } + + if test.expectedCounter != "" { + if got := recorder.counters[test.expectedCounter]; got != 1 { + t.Errorf( + "expected counter [%s] to be 1, got [%v]", + test.expectedCounter, + got, + ) + } + } + }) + } +} + func TestUniqueWalletPublicKeyHashes(t *testing.T) { bytesFromHex := func(str string) []byte { value, err := hex.DecodeString(str)