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,