Skip to content
29 changes: 29 additions & 0 deletions pkg/maintainer/spv/bitcoin_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
201 changes: 93 additions & 108 deletions pkg/maintainer/spv/spv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default branch here silently returns false, which makes the caller log "proof goes outside the previous and current difficulty epochs" — but the real issue is the tx block was mined at min-difficulty.

On testnet4 this will cause silent retries with a misleading log. Could you add a logger.Warnf distinguishing this case? Something like "transaction block difficulty %s matches neither current (%s) nor previous (%s) epoch — may be unprovable if mined in a min-difficulty block."

// 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.
Expand Down
Loading
Loading