diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 2f790bf11f..888ab7960a 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -3,11 +3,26 @@ package spv import ( "bytes" "fmt" + "math/big" "sync" + "github.com/btcsuite/btcd/blockchain" "github.com/keep-network/keep-core/pkg/bitcoin" ) +// blockHeaderWithDifficulty returns a header whose Difficulty() matches the +// given value (within Bitcoin compact encoding precision). +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 @@ -203,6 +218,20 @@ func (lbc *localBitcoinChain) addBlockHeader( return nil } +// populateBlockHeaders adds headers for [fromHeight, toHeight] inclusive using +// difficultyAt(height) for each block's Bits-derived difficulty. +func (lbc *localBitcoinChain) populateBlockHeaders( + fromHeight, toHeight uint, + difficultyAt func(uint) *big.Int, +) error { + for h := fromHeight; h <= toHeight; h++ { + if err := lbc.addBlockHeader(h, blockHeaderWithDifficulty(difficultyAt(h))); err != nil { + return err + } + } + return nil +} + func (lbc *localBitcoinChain) addTransactionConfirmations( transactionHash bitcoin.Hash, transactionConfirmations uint, diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 990d8b0ec0..21eb54cd97 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -292,10 +292,45 @@ func isInputCurrentWalletsMainUTXO( return bytes.Equal(mainUtxoHash[:], wallet.MainUtxoHash[:]), nil } +// proofRangeWithinRelayWindow returns true iff [proofStartBlock, proofEndBlock] +// is one of: entirely in previous epoch, entirely in current epoch, or spanning +// exactly previous→current (matches Bridge SPV assumptions). +func proofRangeWithinRelayWindow( + proofStartBlock, proofEndBlock uint64, + previousEpoch, currentEpoch uint64, +) bool { + if proofEndBlock < proofStartBlock { + return false + } + ps := proofStartBlock / difficultyEpochLength + pe := proofEndBlock / difficultyEpochLength + if ps < previousEpoch || pe > currentEpoch { + return false + } + if ps == currentEpoch && pe == currentEpoch { + return true + } + if ps == previousEpoch && pe == previousEpoch { + return true + } + if ps == previousEpoch && pe == currentEpoch { + return true + } + return false +} + // 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. +// +// Required confirmations are computed to match Bridge.evaluateProofDifficulty: +// the concatenated block headers must sum to at least +// requestedDifficulty * txProofDifficultyFactor, where requestedDifficulty is the +// relay epoch difficulty that matches the first header (same as on-chain). +// Per-block difficulties can vary (e.g. testnet4 min-difficulty blocks), so we +// walk forward summing actual header difficulties instead of assuming a fixed +// block count × epoch-average difficulty. func getProofInfo( transactionHash bitcoin.Hash, btcChain bitcoin.Chain, @@ -330,135 +365,85 @@ func getProofInfo( ) } - // Calculate the starting block of the proof and the difficulty epoch number - // it belongs to. - 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 + currentEpochDifficulty, previousEpochDifficulty, err := + btcDiffChain.GetCurrentAndPrevEpochDifficulty() + if err != nil { + return false, 0, 0, fmt.Errorf( + "failed to get Bitcoin epoch difficulties: [%v]", + err, + ) + } - // 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. + proofStartBlock := uint64(latestBlockHeight) - uint64(accumulatedConfirmations) + 1 - // 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 + firstHeader, err := btcChain.GetBlockHeader(uint(proofStartBlock)) + if err != nil { + return false, 0, 0, fmt.Errorf( + "failed to get block header at proof start: [%v]", + err, + ) } - - // 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 + firstHeaderDiff := firstHeader.Difficulty() + + var requestedDiff *big.Int + switch { + case firstHeaderDiff.Cmp(currentEpochDifficulty) == 0: + requestedDiff = currentEpochDifficulty + case firstHeaderDiff.Cmp(previousEpochDifficulty) == 0: + requestedDiff = previousEpochDifficulty + default: + // Bridge would revert "Not at current or previous difficulty". + return false, 0, 0, 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() + totalDifficultyRequired := new(big.Int).Mul( + requestedDiff, + txProofDifficultyFactor, + ) + + sumDifficulty := new(big.Int) + var requiredBlockCount uint64 + var reached bool + for height := proofStartBlock; height <= uint64(latestBlockHeight); height++ { + hdr, err := btcChain.GetBlockHeader(uint(height)) if err != nil { return false, 0, 0, fmt.Errorf( - "failed to get Bitcoin epoch difficulties: [%v]", + "failed to get block header at height %d: [%v]", + height, 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, - ) - - // 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, - ) - - // Calculate how much difficulty must come from the current epoch. - totalDifficultyCurrentEpoch := new(big.Int).Sub( - totalDifficultyRequired, - totalDifficultyPreviousEpoch, - ) - - // Calculate how many blocks from the current epoch we need. - remainder := new(big.Int) - numberOfBlocksCurrentEpoch, remainder := new(big.Int).DivMod( - totalDifficultyCurrentEpoch, - currentEpochDifficulty, - remainder, - ) - // 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), - ) + sumDifficulty.Add(sumDifficulty, hdr.Difficulty()) + requiredBlockCount++ + if sumDifficulty.Cmp(totalDifficultyRequired) >= 0 { + reached = true + break } + } - // The total required number of confirmations is the sum of blocks from - // the previous and current epochs. - requiredConfirmations := numberOfBlocksPreviousEpoch + - numberOfBlocksCurrentEpoch.Uint64() + if !reached { + // Not enough accumulated work in the chain yet; wait for more blocks. + available := uint64(latestBlockHeight) - proofStartBlock + 1 + return true, accumulatedConfirmations, uint(available + 1), nil + } - return true, accumulatedConfirmations, uint(requiredConfirmations), nil + proofEndBlock := proofStartBlock + requiredBlockCount - 1 + if !proofRangeWithinRelayWindow( + proofStartBlock, + proofEndBlock, + previousEpoch, + currentEpoch, + ) { + return false, 0, 0, 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 + return true, accumulatedConfirmations, uint(requiredBlockCount), 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 94c2084d11..532d56a4d5 100644 --- a/pkg/maintainer/spv/spv_test.go +++ b/pkg/maintainer/spv/spv_test.go @@ -12,12 +12,16 @@ import ( ) func TestGetProofInfo(t *testing.T) { + // First block height of Bitcoin difficulty epoch 392 (392 * 2016). + const epoch392Start = 392 * 2016 + tests := map[string]struct { latestBlockHeight uint transactionConfirmations uint currentEpoch uint64 currentEpochDifficulty *big.Int previousEpochDifficulty *big.Int + difficultyAtBlock func(uint) *big.Int expectedIsProofWithinRelayRange bool expectedAccumulatedConfirmations uint expectedRequiredConfirmations uint @@ -26,58 +30,79 @@ func TestGetProofInfo(t *testing.T) { latestBlockHeight: 790277, transactionConfirmations: 3, currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + currentEpochDifficulty: big.NewInt(1), + previousEpochDifficulty: big.NewInt(1), + difficultyAtBlock: func(uint) *big.Int { return big.NewInt(1) }, expectedIsProofWithinRelayRange: true, expectedAccumulatedConfirmations: 3, - expectedRequiredConfirmations: 6, + // Only 3 blocks of work available (sum 3 < 6); need one more block. + expectedRequiredConfirmations: 4, }, "proof entirely within previous epoch": { latestBlockHeight: 790300, transactionConfirmations: 2041, currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + currentEpochDifficulty: big.NewInt(1), + previousEpochDifficulty: big.NewInt(1), + difficultyAtBlock: func(uint) *big.Int { return big.NewInt(1) }, expectedAccumulatedConfirmations: 2041, expectedIsProofWithinRelayRange: true, expectedRequiredConfirmations: 6, }, "proof spans previous and current epochs and difficulty drops": { - latestBlockHeight: 790300, - transactionConfirmations: 31, - currentEpoch: 392, - currentEpochDifficulty: big.NewInt(50000000000000), - previousEpochDifficulty: big.NewInt(30000000000000), + latestBlockHeight: 790300, + transactionConfirmations: 31, + currentEpoch: 392, + currentEpochDifficulty: big.NewInt(50000), + previousEpochDifficulty: big.NewInt(30000), + difficultyAtBlock: func(h uint) *big.Int { + if h < epoch392Start { + return big.NewInt(30000) + } + return big.NewInt(50000) + }, expectedIsProofWithinRelayRange: true, expectedAccumulatedConfirmations: 31, - expectedRequiredConfirmations: 9, + // requestedDiff 30000 * factor 6 = 180000; first 5 headers suffice. + expectedRequiredConfirmations: 5, }, "proof spans previous and current epochs and difficulty raises": { - latestBlockHeight: 790300, - transactionConfirmations: 31, - currentEpoch: 392, - currentEpochDifficulty: big.NewInt(30000000000000), - previousEpochDifficulty: big.NewInt(60000000000000), + latestBlockHeight: 790300, + transactionConfirmations: 31, + currentEpoch: 392, + currentEpochDifficulty: big.NewInt(30000), + previousEpochDifficulty: big.NewInt(60000), + difficultyAtBlock: func(h uint) *big.Int { + if h < epoch392Start { + return big.NewInt(60000) + } + return big.NewInt(30000) + }, expectedIsProofWithinRelayRange: true, expectedAccumulatedConfirmations: 31, - expectedRequiredConfirmations: 4, + // requestedDiff 60000 * 6 = 360000; needs 10 headers from proof start. + expectedRequiredConfirmations: 10, }, "proof begins outside previous epoch": { latestBlockHeight: 790300, transactionConfirmations: 2048, currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + currentEpochDifficulty: big.NewInt(1), + previousEpochDifficulty: big.NewInt(1), + difficultyAtBlock: func(uint) *big.Int { return big.NewInt(1) }, expectedIsProofWithinRelayRange: false, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, }, "proof ends outside current epoch": { - latestBlockHeight: 792285, - transactionConfirmations: 3, + // Tx in 792283; six difficulty-1 blocks reach 792288 (next epoch), which + // is past relay currentEpoch 392. + latestBlockHeight: 792288, + transactionConfirmations: 6, currentEpoch: 392, - currentEpochDifficulty: nil, // not needed - previousEpochDifficulty: nil, // not needed + currentEpochDifficulty: big.NewInt(1), + previousEpochDifficulty: big.NewInt(1), + difficultyAtBlock: func(uint) *big.Int { return big.NewInt(1) }, expectedIsProofWithinRelayRange: false, expectedAccumulatedConfirmations: 0, expectedRequiredConfirmations: 0, @@ -97,10 +122,15 @@ func TestGetProofInfo(t *testing.T) { localChain := newLocalChain() btcChain := newLocalBitcoinChain() - btcChain.addBlockHeader( + proofStart := test.latestBlockHeight - test.transactionConfirmations + 1 + err = btcChain.populateBlockHeaders( + proofStart, test.latestBlockHeight, - &bitcoin.BlockHeader{}, + test.difficultyAtBlock, ) + if err != nil { + t.Fatal(err) + } btcChain.addTransactionConfirmations( transactionHash, test.transactionConfirmations, @@ -109,8 +139,8 @@ func TestGetProofInfo(t *testing.T) { localChain.setTxProofDifficultyFactor(big.NewInt(6)) localChain.setCurrentEpoch(test.currentEpoch) localChain.setCurrentAndPrevEpochDifficulty( - test.currentEpochDifficulty, test.previousEpochDifficulty, + test.currentEpochDifficulty, ) isProofWithinRelayRange,