From 9212f0752d5244a859289564ee56fa81bedfc50d Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Mon, 9 Oct 2023 13:50:02 +0100 Subject: [PATCH 1/3] forge install: bitcoin-spv --- .gitmodules | 3 +++ lib/bitcoin-spv | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/bitcoin-spv diff --git a/.gitmodules b/.gitmodules index 690924b6..9852da38 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/bitcoin-spv"] + path = lib/bitcoin-spv + url = https://github.com/bob-collective/bitcoin-spv diff --git a/lib/bitcoin-spv b/lib/bitcoin-spv new file mode 160000 index 00000000..721155b2 --- /dev/null +++ b/lib/bitcoin-spv @@ -0,0 +1 @@ +Subproject commit 721155b2b42aae7f11482f264f1b08a9d9aa5823 From 5d9ff5daf1d532b33ad6173f5623a6b3be30b0e3 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Mon, 9 Oct 2023 16:51:18 +0100 Subject: [PATCH 2/3] feat: add light relay Signed-off-by: Gregory Hill --- foundry.toml | 1 + lib/bitcoin-spv | 2 +- src/bridge/BitcoinTx.sol | 350 ++++++++++++++++++++++ src/bridge/BridgeState.sol | 17 ++ src/bridge/IRelay.sol | 17 ++ src/relay/LightRelay.sol | 600 +++++++++++++++++++++++++++++++++++++ test/LightRelay.t.sol | 64 ++++ 7 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 src/bridge/BitcoinTx.sol create mode 100644 src/bridge/BridgeState.sol create mode 100644 src/bridge/IRelay.sol create mode 100644 src/relay/LightRelay.sol create mode 100644 test/LightRelay.t.sol diff --git a/foundry.toml b/foundry.toml index f69e537e..575f1994 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,7 @@ libs = ["lib"] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options remappings = [ + "@bob-collective/bitcoin-spv/=lib/bitcoin-spv/src/", "ds-test/=lib/forge-std/lib/ds-test/src/", "forge-std/=lib/forge-std/src/", "@openzeppelin/=lib/openzeppelin-contracts/", diff --git a/lib/bitcoin-spv b/lib/bitcoin-spv index 721155b2..507d01ba 160000 --- a/lib/bitcoin-spv +++ b/lib/bitcoin-spv @@ -1 +1 @@ -Subproject commit 721155b2b42aae7f11482f264f1b08a9d9aa5823 +Subproject commit 507d01ba7aea4599121b83abf1925c663932170c diff --git a/src/bridge/BitcoinTx.sol b/src/bridge/BitcoinTx.sol new file mode 100644 index 00000000..e31bca96 --- /dev/null +++ b/src/bridge/BitcoinTx.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// Forked from https://github.com/keep-network/tbtc-v2 + +pragma solidity 0.8.17; + +import {BTCUtils} from "@bob-collective/bitcoin-spv/BTCUtils.sol"; +import {BytesLib} from "@bob-collective/bitcoin-spv/BytesLib.sol"; +import {ValidateSPV} from "@bob-collective/bitcoin-spv/ValidateSPV.sol"; + +import "./BridgeState.sol"; + +/// @title Bitcoin transaction +/// @notice Allows to reference Bitcoin raw transaction in Solidity. +/// @dev See https://developer.bitcoin.org/reference/transactions.html#raw-transaction-format +/// +/// Raw Bitcoin transaction data: +/// +/// | Bytes | Name | BTC type | Description | +/// |--------|--------------|------------------------|---------------------------| +/// | 4 | version | int32_t (LE) | TX version number | +/// | varies | tx_in_count | compactSize uint (LE) | Number of TX inputs | +/// | varies | tx_in | txIn[] | TX inputs | +/// | varies | tx_out_count | compactSize uint (LE) | Number of TX outputs | +/// | varies | tx_out | txOut[] | TX outputs | +/// | 4 | lock_time | uint32_t (LE) | Unix time or block number | +/// +// +/// Non-coinbase transaction input (txIn): +/// +/// | Bytes | Name | BTC type | Description | +/// |--------|------------------|------------------------|---------------------------------------------| +/// | 36 | previous_output | outpoint | The previous outpoint being spent | +/// | varies | script_bytes | compactSize uint (LE) | The number of bytes in the signature script | +/// | varies | signature_script | char[] | The signature script, empty for P2WSH | +/// | 4 | sequence | uint32_t (LE) | Sequence number | +/// +/// +/// The reference to transaction being spent (outpoint): +/// +/// | Bytes | Name | BTC type | Description | +/// |-------|-------|---------------|------------------------------------------| +/// | 32 | hash | char[32] | Hash of the transaction to spend | +/// | 4 | index | uint32_t (LE) | Index of the specific output from the TX | +/// +/// +/// Transaction output (txOut): +/// +/// | Bytes | Name | BTC type | Description | +/// |--------|-----------------|-----------------------|--------------------------------------| +/// | 8 | value | int64_t (LE) | Number of satoshis to spend | +/// | 1+ | pk_script_bytes | compactSize uint (LE) | Number of bytes in the pubkey script | +/// | varies | pk_script | char[] | Pubkey script | +/// +/// compactSize uint format: +/// +/// | Value | Bytes | Format | +/// |-----------------------------------------|-------|----------------------------------------------| +/// | >= 0 && <= 252 | 1 | uint8_t | +/// | >= 253 && <= 0xffff | 3 | 0xfd followed by the number as uint16_t (LE) | +/// | >= 0x10000 && <= 0xffffffff | 5 | 0xfe followed by the number as uint32_t (LE) | +/// | >= 0x100000000 && <= 0xffffffffffffffff | 9 | 0xff followed by the number as uint64_t (LE) | +/// +/// (*) compactSize uint is often references as VarInt) +/// +/// Coinbase transaction input (txIn): +/// +/// | Bytes | Name | BTC type | Description | +/// |--------|------------------|------------------------|---------------------------------------------| +/// | 32 | hash | char[32] | A 32-byte 0x0 null (no previous_outpoint) | +/// | 4 | index | uint32_t (LE) | 0xffffffff (no previous_outpoint) | +/// | varies | script_bytes | compactSize uint (LE) | The number of bytes in the coinbase script | +/// | varies | height | char[] | The block height of this block (BIP34) (*) | +/// | varies | coinbase_script | none | Arbitrary data, max 100 bytes | +/// | 4 | sequence | uint32_t (LE) | Sequence number +/// +/// (*) Uses script language: starts with a data-pushing opcode that indicates how many bytes to push to +/// the stack followed by the block height as a little-endian unsigned integer. This script must be as +/// short as possible, otherwise it may be rejected. The data-pushing opcode will be 0x03 and the total +/// size four bytes until block 16,777,216 about 300 years from now. +library BitcoinTx { + using BTCUtils for bytes; + using BTCUtils for uint256; + using BytesLib for bytes; + using ValidateSPV for bytes; + using ValidateSPV for bytes32; + + /// @notice Represents Bitcoin transaction data. + struct Info { + /// @notice Bitcoin transaction version. + /// @dev `version` from raw Bitcoin transaction data. + /// Encoded as 4-bytes signed integer, little endian. + bytes4 version; + /// @notice All Bitcoin transaction inputs, prepended by the number of + /// transaction inputs. + /// @dev `tx_in_count | tx_in` from raw Bitcoin transaction data. + /// + /// The number of transaction inputs encoded as compactSize + /// unsigned integer, little-endian. + /// + /// Note that some popular block explorers reverse the order of + /// bytes from `outpoint`'s `hash` and display it as big-endian. + /// Solidity code of Bridge expects hashes in little-endian, just + /// like they are represented in a raw Bitcoin transaction. + bytes inputVector; + /// @notice All Bitcoin transaction outputs prepended by the number of + /// transaction outputs. + /// @dev `tx_out_count | tx_out` from raw Bitcoin transaction data. + /// + /// The number of transaction outputs encoded as a compactSize + /// unsigned integer, little-endian. + bytes outputVector; + /// @notice Bitcoin transaction locktime. + /// + /// @dev `lock_time` from raw Bitcoin transaction data. + /// Encoded as 4-bytes unsigned integer, little endian. + bytes4 locktime; + // This struct doesn't contain `__gap` property as the structure is not + // stored, it is used as a function's calldata argument. + } + + /// @notice Represents data needed to perform a Bitcoin SPV proof. + struct Proof { + /// @notice The merkle proof of transaction inclusion in a block. + bytes merkleProof; + /// @notice Transaction index in the block (0-indexed). + uint256 txIndexInBlock; + /// @notice Single byte-string of 80-byte bitcoin headers, + /// lowest height first. + bytes bitcoinHeaders; + // This struct doesn't contain `__gap` property as the structure is not + // stored, it is used as a function's calldata argument. + } + + /// @notice Represents info about an unspent transaction output. + struct UTXO { + /// @notice Hash of the transaction the output belongs to. + /// @dev Byte order corresponds to the Bitcoin internal byte order. + bytes32 txHash; + /// @notice Index of the transaction output (0-indexed). + uint32 txOutputIndex; + /// @notice Value of the transaction output. + uint64 txOutputValue; + // This struct doesn't contain `__gap` property as the structure is not + // stored, it is used as a function's calldata argument. + } + + /// @notice Represents Bitcoin signature in the R/S/V format. + struct RSVSignature { + /// @notice Signature r value. + bytes32 r; + /// @notice Signature s value. + bytes32 s; + /// @notice Signature recovery value. + uint8 v; + // This struct doesn't contain `__gap` property as the structure is not + // stored, it is used as a function's calldata argument. + } + + /// @notice Validates the SPV proof of the Bitcoin transaction. + /// Reverts in case the validation or proof verification fail. + /// @param txInfo Bitcoin transaction data. + /// @param proof Bitcoin proof data. + /// @return txHash Proven 32-byte transaction hash. + function validateProof( + BridgeState.Storage storage self, + Info calldata txInfo, + Proof calldata proof + ) internal view returns (bytes32 txHash) { + require( + txInfo.inputVector.validateVin(), + "Invalid input vector provided" + ); + require( + txInfo.outputVector.validateVout(), + "Invalid output vector provided" + ); + + txHash = abi + .encodePacked( + txInfo.version, + txInfo.inputVector, + txInfo.outputVector, + txInfo.locktime + ) + .hash256View(); + + require( + txHash.prove( + proof.bitcoinHeaders.extractMerkleRootLE(), + proof.merkleProof, + proof.txIndexInBlock + ), + "Tx merkle proof is not valid for provided header and tx hash" + ); + + evaluateProofDifficulty(self, proof.bitcoinHeaders); + + return txHash; + } + + /// @notice Evaluates the given Bitcoin proof difficulty against the actual + /// Bitcoin chain difficulty provided by the relay oracle. + /// Reverts in case the evaluation fails. + /// @param bitcoinHeaders Bitcoin headers chain being part of the SPV + /// proof. Used to extract the observed proof difficulty. + function evaluateProofDifficulty( + BridgeState.Storage storage self, + bytes memory bitcoinHeaders + ) internal view { + IRelay relay = self.relay; + uint256 currentEpochDifficulty = relay.getCurrentEpochDifficulty(); + uint256 previousEpochDifficulty = relay.getPrevEpochDifficulty(); + + uint256 requestedDiff = 0; + uint256 firstHeaderDiff = bitcoinHeaders + .extractTarget() + .calculateDifficulty(); + + if (firstHeaderDiff == currentEpochDifficulty) { + requestedDiff = currentEpochDifficulty; + } else if (firstHeaderDiff == previousEpochDifficulty) { + requestedDiff = previousEpochDifficulty; + } else { + revert("Not at current or previous difficulty"); + } + + uint256 observedDiff = bitcoinHeaders.validateHeaderChain(); + + require( + observedDiff != ValidateSPV.getErrBadLength(), + "Invalid length of the headers chain" + ); + require( + observedDiff != ValidateSPV.getErrInvalidChain(), + "Invalid headers chain" + ); + require( + observedDiff != ValidateSPV.getErrLowWork(), + "Insufficient work in a header" + ); + + require( + observedDiff >= requestedDiff * self.txProofDifficultyFactor, + "Insufficient accumulated difficulty in header chain" + ); + } + + /// @notice Extracts public key hash from the provided P2PKH or P2WPKH output. + /// Reverts if the validation fails. + /// @param output The transaction output. + /// @return pubKeyHash 20-byte public key hash the output locks funds on. + /// @dev Requirements: + /// - The output must be of P2PKH or P2WPKH type and lock the funds + /// on a 20-byte public key hash. + function extractPubKeyHash(BridgeState.Storage storage, bytes memory output) + internal + pure + returns (bytes20 pubKeyHash) + { + bytes memory pubKeyHashBytes = output.extractHash(); + + require( + pubKeyHashBytes.length == 20, + "Output's public key hash must have 20 bytes" + ); + + pubKeyHash = pubKeyHashBytes.slice20(0); + + // The output consists of an 8-byte value and a variable length script. + // To extract just the script, we ignore the first 8 bytes. + uint256 scriptLen = output.length - 8; + + // The P2PKH script is 26 bytes long. + // The P2WPKH script is 23 bytes long. + // A valid script must have one of these lengths, + // and we can identify the expected script type by the length. + require( + scriptLen == 26 || scriptLen == 23, + "Output must be P2PKH or P2WPKH" + ); + + if (scriptLen == 26) { + // Compare to the expected P2PKH script. + bytes26 script = bytes26(output.slice32(8)); + + require( + script == makeP2PKHScript(pubKeyHash), + "Invalid P2PKH script" + ); + } + + if (scriptLen == 23) { + // Compare to the expected P2WPKH script. + bytes23 script = bytes23(output.slice32(8)); + + require( + script == makeP2WPKHScript(pubKeyHash), + "Invalid P2WPKH script" + ); + } + + return pubKeyHash; + } + + /// @notice Build the P2PKH script from the given public key hash. + /// @param pubKeyHash The 20-byte public key hash. + /// @return The P2PKH script. + /// @dev The P2PKH script has the following byte format: + /// <0x1976a914> <20-byte PKH> <0x88ac>. According to + /// https://en.bitcoin.it/wiki/Script#Opcodes this translates to: + /// - 0x19: Byte length of the entire script + /// - 0x76: OP_DUP + /// - 0xa9: OP_HASH160 + /// - 0x14: Byte length of the public key hash + /// - 0x88: OP_EQUALVERIFY + /// - 0xac: OP_CHECKSIG + /// which matches the P2PKH structure as per: + /// https://en.bitcoin.it/wiki/Transaction#Pay-to-PubkeyHash + function makeP2PKHScript(bytes20 pubKeyHash) + internal + pure + returns (bytes26) + { + bytes26 P2PKHScriptMask = hex"1976a914000000000000000000000000000000000000000088ac"; + + return ((bytes26(pubKeyHash) >> 32) | P2PKHScriptMask); + } + + /// @notice Build the P2WPKH script from the given public key hash. + /// @param pubKeyHash The 20-byte public key hash. + /// @return The P2WPKH script. + /// @dev The P2WPKH script has the following format: + /// <0x160014> <20-byte PKH>. According to + /// https://en.bitcoin.it/wiki/Script#Opcodes this translates to: + /// - 0x16: Byte length of the entire script + /// - 0x00: OP_0 + /// - 0x14: Byte length of the public key hash + /// which matches the P2WPKH structure as per: + /// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#P2WPKH + function makeP2WPKHScript(bytes20 pubKeyHash) + internal + pure + returns (bytes23) + { + bytes23 P2WPKHScriptMask = hex"1600140000000000000000000000000000000000000000"; + + return ((bytes23(pubKeyHash) >> 24) | P2WPKHScriptMask); + } +} diff --git a/src/bridge/BridgeState.sol b/src/bridge/BridgeState.sol new file mode 100644 index 00000000..342bb0e3 --- /dev/null +++ b/src/bridge/BridgeState.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// Forked from https://github.com/keep-network/tbtc-v2 + +pragma solidity 0.8.17; + +import "./IRelay.sol"; + +library BridgeState { + struct Storage { + // Bitcoin relay providing the current Bitcoin network difficulty. + IRelay relay; + // The number of confirmations on the Bitcoin chain required to + // successfully evaluate an SPV proof. + uint96 txProofDifficultyFactor; + } +} diff --git a/src/bridge/IRelay.sol b/src/bridge/IRelay.sol new file mode 100644 index 00000000..6d18d24f --- /dev/null +++ b/src/bridge/IRelay.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// Forked from https://github.com/keep-network/tbtc-v2s + +pragma solidity 0.8.17; + +/// @title Interface for the Bitcoin relay +/// @notice Contains only the methods needed by tBTC v2. The Bitcoin relay +/// provides the difficulty of the previous and current epoch. One +/// difficulty epoch spans 2016 blocks. +interface IRelay { + /// @notice Returns the difficulty of the current epoch. + function getCurrentEpochDifficulty() external view returns (uint256); + + /// @notice Returns the difficulty of the previous epoch. + function getPrevEpochDifficulty() external view returns (uint256); +} diff --git a/src/relay/LightRelay.sol b/src/relay/LightRelay.sol new file mode 100644 index 00000000..4c0bf27a --- /dev/null +++ b/src/relay/LightRelay.sol @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// Forked from https://github.com/keep-network/tbtc-v2 + +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +import {BytesLib} from "@bob-collective/bitcoin-spv/BytesLib.sol"; +import {BTCUtils} from "@bob-collective/bitcoin-spv/BTCUtils.sol"; +import {ValidateSPV} from "@bob-collective/bitcoin-spv/ValidateSPV.sol"; + +import "../bridge/IRelay.sol"; + +struct Epoch { + uint32 timestamp; + // By definition, bitcoin targets have at least 32 leading zero bits. + // Thus we can only store the bits that aren't guaranteed to be 0. + uint224 target; +} + +interface ILightRelay is IRelay { + event Genesis(uint256 blockHeight); + event Retarget(uint256 oldDifficulty, uint256 newDifficulty); + event ProofLengthChanged(uint256 newLength); + event AuthorizationRequirementChanged(bool newStatus); + event SubmitterAuthorized(address submitter); + event SubmitterDeauthorized(address submitter); + + function retarget(bytes memory headers) external; + + function validateChain(bytes memory headers) + external + view + returns (uint256 startingHeaderTimestamp, uint256 headerCount); + + function getBlockDifficulty(uint256 blockNumber) + external + view + returns (uint256); + + function getEpochDifficulty(uint256 epochNumber) + external + view + returns (uint256); + + function getRelayRange() + external + view + returns (uint256 relayGenesis, uint256 currentEpochEnd); +} + +library RelayUtils { + using BytesLib for bytes; + + /// @notice Extract the timestamp of the header at the given position. + /// @param headers Byte array containing the header of interest. + /// @param at The start of the header in the array. + /// @return The timestamp of the header. + /// @dev Assumes that the specified position contains a valid header. + /// Performs no validation whatsoever. + function extractTimestampAt(bytes memory headers, uint256 at) + internal + pure + returns (uint32) + { + return BTCUtils.reverseUint32(uint32(headers.slice4(68 + at))); + } +} + +/// @dev THE RELAY MUST NOT BE USED BEFORE GENESIS AND AT LEAST ONE RETARGET. +contract LightRelay is Ownable, ILightRelay { + using BytesLib for bytes; + using BTCUtils for bytes; + using ValidateSPV for bytes; + using RelayUtils for bytes; + + bool public ready; + // Whether the relay requires the address submitting a retarget to be + // authorised in advance by governance. + bool public authorizationRequired; + // Number of blocks required for each side of a retarget proof: + // a retarget must provide `proofLength` blocks before the retarget + // and `proofLength` blocks after it. + // Governable + // Should be set to a fairly high number (e.g. 20-50) in production. + uint64 public proofLength; + // The number of the first epoch recorded by the relay. + // This should equal the height of the block starting the genesis epoch, + // divided by 2016, but this is not enforced as the relay has no + // information about block numbers. + uint64 public genesisEpoch; + // The number of the latest epoch whose difficulty is proven to the relay. + // If the genesis epoch's number is set correctly, and retargets along the + // way have been legitimate, this equals the height of the block starting + // the most recent epoch, divided by 2016. + uint64 public currentEpoch; + + uint256 internal currentEpochDifficulty; + uint256 internal prevEpochDifficulty; + + // Each epoch from genesis to the current one, keyed by their numbers. + mapping(uint256 => Epoch) internal epochs; + + mapping(address => bool) public isAuthorized; + + modifier relayActive() { + require(ready, "Relay is not ready for use"); + _; + } + + /// @notice Establish a starting point for the relay by providing the + /// target, timestamp and blockheight of the first block of the relay + /// genesis epoch. + /// @param genesisHeader The first block header of the genesis epoch. + /// @param genesisHeight The block number of the first block of the epoch. + /// @param genesisProofLength The number of blocks required to accept a + /// proof. + /// @dev If the relay is used by querying the current and previous epoch + /// difficulty, at least one retarget needs to be provided after genesis; + /// otherwise the prevEpochDifficulty will be uninitialised and zero. + function genesis( + bytes calldata genesisHeader, + uint256 genesisHeight, + uint64 genesisProofLength + ) external onlyOwner { + require(!ready, "Genesis already performed"); + + require(genesisHeader.length == 80, "Invalid genesis header length"); + + require( + genesisHeight % 2016 == 0, + "Invalid height of relay genesis block" + ); + + require(genesisProofLength < 2016, "Proof length excessive"); + require(genesisProofLength > 0, "Proof length may not be zero"); + + genesisEpoch = uint64(genesisHeight / 2016); + currentEpoch = genesisEpoch; + uint256 genesisTarget = genesisHeader.extractTarget(); + uint256 genesisTimestamp = genesisHeader.extractTimestamp(); + epochs[genesisEpoch] = Epoch( + uint32(genesisTimestamp), + uint224(genesisTarget) + ); + proofLength = genesisProofLength; + currentEpochDifficulty = BTCUtils.calculateDifficulty(genesisTarget); + ready = true; + + emit Genesis(genesisHeight); + } + + /// @notice Set the number of blocks required to accept a header chain. + /// @param newLength The required number of blocks. Must be less than 2016. + /// @dev For production, a high number (e.g. 20-50) is recommended. + /// Small numbers are accepted but should only be used for testing. + function setProofLength(uint64 newLength) external relayActive onlyOwner { + require(newLength < 2016, "Proof length excessive"); + require(newLength > 0, "Proof length may not be zero"); + require(newLength != proofLength, "Proof length unchanged"); + proofLength = newLength; + emit ProofLengthChanged(newLength); + } + + /// @notice Set whether the relay requires retarget submitters to be + /// pre-authorised by governance. + /// @param status True if authorisation is to be required, false if not. + function setAuthorizationStatus(bool status) external onlyOwner { + authorizationRequired = status; + emit AuthorizationRequirementChanged(status); + } + + /// @notice Authorise the given address to submit retarget proofs. + /// @param submitter The address to be authorised. + function authorize(address submitter) external onlyOwner { + isAuthorized[submitter] = true; + emit SubmitterAuthorized(submitter); + } + + /// @notice Rescind the authorisation of the submitter to retarget. + /// @param submitter The address to be deauthorised. + function deauthorize(address submitter) external onlyOwner { + isAuthorized[submitter] = false; + emit SubmitterDeauthorized(submitter); + } + + /// @notice Add a new epoch to the relay by providing a proof + /// of the difficulty before and after the retarget. + /// @param headers A chain of headers including the last X blocks before + /// the retarget, followed by the first X blocks after the retarget, + /// where X equals the current proof length. + /// @dev Checks that the first X blocks are valid in the most recent epoch, + /// that the difficulty of the new epoch is calculated correctly according + /// to the block timestamps, and that the next X blocks would be valid in + /// the new epoch. + /// We have no information of block heights, so we cannot enforce that + /// retargets only happen every 2016 blocks; instead, we assume that this + /// is the case if a valid proof of work is provided. + /// It is possible to cheat the relay by providing X blocks from earlier in + /// the most recent epoch, and then mining X new blocks after them. + /// However, each of these malicious blocks would have to be mined to a + /// higher difficulty than the legitimate ones. + /// Alternatively, if the retarget has not been performed yet, one could + /// first mine X blocks in the old difficulty with timestamps set far in + /// the future, and then another X blocks at a greatly reduced difficulty. + /// In either case, cheating the relay requires more work than mining X + /// legitimate blocks. + /// Only the most recent epoch is vulnerable to these attacks; once a + /// retarget has been proven to the relay, the epoch is immutable even if a + /// contradictory proof were to be presented later. + function retarget(bytes memory headers) external relayActive { + if (authorizationRequired) { + require(isAuthorized[msg.sender], "Submitter unauthorized"); + } + + require( + // Require proofLength headers on both sides of the retarget + headers.length == (proofLength * 2 * 80), + "Invalid header length" + ); + + Epoch storage latest = epochs[currentEpoch]; + + uint256 oldTarget = latest.target; + + bytes32 previousHeaderDigest = bytes32(0); + + // Validate old chain + for (uint256 i = 0; i < proofLength; i++) { + ( + bytes32 currentDigest, + uint256 currentHeaderTarget + ) = validateHeader(headers, i * 80, previousHeaderDigest); + + require( + currentHeaderTarget == oldTarget, + "Invalid target in pre-retarget headers" + ); + + previousHeaderDigest = currentDigest; + } + + // get timestamp of retarget block + uint256 epochEndTimestamp = headers.extractTimestampAt( + (proofLength - 1) * 80 + ); + + // An attacker could produce blocks with timestamps in the future, + // in an attempt to reduce the difficulty after the retarget + // to make mining the second part of the retarget proof easier. + // In particular, the attacker could reuse all but one block + // from the legitimate chain, and only mine the last block. + // To hinder this, require that the epoch end timestamp does not + // exceed the ethereum timestamp. + // NOTE: both are unix seconds, so this comparison should be valid. + require( + /* solhint-disable-next-line not-rely-on-time */ + epochEndTimestamp < block.timestamp, + "Epoch cannot end in the future" + ); + + // Expected target is the full-length target + uint256 expectedTarget = BTCUtils.retargetAlgorithm( + oldTarget, + latest.timestamp, + epochEndTimestamp + ); + + // Mined target is the header-encoded target + uint256 minedTarget = 0; + + uint256 epochStartTimestamp = headers.extractTimestampAt( + proofLength * 80 + ); + + // validate new chain + for (uint256 j = proofLength; j < proofLength * 2; j++) { + ( + bytes32 _currentDigest, + uint256 _currentHeaderTarget + ) = validateHeader(headers, j * 80, previousHeaderDigest); + + if (minedTarget == 0) { + // The new target has not been set, so check its correctness + minedTarget = _currentHeaderTarget; + require( + // Although the target is a 256-bit number, there are only 32 bits of + // space in the Bitcoin header. Because of that, the version stored in + // the header is a less-precise representation of the actual target + // using base-256 version of scientific notation. + // + // The 256-bit unsigned integer returned from BTCUtils.retargetAlgorithm + // is the precise target value. + // The 256-bit unsigned integer returned from validateHeader is the less + // precise target value because it was read from 32 bits of space of + // Bitcoin block header. + // + // We can't compare the precise and less precise representations together + // so we first mask them to obtain the less precise version: + // (full & truncated) == truncated + _currentHeaderTarget == + (expectedTarget & _currentHeaderTarget), + "Invalid target in new epoch" + ); + } else { + // The new target has been set, so remaining targets should match. + require( + _currentHeaderTarget == minedTarget, + "Unexpected target change after retarget" + ); + } + + previousHeaderDigest = _currentDigest; + } + + currentEpoch = currentEpoch + 1; + + epochs[currentEpoch] = Epoch( + uint32(epochStartTimestamp), + uint224(minedTarget) + ); + + uint256 oldDifficulty = currentEpochDifficulty; + uint256 newDifficulty = BTCUtils.calculateDifficulty(minedTarget); + + prevEpochDifficulty = oldDifficulty; + currentEpochDifficulty = newDifficulty; + + emit Retarget(oldDifficulty, newDifficulty); + } + + /// @notice Check whether a given chain of headers should be accepted as + /// valid within the rules of the relay. + /// If the validation fails, this function throws an exception. + /// @param headers A chain of 2 to 2015 bitcoin headers. + /// @return startingHeaderTimestamp The timestamp of the first header. + /// @return headerCount The number of headers. + /// @dev A chain of headers is accepted as valid if: + /// - Its length is between 2 and 2015 headers. + /// - Headers in the chain are sequential and refer to previous digests. + /// - Each header is mined with the correct amount of work. + /// - The difficulty in each header matches an epoch of the relay, + /// as determined by the headers' timestamps. The headers must be between + /// the genesis epoch and the latest proven epoch (inclusive). + /// If the chain contains a retarget, it is accepted if the retarget has + /// already been proven to the relay. + /// If the chain contains blocks of an epoch that has not been proven to + /// the relay (after a retarget within the header chain, or when the entire + /// chain falls within an epoch that has not been proven yet), it will be + /// rejected. + /// One exception to this is when two subsequent epochs have exactly the + /// same difficulty; headers from the latter epoch will be accepted if the + /// previous epoch has been proven to the relay. + /// This is because it is not possible to distinguish such headers from + /// headers of the previous epoch. + /// + /// If the difficulty increases significantly between relay genesis and the + /// present, creating fraudulent proofs for earlier epochs becomes easier. + /// Users of the relay should check the timestamps of valid headers and + /// only accept appropriately recent ones. + function validateChain(bytes memory headers) + external + view + returns (uint256 startingHeaderTimestamp, uint256 headerCount) + { + require(headers.length % 80 == 0, "Invalid header length"); + + headerCount = headers.length / 80; + + require( + headerCount > 1 && headerCount < 2016, + "Invalid number of headers" + ); + + startingHeaderTimestamp = headers.extractTimestamp(); + + // Short-circuit the first header's validation. + // We validate the header here to get the target which is needed to + // precisely identify the epoch. + ( + bytes32 previousHeaderDigest, + uint256 currentHeaderTarget + ) = validateHeader(headers, 0, bytes32(0)); + + Epoch memory nullEpoch = Epoch(0, 0); + + uint256 startingEpochNumber = currentEpoch; + Epoch memory startingEpoch = epochs[startingEpochNumber]; + Epoch memory nextEpoch = nullEpoch; + + // Find the correct epoch for the given chain + // Fastest with recent epochs, but able to handle anything after genesis + // + // The rules for bitcoin timestamps are: + // - must be greater than the median of the last 11 blocks' timestamps + // - must be less than the network-adjusted time +2 hours + // + // Because of this, the timestamp of a header may be smaller than the + // starting time, or greater than the ending time of its epoch. + // However, a valid timestamp is guaranteed to fall within the window + // formed by the epochs immediately before and after its timestamp. + // We can identify cases like these by comparing the targets. + while (startingHeaderTimestamp < startingEpoch.timestamp) { + startingEpochNumber -= 1; + nextEpoch = startingEpoch; + startingEpoch = epochs[startingEpochNumber]; + } + + // We have identified the centre of the window, + // by reaching the most recent epoch whose starting timestamp + // or reached before the genesis where epoch slots are empty. + // Therefore check that the timestamp is nonzero. + require( + startingEpoch.timestamp > 0, + "Cannot validate chains before relay genesis" + ); + + // The targets don't match. This could be because the block is invalid, + // or it could be because of timestamp inaccuracy. + // To cover the latter case, check adjacent epochs. + if (currentHeaderTarget != startingEpoch.target) { + // The target matches the next epoch. + // This means we are right at the beginning of the next epoch, + // and retargets during the chain should not be possible. + if (currentHeaderTarget == nextEpoch.target) { + startingEpoch = nextEpoch; + nextEpoch = nullEpoch; + } + // The target doesn't match the next epoch. + // Therefore the only valid epoch is the previous one. + // Because the timestamp can't be more than 2 hours into the future + // we must be right near the end of the epoch, + // so a retarget is possible. + else { + startingEpochNumber -= 1; + nextEpoch = startingEpoch; + startingEpoch = epochs[startingEpochNumber]; + + // We have failed to find a match, + // therefore the target has to be invalid. + require( + currentHeaderTarget == startingEpoch.target, + "Invalid target in header chain" + ); + } + } + + // We've found the correct epoch for the first header. + // Validate the rest. + for (uint256 i = 1; i < headerCount; i++) { + bytes32 currentDigest; + (currentDigest, currentHeaderTarget) = validateHeader( + headers, + i * 80, + previousHeaderDigest + ); + + // If the header's target does not match the expected target, + // check if a retarget is possible. + // + // If next epoch timestamp exists, a valid retarget is possible + // (if next epoch timestamp doesn't exist, either a retarget has + // already happened in this chain, the relay needs a retarget + // before this chain can be validated, or a retarget is not allowed + // because we know the headers are within a timestamp irregularity + // of the previous retarget). + // + // In this case the target must match the next epoch's target, + // and the header's timestamp must match the epoch's start. + if (currentHeaderTarget != startingEpoch.target) { + uint256 currentHeaderTimestamp = headers.extractTimestampAt( + i * 80 + ); + + require( + nextEpoch.timestamp != 0 && + currentHeaderTarget == nextEpoch.target && + currentHeaderTimestamp == nextEpoch.timestamp, + "Invalid target in header chain" + ); + + startingEpoch = nextEpoch; + nextEpoch = nullEpoch; + } + + previousHeaderDigest = currentDigest; + } + + return (startingHeaderTimestamp, headerCount); + } + + /// @notice Get the difficulty of the specified block. + /// @param blockNumber The number of the block. Must fall within the relay + /// range (at or after the relay genesis, and at or before the end of the + /// most recent epoch proven to the relay). + /// @return The difficulty of the epoch. + function getBlockDifficulty(uint256 blockNumber) + external + view + returns (uint256) + { + return getEpochDifficulty(blockNumber / 2016); + } + + /// @notice Get the range of blocks the relay can accept proofs for. + /// @dev Assumes that the genesis has been set correctly. + /// Additionally, if the next epoch after the current one has the exact + /// same difficulty, headers for it can be validated as well. + /// This function should be used for informative purposes, + /// e.g. to determine whether a retarget must be provided before submitting + /// a header chain for validation. + /// @return relayGenesis The height of the earliest block that can be + /// included in header chains for the relay to validate. + /// @return currentEpochEnd The height of the last block that can be + /// included in header chains for the relay to validate. + function getRelayRange() + external + view + returns (uint256 relayGenesis, uint256 currentEpochEnd) + { + relayGenesis = genesisEpoch * 2016; + currentEpochEnd = (currentEpoch * 2016) + 2015; + } + + /// @notice Returns the difficulty of the current epoch. + /// @dev returns 0 if the relay is not ready. + /// @return The difficulty of the current epoch. + function getCurrentEpochDifficulty() + external + view + virtual + returns (uint256) + { + return currentEpochDifficulty; + } + + /// @notice Returns the difficulty of the previous epoch. + /// @dev Returns 0 if the relay is not ready or has not had a retarget. + /// @return The difficulty of the previous epoch. + function getPrevEpochDifficulty() external view virtual returns (uint256) { + return prevEpochDifficulty; + } + + function getCurrentAndPrevEpochDifficulty() + external + view + returns (uint256 current, uint256 previous) + { + return (currentEpochDifficulty, prevEpochDifficulty); + } + + /// @notice Get the difficulty of the specified epoch. + /// @param epochNumber The number of the epoch (the height of the first + /// block of the epoch, divided by 2016). Must fall within the relay range. + /// @return The difficulty of the epoch. + function getEpochDifficulty(uint256 epochNumber) + public + view + returns (uint256) + { + require(epochNumber >= genesisEpoch, "Epoch is before relay genesis"); + require( + epochNumber <= currentEpoch, + "Epoch is not proven to the relay yet" + ); + return BTCUtils.calculateDifficulty(epochs[epochNumber].target); + } + + /// @notice Check that the specified header forms a correct chain with the + /// digest of the previous header (if provided), and has sufficient work. + /// @param headers The byte array containing the header of interest. + /// @param start The start of the header in the array. + /// @param prevDigest The digest of the previous header + /// (optional; providing zeros for the digest skips the check). + /// @return digest The digest of the current header. + /// @return target The PoW target of the header. + /// @dev Throws an exception if the header's chain or PoW are invalid. + /// Performs no other validation. + function validateHeader( + bytes memory headers, + uint256 start, + bytes32 prevDigest + ) internal view returns (bytes32 digest, uint256 target) { + // If previous block digest has been provided, require that it matches + if (prevDigest != bytes32(0)) { + require( + headers.validateHeaderPrevHash(start, prevDigest), + "Invalid chain" + ); + } + + // Require that the header has sufficient work for its stated target + target = headers.extractTargetAt(start); + digest = headers.hash256Slice(start, 80); + require(ValidateSPV.validateHeaderWork(digest, target), "Invalid work"); + + return (digest, target); + } +} diff --git a/test/LightRelay.t.sol b/test/LightRelay.t.sol new file mode 100644 index 00000000..63bb6c2a --- /dev/null +++ b/test/LightRelay.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import {Test, console2} from "forge-std/Test.sol"; +import {LightRelay} from "../src/relay/LightRelay.sol"; + +contract LightRelayTest is Test { + LightRelay public relay; + + struct Header { + bytes32 digest; + bytes data; + uint256 height; + } + + Header genesis; + bytes32 periodStart; + + Header[3] headers; + + constructor() public { + genesis = Header({ + digest: hex"00000000000000000021ce9ccc1691e25066eb388be7821a4906dbee4b611546", + data: hex"00000020db62962b5989325f30f357762ae456b2ec340432278e14000000000000000000d1dd4e30908c361dfeabfb1e560281c1a270bde3c8719dbda7c848005317594440bf615c886f2e17bd6b082d", + height: 562621 + }); + periodStart = hex"5204b3afd5c0dc010de8eeb28925b97d4b38a16b1d020f000000000000000000"; + + headers[0] = Header({ + digest: hex"0000000000000000000f498a3b8394685a70ba0b0d8cb27850b1f499a380b5b8", + data: hex"000000204615614beedb06491a82e78b38eb6650e29116cc9cce21000000000000000000b034884fc285ff1acc861af67be0d87f5a610daa459d75a58503a01febcc287a34c0615c886f2e17046e7325", + height: 562622 + }); + headers[1] = Header({ + digest: hex"0000000000000000002066539e823973bbd8174973a5af1634ab56f685a949f5", + data: hex"00000020b8b580a399f4b15078b28c0d0bba705a6894833b8a490f000000000000000000b16c32aa36d3b70749e7febbb9e733321530cc9a390ccb62dfb78e3955859d4c44c0615c886f2e1744ea7cc4", + height: 562623 + }); + headers[2] = Header({ + digest: hex"00000000000000000014c9dc88648827978218b292dbdf0aa92aaf28aef9ff8b", + data: hex"00000020f549a985f656ab3416afa5734917d8bb7339829e536620000000000000000000af9c9fe22494c39cf382b5c8dcef91f079ad84cb9838387aaa17948fbf25753430c2615c886f2e170a654758", + height: 562624 + }); + } + + + function setUp() public { + relay = new LightRelay(); + relay.genesis( + genesis.data, + genesis.height, + 1 + ); + } + + function test_Retarget() public { + // relay.addHeaders(genesis.data, abi.encodePacked( + // headers[0].data, + // headers[1].data, + // headers[2].data + // )); + // assertEq(relay.getBestKnownDigest(), headers[2].digest); + } +} From 5914357bca11196e998d274f158b21a2acba29e9 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Tue, 10 Oct 2023 04:39:40 +0100 Subject: [PATCH 3/3] feat: add tests for genesis, retarget and validate proof Signed-off-by: Gregory Hill --- src/bridge/BitcoinTx.sol | 4 +- test/LightRelay.t.sol | 105 +++++++++++++++++++++++++++------------ 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/bridge/BitcoinTx.sol b/src/bridge/BitcoinTx.sol index e31bca96..251e1619 100644 --- a/src/bridge/BitcoinTx.sol +++ b/src/bridge/BitcoinTx.sol @@ -164,8 +164,8 @@ library BitcoinTx { /// @return txHash Proven 32-byte transaction hash. function validateProof( BridgeState.Storage storage self, - Info calldata txInfo, - Proof calldata proof + Info memory txInfo, + Proof memory proof ) internal view returns (bytes32 txHash) { require( txInfo.inputVector.validateVin(), diff --git a/test/LightRelay.t.sol b/test/LightRelay.t.sol index 63bb6c2a..81538dbc 100644 --- a/test/LightRelay.t.sol +++ b/test/LightRelay.t.sol @@ -2,63 +2,102 @@ pragma solidity 0.8.17; import {Test, console2} from "forge-std/Test.sol"; + import {LightRelay} from "../src/relay/LightRelay.sol"; +import {BitcoinTx} from "../src/bridge/BitcoinTx.sol"; +import {BridgeState} from "../src/bridge/BridgeState.sol"; contract LightRelayTest is Test { + using BitcoinTx for BridgeState.Storage; + LightRelay public relay; + BridgeState.Storage internal state; - struct Header { - bytes32 digest; + struct Header { bytes data; uint256 height; } - Header genesis; - bytes32 periodStart; - - Header[3] headers; + Header genesisHeader; + Header[4] retargetHeaders; + Header[2] proofHeaders; constructor() public { - genesis = Header({ - digest: hex"00000000000000000021ce9ccc1691e25066eb388be7821a4906dbee4b611546", - data: hex"00000020db62962b5989325f30f357762ae456b2ec340432278e14000000000000000000d1dd4e30908c361dfeabfb1e560281c1a270bde3c8719dbda7c848005317594440bf615c886f2e17bd6b082d", - height: 562621 + genesisHeader = Header({ + data: hex"04000000473ed7b7ef2fce828c318fd5e5868344a5356c9e93b6040400000000000000004409cae5b7b2f8f18ea55f558c9bfa7c5f4778a1a53172a48fc57e172d0ed3d264c5eb56c3a40618af9bc1c7", + height: 403200 }); - periodStart = hex"5204b3afd5c0dc010de8eeb28925b97d4b38a16b1d020f000000000000000000"; - headers[0] = Header({ - digest: hex"0000000000000000000f498a3b8394685a70ba0b0d8cb27850b1f499a380b5b8", - data: hex"000000204615614beedb06491a82e78b38eb6650e29116cc9cce21000000000000000000b034884fc285ff1acc861af67be0d87f5a610daa459d75a58503a01febcc287a34c0615c886f2e17046e7325", - height: 562622 + retargetHeaders[0] = Header({ + data: hex"04000000ea6410972dfe65f4d93d376c1678148baff89b914654460600000000000000000275ca93e15993399290808da2da97bce309e5d0f559713622fca2b32bf6330e1e10fe56c3a4061860b02896", + height: 405214 }); - headers[1] = Header({ - digest: hex"0000000000000000002066539e823973bbd8174973a5af1634ab56f685a949f5", - data: hex"00000020b8b580a399f4b15078b28c0d0bba705a6894833b8a490f000000000000000000b16c32aa36d3b70749e7febbb9e733321530cc9a390ccb62dfb78e3955859d4c44c0615c886f2e1744ea7cc4", - height: 562623 + retargetHeaders[1] = Header({ + data: hex"040000005f5560a04006b8a4a6fc85c7a7816c36f89a6da5b03aaa000000000000000000e74d25fadc646f6e0e2b781da6d172170ed1213afb9ac6f05c5a5dc482830e520914fe56c3a40618a6a1ffc0", + height: 405215 }); - headers[2] = Header({ - digest: hex"00000000000000000014c9dc88648827978218b292dbdf0aa92aaf28aef9ff8b", - data: hex"00000020f549a985f656ab3416afa5734917d8bb7339829e536620000000000000000000af9c9fe22494c39cf382b5c8dcef91f079ad84cb9838387aaa17948fbf25753430c2615c886f2e170a654758", - height: 562624 + // **RETARGET** + retargetHeaders[2] = Header({ + data: hex"04000000f7ef2881b8a0cb415ba81e889c79bc5f1b098167c95646030000000000000000a48869fe8d6777821fa85525139cb77d12c440c16182c637e943dfea7d937daa7b16fe56f49606185628272d", + height: 405216 + }); + retargetHeaders[3] = Header({ + data: hex"0000003007fc81082e2d4d3b44e6ee00ad53a9d926213aee7394960600000000000000003a810e13e6253dd483a95f55cfc3adbc60e112f0d485832ea1d482661487e9dc411cfe56f496061837c4a22f", + height: 405217 }); - } + proofHeaders[0] = Header({ + data: hex"04000000e0879a33a87bf9481385adae91fa9e93713b932cbe8a09030000000000000000ee5ded948d805bb71bee5de25b447c42527898cac93eee1afe04663bb8204b358627fe56f4960618304a7db1", + height: 405220 + }); + proofHeaders[1] = Header({ + data: hex"04000000c0de92e7326cb020b59ffc5998405e539863c57da088a7040000000000000000d8e7273d0198ba4f10dfd57d151327c32113fc244fd0587d161a5c5332a53651ed28fe56f4960618b24502cc", + height: 405221 + }); + } function setUp() public { relay = new LightRelay(); relay.genesis( - genesis.data, - genesis.height, - 1 + genesisHeader.data, + genesisHeader.height, + 2 ); + state.relay = relay; + state.txProofDifficultyFactor = 2; + + // we need at least one retarget + vm.warp(1459492475); + relay.retarget(abi.encodePacked( + retargetHeaders[0].data, + retargetHeaders[1].data, + retargetHeaders[2].data, + retargetHeaders[3].data + )); } function test_Retarget() public { - // relay.addHeaders(genesis.data, abi.encodePacked( - // headers[0].data, - // headers[1].data, - // headers[2].data - // )); - // assertEq(relay.getBestKnownDigest(), headers[2].digest); + // actually submitted in setup, but check expected difficulty here + assertEq(relay.getCurrentEpochDifficulty(), 166851513282); + } + + function test_ValidateProof() public { + // 2ef69769cc0ee81141c79552de6b91f372ff886216dbfa84e5497a16b0173e79 + state.validateProof( + BitcoinTx.Info({ + version: hex"01000000", + inputVector: hex"01996cf4e2f0016a1f092aaaba653c7eae5dd4b6eef1f9a2a94c64f34b2fecbd85010000006a47304402206f99da49ce586528ed8981842df30b4a5a91195fd2d83e440d4193fc16a944ec022055cfdf63a2c90638821f1b5ff1fdf77526163ae057a0d0de30a6e1d3009e7a29012102811832eef7216470f489991f1d87e36d2890755d2bbf827eb1e71804491506afffffffff", + outputVector: hex"0200e9a435000000001976a914fd7e6999cd7e7114383e014b7e612a88ab6be68f88ac804a5d05000000001976a9145c1addbd0e4e78479e71fdca0555d2d44b67378e88ac", + locktime: hex"00000000" + }), + BitcoinTx.Proof({ + merkleProof: hex"0465f99dbe384bbc5d86a5242712e4154958e4b01f595f14b76f873ec349e14a16b17770af2bb48c9b2ce4dddf4631866fe3753e6c54bdcf18dfb2d4fb9983ee58e4f3be92087c843b815bbe1d5d686dc972552f7ffda4342319ceb5bea67ab0f2e463ec8ce8e3f580c5e2470ef20c5b33398ab9fea5ccbd0b3e3f6211305edafa068a28c8ac634df5bbc8064357295373b97db2600745f23ad6ebc87b66b4a8685aa8ff8e69abc5029dbf4b2fa03f05680c7a2c491410b23a5a6b27c5a91b89dac8cdd16a4460ce8ac8d17491025d29336440a133867f938a7f41cc7a64f3f04ac3817c3eb6a6a11dc30850ca4e80f9abbd42268bcc626138bc01639a902713425e7d3aca45647001fb32ff396c07027c5b081325530e74f936e6c4a8078a05f9717efd315534a84d047ee2ff0b2b93159a2b98eabb578af67ef7540a58e488b9c587a994c1a9a86937ad343ea734b7427678e3e6ba0be8f5045ce47e541bbc", + txIndexInBlock: 1, + bitcoinHeaders: abi.encodePacked( + proofHeaders[0].data, + proofHeaders[1].data + ) + }) + ); } }