From 832c1168cedaf7841c4dc76d92b2913c54f61a84 Mon Sep 17 00:00:00 2001 From: Lev Akhnazarov Date: Fri, 12 Jun 2026 14:14:22 +0100 Subject: [PATCH] fix(spv): compute required proof headers from actual header difficulties getProofInfo assumed every proof header carries the relay epoch difficulty, so with txProofDifficultyFactor=1 it assembled single-header proofs. On testnet4 (BIP94), sweeps mined in minimum-difficulty blocks produced proofs containing only a DIFF1 header, which the Bridge rejects with "Not at current or previous difficulty". Mirror the Bridge's BitcoinTx logic instead: skip leading DIFF1 headers when both relay epochs are above minimum, bind the requested difficulty to the first decisive header matching the relay's current or previous epoch difficulty, and accumulate headers until their total observed difficulty covers requestedDifficulty * txProofDifficultyFactor. --- pkg/maintainer/spv/bitcoin_chain_test.go | 32 ++++ pkg/maintainer/spv/spv.go | 186 +++++++++-------------- pkg/maintainer/spv/spv_test.go | 181 +++++++++++++++++----- 3 files changed, 245 insertions(+), 154 deletions(-) diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 2f790bf11f..266128a94d 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -3,11 +3,43 @@ package spv import ( "bytes" "fmt" + "math/big" "sync" + "github.com/btcsuite/btcd/blockchain" "github.com/keep-network/keep-core/pkg/bitcoin" ) +// populateBlockHeaders adds headers for [fromHeight, toHeight] inclusive using +// difficultyAt(height) for each block's Bits-derived difficulty. +func populateBlockHeaders( + lbc *localBitcoinChain, + fromHeight, toHeight uint, + difficultyAt func(uint) *big.Int, +) error { + for h := fromHeight; h <= toHeight; h++ { + header := blockHeaderWithDifficulty(difficultyAt(h)) + if err := lbc.addBlockHeader(h, header); err != nil { + return err + } + } + return nil +} + +// blockHeaderWithDifficulty returns a header whose Difficulty() matches the +// given value (within Bitcoin compact encoding precision). Powers of two and +// small values round-trip exactly. +func blockHeaderWithDifficulty(difficulty *big.Int) *bitcoin.BlockHeader { + maxTarget := new(big.Int) + maxTarget.SetString( + "ffff0000000000000000000000000000000000000000000000000000", + 16, + ) + target := new(big.Int).Div(maxTarget, difficulty) + bits := blockchain.BigToCompact(target) + return &bitcoin.BlockHeader{Bits: bits} +} + type localBitcoinChain struct { mutex sync.Mutex diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index f842821c84..20bd84bd3c 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -22,6 +22,11 @@ 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). +const maxProofHeaders = 144 + func Initialize( ctx context.Context, config Config, @@ -339,135 +344,92 @@ func getProofInfo( ) } - // Calculate the starting block of the proof and the difficulty epoch number - // it belongs to. + currentEpochDifficulty, previousEpochDifficulty, err := + btcDiffChain.GetCurrentAndPrevEpochDifficulty() + if err != nil { + return false, 0, 0, fmt.Errorf( + "failed to get Bitcoin epoch difficulties: [%v]", + err, + ) + } + + // Calculate the starting block of the proof. proofStartBlock := uint64(latestBlockHeight - accumulatedConfirmations + 1) - proofStartEpoch := proofStartBlock / difficultyEpochLength - // Calculate the ending block of the proof and the difficulty epoch number - // it belongs to. - proofEndBlock := proofStartBlock + txProofDifficultyFactor.Uint64() - 1 - proofEndEpoch := proofEndBlock / difficultyEpochLength + // Walk the header chain forward, mirroring the Bridge's + // BitcoinTx.determineRequestedDifficulty and evaluateProofDifficulty + // behavior: + // - minimum-difficulty (DIFF1) headers are skipped while looking for the + // decisive header, but only when both relay epoch difficulties are + // above minimum (testnet4 BIP94 blocks in real epochs), + // - the first decisive header must match the relay's current or previous + // epoch difficulty; that value becomes the requested difficulty, + // - headers are accumulated until their total observed difficulty reaches + // requested difficulty times the transaction proof difficulty factor. + one := big.NewInt(1) + skipMinDifficulty := currentEpochDifficulty.Cmp(one) > 0 && + previousEpochDifficulty.Cmp(one) > 0 + + var requestedDiff *big.Int + observedDiff := big.NewInt(0) + headerCount := uint(0) - // Get the current difficulty epoch number as seen by the relay. Subtract - // one to get the previous epoch number. - currentEpoch, err := btcDiffChain.CurrentEpoch() - if err != nil { - return false, 0, 0, fmt.Errorf("failed to get current epoch: [%v]", err) - } - previousEpoch := currentEpoch - 1 - - // There are only three possible valid combinations of the proof's block - // headers range: the proof must either be entirely in the previous epoch, - // must be entirely in the current epoch or must span the previous and - // current epochs. - - // If the proof is entirely within the current epoch, required confirmations - // does not need to be adjusted. - if proofStartEpoch == currentEpoch && - proofEndEpoch == currentEpoch { - return true, accumulatedConfirmations, uint(txProofDifficultyFactor.Uint64()), nil - } + 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 + } - // If the proof is entirely within the previous epoch, required confirmations - // does not need to be adjusted. - if proofStartEpoch == previousEpoch && - proofEndEpoch == previousEpoch { - return true, accumulatedConfirmations, uint(txProofDifficultyFactor.Uint64()), nil - } + blockHeight := proofStartBlock + uint64(headerCount) + if blockHeight > uint64(latestBlockHeight) { + // 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 + } - // If the proof spans the previous and current difficulty epochs, the - // required confirmations may have to be adjusted. The reason for this is - // that there may be a drop in the value of difficulty between the current - // and the previous epochs. Example: - // Let's assume the transaction was done near the end of an epoch, so that - // part of the proof (let's say two block headers) is in the previous epoch - // and part of it is in the current epoch. - // If the previous epoch difficulty is 50 and the current epoch difficulty - // is 30, the total required difficulty of the proof will be transaction - // difficulty factor times previous difficulty: 6 * 50 = 300. - // However, if we simply use transaction difficulty factor to get the number - // of blocks we will end up with the difficulty sum that is too low: - // 50 + 50 + 30 + 30 + 30 + 30 = 220. To calculate the correct number of - // block headers needed we need to find how much difficulty needs to come - // from from the current epoch block headers: 300 - 2*50 = 200 and divide - // it by the current difficulty: 200 / 30 = 6 and add 1, because there - // was a remainder. So the number of block headers from the current epoch - // would be 7. The total number of block headers would be 9 and the sum - // of their difficulties would be: 50 + 50 + 30 + 30 + 30 + 30 + 30 + 30 + - // 30 = 310 which is enough to prove the transaction. - if proofStartEpoch == previousEpoch && - proofEndEpoch == currentEpoch { - currentEpochDifficulty, previousEpochDifficulty, err := - btcDiffChain.GetCurrentAndPrevEpochDifficulty() + header, err := btcChain.GetBlockHeader(uint(blockHeight)) if err != nil { return false, 0, 0, fmt.Errorf( - "failed to get Bitcoin epoch difficulties: [%v]", + "failed to get block header at height [%v]: [%v]", + blockHeight, err, ) } - // Calculate the total difficulty that is required for the proof. The - // proof begins in the previous difficulty epoch, therefore the total - // required difficulty will be the previous epoch difficulty times - // transaction proof difficulty factor. - totalDifficultyRequired := new(big.Int).Mul( - previousEpochDifficulty, - txProofDifficultyFactor, - ) + headerDiff := header.Difficulty() + headerCount++ + observedDiff.Add(observedDiff, headerDiff) - // Calculate the number of block headers in the proof that will come - // from the previous difficulty epoch. - numberOfBlocksPreviousEpoch := - uint64(difficultyEpochLength - proofStartBlock%difficultyEpochLength) - - // Calculate how much difficulty the blocks from the previous epoch part - // of the proof have in total. - totalDifficultyPreviousEpoch := new(big.Int).Mul( - big.NewInt(int64(numberOfBlocksPreviousEpoch)), - previousEpochDifficulty, - ) + if requestedDiff == nil { + // Still looking for the decisive header. + if skipMinDifficulty && headerDiff.Cmp(one) == 0 { + continue + } - // Calculate how much difficulty must come from the current epoch. - totalDifficultyCurrentEpoch := new(big.Int).Sub( - totalDifficultyRequired, - totalDifficultyPreviousEpoch, - ) + if headerDiff.Cmp(currentEpochDifficulty) == 0 { + requestedDiff = currentEpochDifficulty + } else if headerDiff.Cmp(previousEpochDifficulty) == 0 { + requestedDiff = previousEpochDifficulty + } else { + // The Bridge would revert with "Not at current or previous + // 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 + } + } - // Calculate how many blocks from the current epoch we need. - remainder := new(big.Int) - numberOfBlocksCurrentEpoch, remainder := new(big.Int).DivMod( - totalDifficultyCurrentEpoch, - currentEpochDifficulty, - remainder, + totalDifficultyRequired := new(big.Int).Mul( + requestedDiff, + txProofDifficultyFactor, ) - // If there is a remainder, it means there is still some amount of - // difficulty missing that is less than one block difficulty. We need to - // account for that by adding one additional block. - if remainder.Cmp(big.NewInt(0)) > 0 { - numberOfBlocksCurrentEpoch.Add( - numberOfBlocksCurrentEpoch, - big.NewInt(1), - ) + if observedDiff.Cmp(totalDifficultyRequired) >= 0 { + return true, accumulatedConfirmations, headerCount, nil } - - // The total required number of confirmations is the sum of blocks from - // the previous and current epochs. - requiredConfirmations := numberOfBlocksPreviousEpoch + - numberOfBlocksCurrentEpoch.Uint64() - - return true, accumulatedConfirmations, uint(requiredConfirmations), nil } - - // If we entered here, it means that the proof's block headers range goes - // outside the previous or current difficulty epochs as seen by the relay. - // The reason for this is most likely that transaction entered the Bitcoin - // blockchain within the very new difficulty epoch that is not yet proven in - // the relay. In that case the transaction will be proven in the future. - // The other case could be that the transaction is older than the last two - // Bitcoin difficulty epochs. In that case the transaction will soon leave - // the sliding window of recent transactions. - return false, 0, 0, nil } // walletEvent is a type constraint representing wallet-related chain events. diff --git a/pkg/maintainer/spv/spv_test.go b/pkg/maintainer/spv/spv_test.go index 6f11fd6e2b..088c619883 100644 --- a/pkg/maintainer/spv/spv_test.go +++ b/pkg/maintainer/spv/spv_test.go @@ -13,76 +13,169 @@ import ( ) func TestGetProofInfo(t *testing.T) { + // The proof start block in all test cases. Derived from the latest block + // height and the number of transaction confirmations: + // proofStartBlock = latestBlockHeight - transactionConfirmations + 1. + const proofStart = 790270 + + // Difficulties are powers of two so they round-trip exactly through the + // Bitcoin compact (Bits) encoding used by blockHeaderWithDifficulty. + diff := func(d int64) *big.Int { return big.NewInt(d) } + tests := map[string]struct { - latestBlockHeight uint transactionConfirmations uint - currentEpoch uint64 currentEpochDifficulty *big.Int previousEpochDifficulty *big.Int + headerDifficultyAt func(uint) *big.Int + headersFrom, headersTo uint expectedIsProofWithinRelayRange bool expectedAccumulatedConfirmations uint expectedRequiredConfirmations uint }{ + // All proof headers carry the current epoch difficulty. With factor 6, + // six headers of difficulty 32 reach 6*32. "proof entirely within current epoch": { - latestBlockHeight: 790277, - transactionConfirmations: 3, - currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + transactionConfirmations: 20, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(32) }, + headersFrom: proofStart, + headersTo: proofStart + 19, + expectedIsProofWithinRelayRange: true, - expectedAccumulatedConfirmations: 3, + expectedAccumulatedConfirmations: 20, expectedRequiredConfirmations: 6, }, + // All proof headers carry the previous epoch difficulty. "proof entirely within previous epoch": { - latestBlockHeight: 790300, - transactionConfirmations: 2041, - currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed - expectedAccumulatedConfirmations: 2041, + transactionConfirmations: 20, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(16) }, + headersFrom: proofStart, + headersTo: proofStart + 19, + expectedIsProofWithinRelayRange: true, + expectedAccumulatedConfirmations: 20, expectedRequiredConfirmations: 6, }, + // Proof starts in the previous epoch (difficulty 32) two blocks before + // the epoch boundary and continues in the current epoch (difficulty + // 16). Required total is 6*32=192; 2*32 + 8*16 = 192 -> 10 headers. "proof spans previous and current epochs and difficulty drops": { - latestBlockHeight: 790300, - transactionConfirmations: 31, - currentEpoch: 392, - currentEpochDifficulty: big.NewInt(50000000000000), - previousEpochDifficulty: big.NewInt(30000000000000), + transactionConfirmations: 31, + currentEpochDifficulty: diff(16), + previousEpochDifficulty: diff(32), + headerDifficultyAt: func(h uint) *big.Int { + if h < 790272 { + return diff(32) + } + return diff(16) + }, + headersFrom: proofStart, + headersTo: proofStart + 30, + expectedIsProofWithinRelayRange: true, expectedAccumulatedConfirmations: 31, - expectedRequiredConfirmations: 9, + expectedRequiredConfirmations: 10, }, + // Required total is 6*16=96; 2*16 + 2*32 = 96 -> 4 headers. "proof spans previous and current epochs and difficulty raises": { - latestBlockHeight: 790300, - transactionConfirmations: 31, - currentEpoch: 392, - currentEpochDifficulty: big.NewInt(30000000000000), - previousEpochDifficulty: big.NewInt(60000000000000), + transactionConfirmations: 31, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(h uint) *big.Int { + if h < 790272 { + return diff(16) + } + return diff(32) + }, + headersFrom: proofStart, + headersTo: proofStart + 30, + expectedIsProofWithinRelayRange: true, expectedAccumulatedConfirmations: 31, expectedRequiredConfirmations: 4, }, - "proof begins outside previous epoch": { - latestBlockHeight: 790300, - transactionConfirmations: 2048, - currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + // Transaction mined in minimum-difficulty (DIFF1) blocks (testnet4 + // BIP94). Leading DIFF1 headers are skipped when binding to the relay + // difficulty but still contribute their work. Required total is + // 6*32=192; 1+1+6*32=194 >= 192 -> 8 headers. + "leading minimum difficulty headers are skipped": { + transactionConfirmations: 31, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(h uint) *big.Int { + if h < 790272 { + return diff(1) + } + return diff(32) + }, + headersFrom: proofStart, + headersTo: proofStart + 30, + + expectedIsProofWithinRelayRange: true, + expectedAccumulatedConfirmations: 31, + expectedRequiredConfirmations: 8, + }, + // When the relay epoch difficulty is minimum (test/dev setups), + // minimum-difficulty headers are not skipped and match directly. + "epoch difficulty is minimum": { + transactionConfirmations: 20, + currentEpochDifficulty: diff(1), + previousEpochDifficulty: diff(1), + headerDifficultyAt: func(uint) *big.Int { return diff(1) }, + headersFrom: proofStart, + headersTo: proofStart + 19, + + expectedIsProofWithinRelayRange: true, + expectedAccumulatedConfirmations: 20, + expectedRequiredConfirmations: 6, + }, + // The decisive header difficulty matches neither the current nor the + // previous relay epoch difficulty. The Bridge would revert, so the + // transaction is reported as outside the relay range. + "decisive header matches no epoch difficulty": { + transactionConfirmations: 20, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(8) }, + headersFrom: proofStart, + headersTo: proofStart + 19, + expectedIsProofWithinRelayRange: false, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, }, - "proof ends outside current epoch": { - latestBlockHeight: 792285, - transactionConfirmations: 3, - currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + // A run of minimum-difficulty headers longer than maxProofHeaders + // never reaches a decisive header. + "minimum difficulty run exceeds header bound": { + transactionConfirmations: 150, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(1) }, + headersFrom: proofStart, + headersTo: proofStart + 149, + expectedIsProofWithinRelayRange: false, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, }, + // The chain tip is reached before enough difficulty is accumulated. + // The reported requirement is one header more than currently exists, + // so the caller waits for more confirmations. + "not enough mined blocks yet": { + transactionConfirmations: 3, + currentEpochDifficulty: diff(32), + previousEpochDifficulty: diff(16), + headerDifficultyAt: func(uint) *big.Int { return diff(32) }, + headersFrom: proofStart, + headersTo: proofStart + 2, + + expectedIsProofWithinRelayRange: true, + expectedAccumulatedConfirmations: 3, + expectedRequiredConfirmations: 4, + }, } for testName, test := range tests { @@ -98,17 +191,21 @@ func TestGetProofInfo(t *testing.T) { localChain := newLocalChain() btcChain := newLocalBitcoinChain() - btcChain.addBlockHeader( - test.latestBlockHeight, - &bitcoin.BlockHeader{}, - ) + if err := populateBlockHeaders( + btcChain, + test.headersFrom, + test.headersTo, + test.headerDifficultyAt, + ); err != nil { + t.Fatal(err) + } btcChain.addTransactionConfirmations( transactionHash, test.transactionConfirmations, ) localChain.setTxProofDifficultyFactor(big.NewInt(6)) - localChain.setCurrentEpoch(test.currentEpoch) + localChain.setCurrentEpoch(392) localChain.setCurrentAndPrevEpochDifficulty( test.currentEpochDifficulty, test.previousEpochDifficulty,