diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45f607c8..a9365cde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,13 +3,7 @@ name: CI # Controls when the workflow will run -on: - push: - branches: [master, Stable-Test, QA-Test] - pull_request: - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: +on: [push, pull_request, workflow_dispatch] permissions: contents: read diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml index 36c2c0a4..cd79bfe0 100644 --- a/.github/workflows/slither.yml +++ b/.github/workflows/slither.yml @@ -33,7 +33,7 @@ jobs: sarif: results.sarif fail-on: none target: . - slither-args: --filter-paths "node_modules/|contracts/test-contracts/safe-test-contracts/" + slither-args: --filter-paths "node_modules/|contracts/test-contracts/safe-test-contracts/|contracts/legacy" - name: Upload SARIF file uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 with: diff --git a/contracts/DaoContributor.sol b/contracts/DaoContributor.sol new file mode 100644 index 00000000..fbb41303 --- /dev/null +++ b/contracts/DaoContributor.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {Flyover} from "./libraries/Flyover.sol"; + +/// @title OwnableDaoContributorUpgradeable +/// @notice This contract is used to handle the contributions to the DAO +/// @author Rootstock Labs +/// @dev Any contract that inherits from this contract will be able to collect DAO +/// contributions according to the logic the child contract defines +abstract contract OwnableDaoContributorUpgradeable is + ReentrancyGuardUpgradeable, + OwnableUpgradeable { + + // @custom:storage-location erc7201:rsk.dao.contributor + struct DaoContributorStorage { + uint256 feePercentage; + uint256 currentContribution; + address payable feeCollector; + } + + // keccak256(abi.encode(uint256(keccak256(bytes("rsk.dao.contributor"))) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant _CONTRIBUTOR_STORAGE_LOCATION = + 0xb7e513d124139aa68259a99d4c2c344f3ba61e36716330d77f7fa887d0048e00; + + /// @notice This event is emitted when a contribution is made to the DAO + /// @param contributor the address of the contributor + /// @param amount the amount of the contribution + event DaoContribution(address indexed contributor, uint256 indexed amount); + + /// @notice This event is emitted when the DAO fees are claimed. The claim is always all the + /// contributions made to the DAO by this contract so far + /// @param claimer the address of the claimer + /// @param receiver the address of the receiver + /// @param amount the amount of the fees claimed + event DaoFeesClaimed(address indexed claimer, address indexed receiver, uint256 indexed amount); + + /// @notice This event is emitted when the contributions are configured + /// @param feeCollector the address of the fee collector + /// @param feePercentage the percentage of the contributions that goes to the DAO + event ContributionsConfigured(address indexed feeCollector, uint256 indexed feePercentage); + + /// @notice This error is emitted when there are no fees to claim + error NoFees(); + + /// @notice This error is emitted when the fee collector is not set + error FeeCollectorUnset(); + + /// @notice This function is used to claim the contributions to the DAO. + /// It sends to the fee collector all the accumulated contributions so far + /// and resets the accumulated contributions to zero + /// @dev The function is only callable by the owner of the contract + function claimContribution() external onlyOwner nonReentrant { + DaoContributorStorage storage $ = _getContributorStorage(); + uint256 amount = $.currentContribution; + $.currentContribution = 0; + address feeCollector = $.feeCollector; + if (amount == 0) revert NoFees(); + if (feeCollector == address(0)) revert FeeCollectorUnset(); + if (amount > address(this).balance) revert Flyover.NoBalance(amount, address(this).balance); + emit DaoFeesClaimed(msg.sender, feeCollector, amount); + (bool sent, bytes memory reason) = feeCollector.call{value: amount}(""); + if (!sent) revert Flyover.PaymentFailed(feeCollector, amount, reason); + } + + /// @notice This function is used to configure the contributions to the DAO + /// @param feeCollector the address of the fee collector + /// @param feePercentage the percentage of the contributions that goes to the DAO + /// @dev The function is only callable by the owner of the contract + function configureContributions( + address payable feeCollector, + uint256 feePercentage + ) external onlyOwner { + DaoContributorStorage storage $ = _getContributorStorage(); + $.feeCollector = feeCollector; + $.feePercentage = feePercentage; + emit ContributionsConfigured(feeCollector, feePercentage); + } + + /// @notice This function is used to get the fee percentage + /// that the child contracts use to calculate the contributions + /// @return feePercentage the fee percentage + function getFeePercentage() external view returns (uint256) { + return _getContributorStorage().feePercentage; + } + + /// @notice This function is used to get the current contribution + /// @return currentContribution the current contribution + function getCurrentContribution() external view returns (uint256) { + return _getContributorStorage().currentContribution; + } + + /// @notice This function is used to get the fee collector + /// @return feeCollector the fee collector address + function getFeeCollector() external view returns (address) { + return _getContributorStorage().feeCollector; + } + + /// @notice This function is used to initialize the contract + /// @param owner the owner of the contract + /// @param feePercentage the percentage that the child contracts use to calculate the contributions + /// @param feeCollector the address of the fee collector that will receive the contributions + // solhint-disable-next-line func-name-mixedcase + function __OwnableDaoContributor_init( + address owner, + uint256 feePercentage, + address payable feeCollector + ) internal onlyInitializing { + __ReentrancyGuard_init_unchained(); + __Ownable_init_unchained(owner); + DaoContributorStorage storage $ = _getContributorStorage(); + $.feePercentage = feePercentage; + $.feeCollector = feeCollector; + } + + /// @notice This function is used to add a contribution to the DAO + /// @dev The function is only callable by the child contract, it can be + /// included wherever they consider the protocol should collect fees for the DAO + /// @param contributor the address of the contributor + /// @param amount the amount of the contribution + function _addDaoContribution(address contributor, uint256 amount) internal { + if (amount < 1) return; + DaoContributorStorage storage $ = _getContributorStorage(); + $.currentContribution += amount; + emit DaoContribution(contributor, amount); + } + + /// @dev The function is used to get the storage of the contract, avoid using the regular + /// storage to prevent conflicts with state variables of the child contract + /// @return $ the contributor storage + function _getContributorStorage() private pure returns (DaoContributorStorage storage $) { + assembly { + $.slot := _CONTRIBUTOR_STORAGE_LOCATION + } + } +} diff --git a/contracts/PegOutContract.sol b/contracts/PegOutContract.sol new file mode 100644 index 00000000..68334348 --- /dev/null +++ b/contracts/PegOutContract.sol @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {BtcUtils} from "@rsksmart/btc-transaction-solidity-helper/contracts/BtcUtils.sol"; +import {OwnableDaoContributorUpgradeable} from "./DaoContributor.sol"; +import {IBridge} from "./interfaces/IBridge.sol"; +import {ICollateralManagement, CollateralManagementSet} from "./interfaces/ICollateralManagement.sol"; +import {IPegOut} from "./interfaces/IPegOut.sol"; +import {Flyover} from "./libraries/Flyover.sol"; +import {Quotes} from "./libraries/Quotes.sol"; +import {SignatureValidator} from "./libraries/SignatureValidator.sol"; + +/// @title PegOutContract +/// @notice This contract is used to handle the peg out of the RSK network to the Bitcoin network +/// @author Rootstock Labs +contract PegOutContract is + OwnableDaoContributorUpgradeable, + IPegOut +{ + /// @notice This struct is used to store the information of a peg out + /// @param completed whether the peg out has been completed or not, + /// completed means the peg out was paid and refunded (to any party) + /// @param depositTimestamp the timestamp of the deposit + struct PegOutRecord { + bool completed; + uint256 depositTimestamp; + } + + /// @notice The version of the contract + string constant public VERSION = "1.0.0"; + Flyover.ProviderType constant private _PEG_TYPE = Flyover.ProviderType.PegOut; + uint256 constant private _PAY_TO_ADDRESS_OUTPUT = 0; + uint256 constant private _QUOTE_HASH_OUTPUT = 1; + uint256 constant private _SAT_TO_WEI_CONVERSION = 10**10; + uint256 constant private _QUOTE_HASH_SIZE = 32; + + IBridge private _bridge; + ICollateralManagement private _collateralManagement; + + mapping(bytes32 => Quotes.PegOutQuote) private _pegOutQuotes; + mapping(bytes32 => PegOutRecord) private _pegOutRegistry; + + /// @notice The dust threshold for the peg out. If the difference between the amount paid and the amount required + /// is more than this value, the difference goes back to the user's wallet + uint256 public dustThreshold; + /// @notice Average Bitcoin block time in seconds, used to calculate the expected confirmation time + uint256 public btcBlockTime; + bool private _mainnet; + + /// @notice This event is emitted when the dust threshold is set + /// @param oldThreshold the old dust threshold + /// @param newThreshold the new dust threshold + event DustThresholdSet(uint256 indexed oldThreshold, uint256 indexed newThreshold); + /// @notice This event is emitted when the Bitcoin block time is set + /// @param oldTime the old Bitcoin block time + /// @param newTime the new Bitcoin block time + event BtcBlockTimeSet(uint256 indexed oldTime, uint256 indexed newTime); + + /// @inheritdoc IPegOut + function depositPegOut( + Quotes.PegOutQuote calldata quote, + bytes calldata signature + ) external payable nonReentrant override { + if(!_collateralManagement.isRegistered(_PEG_TYPE, quote.lpRskAddress)) { + revert Flyover.ProviderNotRegistered(quote.lpRskAddress); + } + uint256 requiredAmount = quote.value + quote.callFee + quote.productFeeAmount + quote.gasFee; + if (msg.value < requiredAmount) { + revert InsufficientAmount(msg.value, requiredAmount); + } + if (quote.depositDateLimit < block.timestamp || quote.expireDate < block.timestamp) { + revert QuoteExpiredByTime(quote.depositDateLimit, quote.expireDate); + } + if (quote.expireBlock < block.number) { + revert QuoteExpiredByBlocks(quote.expireBlock); + } + + bytes32 quoteHash = _hashPegOutQuote(quote); + if (!SignatureValidator.verify(quote.lpRskAddress, quoteHash, signature)) { + revert SignatureValidator.IncorrectSignature(quote.lpRskAddress, quoteHash, signature); + } + + Quotes.PegOutQuote storage registeredQuote = _pegOutQuotes[quoteHash]; + + if (_isQuoteCompleted(quoteHash)) { + revert QuoteAlreadyCompleted(quoteHash); + } + if (registeredQuote.lbcAddress != address(0)) { + revert QuoteAlreadyRegistered(quoteHash); + } + + _pegOutQuotes[quoteHash] = quote; + _pegOutRegistry[quoteHash].depositTimestamp = block.timestamp; + + emit PegOutDeposit(quoteHash, msg.sender, msg.value, block.timestamp); + + if (dustThreshold > msg.value - requiredAmount) { + return; + } + + uint256 change = msg.value - requiredAmount; + emit PegOutChangePaid(quoteHash, quote.rskRefundAddress, change); + (bool sent, bytes memory reason) = quote.rskRefundAddress.call{value: change}(""); + if (!sent) { + revert Flyover.PaymentFailed(quote.rskRefundAddress, change, reason); + } + } + + /// @notice This function is used to initialize the contract + /// @param owner the owner of the contract + /// @param bridge the address of the Rootstock bridge + /// @param dustThreshold_ the dust threshold for the peg out + /// @param collateralManagement the address of the Collateral Management contract + /// @param mainnet whether the contract is on the mainnet or not + /// @param btcBlockTime_ the average Bitcoin block time in seconds + /// @param daoFeePercentage the percentage of the peg out amount that goes to the DAO. + /// Use zero to disable the DAO integration feature + /// @param daoFeeCollector the address of the DAO fee collector + // solhint-disable-next-line comprehensive-interface + function initialize( + address owner, + address payable bridge, + uint256 dustThreshold_, + address collateralManagement, + bool mainnet, + uint256 btcBlockTime_, + uint256 daoFeePercentage, + address payable daoFeeCollector + ) external initializer { + if (collateralManagement.code.length == 0) revert Flyover.NoContract(collateralManagement); + __OwnableDaoContributor_init(owner, daoFeePercentage, daoFeeCollector); + _bridge = IBridge(bridge); + _collateralManagement = ICollateralManagement(collateralManagement); + _mainnet = mainnet; + dustThreshold = dustThreshold_; + btcBlockTime = btcBlockTime_; + } + + /// @notice This function is used to set the collateral management contract + /// @param collateralManagement the address of the Collateral Management contract + /// @dev This function is only callable by the owner of the contract + // solhint-disable-next-line comprehensive-interface + function setCollateralManagement(address collateralManagement) external onlyOwner { + if (collateralManagement.code.length == 0) revert Flyover.NoContract(collateralManagement); + emit CollateralManagementSet(address(_collateralManagement), collateralManagement); + _collateralManagement = ICollateralManagement(collateralManagement); + } + + /// @notice This function is used to set the dust threshold + /// @param threshold the new dust threshold + /// @dev This function is only callable by the owner of the contract + // solhint-disable-next-line comprehensive-interface + function setDustThreshold(uint256 threshold) external onlyOwner { + emit DustThresholdSet(dustThreshold, threshold); + dustThreshold = threshold; + } + + /// @notice This function is used to set the average Bitcoin block time + /// @param blockTime the new average Bitcoin block time in seconds + /// @dev This function is only callable by the owner of the contract + // solhint-disable-next-line comprehensive-interface + function setBtcBlockTime(uint256 blockTime) external onlyOwner { + emit BtcBlockTimeSet(btcBlockTime, blockTime); + btcBlockTime = blockTime; + } + + /// @inheritdoc IPegOut + function refundPegOut( + bytes32 quoteHash, + bytes calldata btcTx, + bytes32 btcBlockHeaderHash, + uint256 merkleBranchPath, + bytes32[] calldata merkleBranchHashes + ) external nonReentrant override { + if(!_collateralManagement.isRegistered(_PEG_TYPE, msg.sender)) { + revert Flyover.ProviderNotRegistered(msg.sender); + } + if (_isQuoteCompleted(quoteHash)) revert QuoteAlreadyCompleted(quoteHash); + + Quotes.PegOutQuote memory quote = _pegOutQuotes[quoteHash]; + if (quote.lbcAddress == address(0)) revert Flyover.QuoteNotFound(quoteHash); + if (quote.lpRskAddress != msg.sender) revert InvalidSender(quote.lpRskAddress, msg.sender); + + BtcUtils.TxRawOutput[] memory outputs = BtcUtils.getOutputs(btcTx); + _validateBtcTxNullData(outputs, quoteHash); + _validateBtcTxConfirmations(quote, btcTx, btcBlockHeaderHash, merkleBranchPath, merkleBranchHashes); + _validateBtcTxAmount(outputs, quote); + _validateBtcTxDestination(outputs, quote); + + delete _pegOutQuotes[quoteHash]; + _pegOutRegistry[quoteHash].completed = true; + emit PegOutRefunded(quoteHash); + + _addDaoContribution(quote.lpRskAddress, quote.productFeeAmount); + + if (_shouldPenalize(quote, quoteHash, btcBlockHeaderHash)) { + _collateralManagement.slashPegOutCollateral(quote, quoteHash); + } + + uint256 refundAmount = quote.value + quote.callFee + quote.gasFee; + (bool sent, bytes memory reason) = quote.lpRskAddress.call{value: refundAmount}(""); + if (!sent) { + revert Flyover.PaymentFailed(quote.lpRskAddress, refundAmount, reason); + } + } + + /// @inheritdoc IPegOut + function refundUserPegOut(bytes32 quoteHash) external nonReentrant override { + Quotes.PegOutQuote memory quote = _pegOutQuotes[quoteHash]; + + if (quote.lbcAddress == address(0)) revert Flyover.QuoteNotFound(quoteHash); + // solhint-disable-next-line gas-strict-inequalities + if (quote.expireDate >= block.timestamp || quote.expireBlock >= block.number) revert QuoteNotExpired(quoteHash); + + uint256 valueToTransfer = quote.value + quote.callFee + quote.productFeeAmount + quote.gasFee; + address addressToTransfer = quote.rskRefundAddress; + + delete _pegOutQuotes[quoteHash]; + _pegOutRegistry[quoteHash].completed = true; + + emit PegOutUserRefunded(quoteHash, addressToTransfer, valueToTransfer); + _collateralManagement.slashPegOutCollateral(quote, quoteHash); + + (bool sent, bytes memory reason) = addressToTransfer.call{value: valueToTransfer}(""); + if (!sent) { + revert Flyover.PaymentFailed(addressToTransfer, valueToTransfer, reason); + } + } + + /// @inheritdoc IPegOut + function hashPegOutQuote( + Quotes.PegOutQuote calldata quote + ) external view override returns (bytes32) { + return _hashPegOutQuote(quote); + } + + /// @inheritdoc IPegOut + function isQuoteCompleted(bytes32 quoteHash) external view override returns (bool) { + return _isQuoteCompleted(quoteHash); + } + + /// @notice This function is used to hash a peg out quote + /// @dev The function also validates the quote belongs to this contract + /// @param quote the peg out quote to hash + /// @return quoteHash the hash of the peg out quote + function _hashPegOutQuote( + Quotes.PegOutQuote calldata quote + ) private view returns (bytes32) { + if (address(this) != quote.lbcAddress) { + revert Flyover.IncorrectContract(address(this), quote.lbcAddress); + } + return keccak256(Quotes.encodePegOutQuote(quote)); + } + + /// @notice This function is used to check if a quote has been completed (refunded by any party) + /// @param quoteHash the hash of the quote to check + /// @return completed whether the quote has been completed or not + function _isQuoteCompleted(bytes32 quoteHash) private view returns (bool) { + return _pegOutRegistry[quoteHash].completed; + } + + /// @notice This function is used to check if a liquidity provider should be penalized + /// according to the following rules: + /// - If the transfer was not made on time, the liquidity provider should be penalized + /// - If the liquidity provider is refunding after expiration, the liquidity provider should be penalized + /// @param quote the peg out quote + /// @param quoteHash the hash of the quote + /// @param blockHash the hash of the block that contains the first confirmation of the peg out transaction + /// @return shouldPenalize whether the liquidity provider should be penalized or not + function _shouldPenalize( + Quotes.PegOutQuote memory quote, + bytes32 quoteHash, + bytes32 blockHash + ) private view returns (bool) { + bytes memory firstConfirmationHeader = _bridge.getBtcBlockchainBlockHeaderByHash(blockHash); + if(firstConfirmationHeader.length < 1) revert Flyover.EmptyBlockHeader(blockHash); + + uint256 firstConfirmationTimestamp = BtcUtils.getBtcBlockTimestamp(firstConfirmationHeader); + uint256 expectedConfirmationTime = _pegOutRegistry[quoteHash].depositTimestamp + + quote.transferTime + + btcBlockTime; + + // penalize if the transfer was not made on time + if (firstConfirmationTimestamp > expectedConfirmationTime) { + return true; + } + + // penalize if LP is refunding after expiration + if (block.timestamp > quote.expireDate || block.number > quote.expireBlock) { + return true; + } + + return false; + } + + /// @notice This function is used to validate the number of confirmations of the Bitcoin transaction. + /// The function interacts with the Rootstock bridge to get the number of confirmations + /// @param quote the peg out quote + /// @param btcTx the Bitcoin transaction + /// @param btcBlockHeaderHash the hash of the block that contains the first confirmation of the peg out transaction + /// @param merkleBranchPath the path of the merkle branch + /// @param merkleBranchHashes the hashes of the merkle branch + function _validateBtcTxConfirmations( + Quotes.PegOutQuote memory quote, + bytes calldata btcTx, + bytes32 btcBlockHeaderHash, + uint256 merkleBranchPath, + bytes32[] calldata merkleBranchHashes + ) private view { + int256 confirmations = _bridge.getBtcTransactionConfirmations( + BtcUtils.hashBtcTx(btcTx), + btcBlockHeaderHash, + merkleBranchPath, + merkleBranchHashes + ); + if (confirmations < 0) { + revert UnableToGetConfirmations(confirmations); + } else if (confirmations < int(uint256(quote.transferConfirmations))) { + revert NotEnoughConfirmations(int(uint256(quote.transferConfirmations)), confirmations); + } + } + + /// @notice This function is used to validate the destination of the Bitcoin transaction + /// @param outputs the outputs of the Bitcoin transaction + /// @param quote the peg out quote + function _validateBtcTxDestination( + BtcUtils.TxRawOutput[] memory outputs, + Quotes.PegOutQuote memory quote + ) private view { + bytes memory btcTxDestination = BtcUtils.outputScriptToAddress( + outputs[_PAY_TO_ADDRESS_OUTPUT].pkScript, + _mainnet + ); + if (keccak256(quote.depositAddress) != keccak256(btcTxDestination)) { + revert InvalidDestination(quote.depositAddress, btcTxDestination); + } + } + + /// @notice This function is used to validate the amount of the Bitcoin transaction + /// @param outputs the outputs of the Bitcoin transaction + /// @param quote the peg out quote + function _validateBtcTxAmount( + BtcUtils.TxRawOutput[] memory outputs, + Quotes.PegOutQuote memory quote + ) private pure { + uint256 requiredAmount = quote.value; + if (quote.value > _SAT_TO_WEI_CONVERSION && (quote.value % _SAT_TO_WEI_CONVERSION) != 0) { + requiredAmount = quote.value - (quote.value % _SAT_TO_WEI_CONVERSION); + } + uint256 paidAmount = outputs[_PAY_TO_ADDRESS_OUTPUT].value * _SAT_TO_WEI_CONVERSION; + if (paidAmount < requiredAmount) revert InsufficientAmount(paidAmount, requiredAmount); + } + + /// @notice This function is used to validate the null data of the Bitcoin transaction. The null data + /// is used to store the hash of the peg out quote in the Bitcoin transaction + /// @param outputs the outputs of the Bitcoin transaction + /// @param quoteHash the hash of the peg out quote + function _validateBtcTxNullData(BtcUtils.TxRawOutput[] memory outputs, bytes32 quoteHash) private pure { + bytes memory scriptContent = BtcUtils.parseNullDataScript(outputs[_QUOTE_HASH_OUTPUT].pkScript); + uint256 scriptLength = scriptContent.length; + + if (scriptLength != _QUOTE_HASH_SIZE + 1 || uint8(scriptContent[0]) != _QUOTE_HASH_SIZE) { + revert MalformedTransaction(scriptContent); + } + + bytes32 txQuoteHash; + assembly { + txQuoteHash := mload(add(scriptContent, 33)) // 32 bytes after the first byte + } + if (quoteHash != txQuoteHash) revert InvalidQuoteHash(quoteHash, txQuoteHash); + } +} diff --git a/contracts/interfaces/ICollateralManagement.sol b/contracts/interfaces/ICollateralManagement.sol index 48e7d786..e368df15 100644 --- a/contracts/interfaces/ICollateralManagement.sol +++ b/contracts/interfaces/ICollateralManagement.sol @@ -2,12 +2,21 @@ pragma solidity 0.8.25; import {Flyover} from "../libraries/Flyover.sol"; +import {Quotes} from "../libraries/Quotes.sol"; + +event CollateralManagementSet(address indexed oldAddress, address indexed newAddress); interface ICollateralManagement { event WithdrawCollateral(address indexed addr, uint indexed amount); event Resigned(address indexed addr); event PegInCollateralAdded(address indexed addr, uint256 indexed amount); event PegOutCollateralAdded(address indexed addr, uint256 indexed amount); + event Penalized( + address indexed liquidityProvider, + bytes32 indexed quoteHash, + Flyover.ProviderType indexed collateralType, + uint penalty + ); error AlreadyResigned(address from); error NotResigned(address from); @@ -16,8 +25,10 @@ interface ICollateralManagement { function addPegInCollateralTo(address addr) external payable; function addPegInCollateral() external payable; + function slashPegInCollateral(Quotes.PegInQuote calldata quote, bytes32 quoteHash) external; function addPegOutCollateralTo(address addr) external payable; function addPegOutCollateral() external payable; + function slashPegOutCollateral(Quotes.PegOutQuote calldata quote, bytes32 quoteHash) external; function getPegInCollateral(address addr) external view returns (uint256); function getPegOutCollateral(address addr) external view returns (uint256); diff --git a/contracts/interfaces/IPegOut.sol b/contracts/interfaces/IPegOut.sol new file mode 100644 index 00000000..0a35a9a2 --- /dev/null +++ b/contracts/interfaces/IPegOut.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Quotes} from "../libraries/Quotes.sol"; + +/// @title PegOut interface +/// @notice This interface is used to expose the required functions to provide the Flyover peg out service +interface IPegOut { + + /// @notice Emitted when a peg out is refunded to the liquidity + /// provider after successfully providing the service + /// @param quoteHash hash of the refunded quote + event PegOutRefunded(bytes32 indexed quoteHash); + + /// @notice Emitted when a peg out is refunded to the user because + /// the liquidity provider failed to provide the service + /// @param quoteHash hash of the refunded quote + /// @param userAddress address of the refunded user + /// @param value refunded amount + event PegOutUserRefunded( + bytes32 indexed quoteHash, + address indexed userAddress, + uint256 indexed value + ); + + /// @notice Emitted when a user overpays a peg out quote and the excess is bigger + /// than the dust threshold so its returned to the user + /// @param quoteHash hash of the paid quote + /// @param userAddress address of the user who is receiving the change + /// @param change the change amount + event PegOutChangePaid( + bytes32 indexed quoteHash, + address indexed userAddress, + uint256 indexed change + ); + + /// Emitted when a peg out quote is paid + /// @param quoteHash hash of the paid quote + /// @param sender the payer of the quote + /// @param timestamp timestamp of the block where the quote was paid + /// @param amount the value of the deposit transaction + event PegOutDeposit( + bytes32 indexed quoteHash, + address indexed sender, + uint256 indexed timestamp, + uint256 amount + ); + + /// @notice This error is emitted when the amount sent is less than the amount required to pay for the quote + /// @param amount the amount sent + /// @param target the amount required to pay for the quote + error InsufficientAmount(uint256 amount, uint256 target); + + /// @notice This error is emitted when the quote has expired by the number of blocks + /// @param expireBlock the number of blocks the quote has expired + error QuoteExpiredByBlocks(uint32 expireBlock); + + /// @notice This error is emitted when the quote has expired by the time + /// @param depositDateLimit the date limit for the user to deposit + /// @param expireDate the expiration of the quote + error QuoteExpiredByTime(uint32 depositDateLimit, uint32 expireDate); + + /// @notice This error is emitted when the quote has already been completed + /// @param quoteHash the hash of the quote that has already been completed + error QuoteAlreadyCompleted(bytes32 quoteHash); + + /// @notice This error is emitted when the quote has already been registered + /// @param quoteHash the hash of the quote that has already been registered + error QuoteAlreadyRegistered(bytes32 quoteHash); + + /// @notice This error is emitted when one of the output scripts of the Bitcoin + /// peg out transaction is malformed + /// @param outputScript the output script that is malformed + error MalformedTransaction(bytes outputScript); + + /// @notice This error is emitted when the destination of the Bitcoin transaction is invalid + /// @param expected the expected destination + /// @param actual the actual destination + error InvalidDestination(bytes expected, bytes actual); + + /// @notice This error is emitted when the quote hash is invalid + /// @param expected the expected quote hash + /// @param actual the actual quote hash + error InvalidQuoteHash(bytes32 expected, bytes32 actual); + + /// @notice This error is emitted when the sender is not allowed to perform a specific operation + /// @param expected the expected sender + /// @param actual the actual sender + error InvalidSender(address expected, address actual); + + /// @notice This error is emitted when the get confirmations from the rootstock bridge fails + /// @param errorCode The error code returned by the rootstock bridge + error UnableToGetConfirmations(int errorCode); + + /// @notice This error is emitted when the number of confirmations of the Bitcoin transaction is not enough + /// @param required the required number of confirmations + /// @param current the current number of confirmations + error NotEnoughConfirmations(int required, int current); + + /// @notice This error is emitted when the quote is not expired yet + /// @param quoteHash the hash of the quote that is not expired + error QuoteNotExpired(bytes32 quoteHash); + + /// @notice This is the function used to pay for a peg out quote. This is the only correct function to execute + /// such payment, sending money directly to the contract does not work + /// @param quote The quote that is being paid + /// @param signature The signature of the quote hash provided by the liquidity provider after the quote acceptance + function depositPegOut(Quotes.PegOutQuote calldata quote, bytes calldata signature) external payable; + + /// @notice This function is used by the liquidity provider to recover the funds spent on the peg out service plus + /// their fee for the service. It proves the inclusion of the transaction paying to the user in the Bitcoin network + /// @param quoteHash hash of the quote being refunded + /// @param btcTx the bitcoin raw transaction without the witness + /// @param btcBlockHeaderHash header hash of the block where the transaction was included + /// @param merkleBranchPath index of the leaf that is being proved to be included in the merkle tree + /// @param merkleBranchHashes hashes of the merkle branch to get to the merkle root using the leaf being proved + function refundPegOut( + bytes32 quoteHash, + bytes calldata btcTx, + bytes32 btcBlockHeaderHash, + uint256 merkleBranchPath, + bytes32[] calldata merkleBranchHashes + ) external; + + /// @notice This function must be used by the user to recover the funds if the liquidity provider + /// fails to provide the service. The user needs to wait for the quote to expire before calling + /// this function + /// @param quoteHash the hash of the quote being refunded + function refundUserPegOut(bytes32 quoteHash) external; + + /// @notice This view is used to get the hash of a quote, this should be used as the single source of truth so + /// all the involved parties can compute the quote hash in the same way + /// @param quote the quote to hash + function hashPegOutQuote(Quotes.PegOutQuote calldata quote) external view returns (bytes32); + + /// @notice This view is used to check if a quote has been completed. Completed means it was paid and refunded + /// doesn't matter if the refund was to the liquidity provider (success) or to the user (failure) + /// @param quoteHash the hash of the quote to check + function isQuoteCompleted(bytes32 quoteHash) external view returns (bool); +} diff --git a/contracts/libraries/Flyover.sol b/contracts/libraries/Flyover.sol index a5d4fa43..84941d76 100644 --- a/contracts/libraries/Flyover.sol +++ b/contracts/libraries/Flyover.sol @@ -14,4 +14,10 @@ library Flyover { } error ProviderNotRegistered(address from); + error IncorrectContract(address expected, address actual); + error QuoteNotFound(bytes32 quoteHash); + error PaymentFailed(address addr, uint amount, bytes reason); + error EmptyBlockHeader(bytes32 blockHash); + error NoBalance(uint256 wanted, uint256 actual); + error NoContract(address addr); } diff --git a/contracts/libraries/SignatureValidator.sol b/contracts/libraries/SignatureValidator.sol index da456201..a573a10d 100644 --- a/contracts/libraries/SignatureValidator.sol +++ b/contracts/libraries/SignatureValidator.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.25; library SignatureValidator { + error IncorrectSignature(address expectedAddress, bytes32 usedHash, bytes signature); /** @dev Verfies signature against address @param addr The signing address diff --git a/contracts/split/CollateralManagement.sol b/contracts/split/CollateralManagement.sol index f46d93d8..a3f6eefc 100644 --- a/contracts/split/CollateralManagement.sol +++ b/contracts/split/CollateralManagement.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.25; import {ICollateralManagement} from "../interfaces/ICollateralManagement.sol"; import {Flyover} from "../libraries/Flyover.sol"; +import {Quotes} from "../libraries/Quotes.sol"; import { AccessControlDefaultAdminRulesUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; @@ -79,6 +80,18 @@ contract CollateralManagementContract is _addPegInCollateralTo(msg.sender); } + function slashPegInCollateral( + Quotes.PegInQuote calldata quote, + bytes32 quoteHash + ) external onlyRole(COLLATERAL_SLASHER) { + uint penalty = _min( + quote.penaltyFee, + _pegInCollateral[quote.liquidityProviderRskAddress] + ); + _pegInCollateral[quote.liquidityProviderRskAddress] -= penalty; + emit Penalized(quote.liquidityProviderRskAddress, quoteHash, Flyover.ProviderType.PegIn, penalty); + } + function addPegOutCollateralTo(address addr) external onlyRole(COLLATERAL_ADDER) payable { _addPegOutCollateralTo(addr); } @@ -87,6 +100,18 @@ contract CollateralManagementContract is _addPegOutCollateralTo(msg.sender); } + function slashPegOutCollateral( + Quotes.PegOutQuote calldata quote, + bytes32 quoteHash + ) external onlyRole(COLLATERAL_SLASHER) { + uint penalty = _min( + quote.penaltyFee, + _pegOutCollateral[quote.lpRskAddress] + ); + _pegOutCollateral[quote.lpRskAddress] -= penalty; + emit Penalized(quote.lpRskAddress, quoteHash, Flyover.ProviderType.PegOut, penalty); + } + function getMinCollateral() external view returns (uint) { return _minCollateral; } @@ -156,4 +181,8 @@ contract CollateralManagementContract is _pegOutCollateral[addr] += amount; emit ICollateralManagement.PegOutCollateralAdded(addr, amount); } + + function _min(uint a, uint b) private pure returns (uint) { + return a < b ? a : b; + } } diff --git a/contracts/split/FlyoverDiscoveryFull.sol b/contracts/split/FlyoverDiscoveryFull.sol index 2ff6ff44..4038384a 100644 --- a/contracts/split/FlyoverDiscoveryFull.sol +++ b/contracts/split/FlyoverDiscoveryFull.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.25; import {IFlyoverDiscovery} from "../interfaces/IFlyoverDiscovery.sol"; import {ICollateralManagement} from "../interfaces/ICollateralManagement.sol"; import {Flyover} from "../libraries/Flyover.sol"; +import {Quotes} from "../libraries/Quotes.sol"; import { AccessControlDefaultAdminRulesUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; @@ -328,4 +329,32 @@ contract FlyoverDiscoveryFull is _resignationBlockNum[addr] == 0; } } + + function slashPegInCollateral( + Quotes.PegInQuote calldata quote, + bytes32 quoteHash + ) external onlyRole(COLLATERAL_SLASHER) { + uint penalty = _min( + quote.penaltyFee, + _pegInCollateral[quote.liquidityProviderRskAddress] + ); + _pegInCollateral[quote.liquidityProviderRskAddress] -= penalty; + emit Penalized(quote.liquidityProviderRskAddress, quoteHash, Flyover.ProviderType.PegIn, penalty); + } + + function slashPegOutCollateral( + Quotes.PegOutQuote calldata quote, + bytes32 quoteHash + ) external onlyRole(COLLATERAL_SLASHER) { + uint penalty = _min( + quote.penaltyFee, + _pegOutCollateral[quote.lpRskAddress] + ); + _pegOutCollateral[quote.lpRskAddress] -= penalty; + emit Penalized(quote.lpRskAddress, quoteHash, Flyover.ProviderType.PegOut, penalty); + } + + function _min(uint a, uint b) private pure returns (uint) { + return a < b ? a : b; + } } diff --git a/contracts/test-contracts/BridgeMock.sol b/contracts/test-contracts/BridgeMock.sol index 6e8096a5..d8ece508 100644 --- a/contracts/test-contracts/BridgeMock.sol +++ b/contracts/test-contracts/BridgeMock.sol @@ -9,9 +9,14 @@ contract BridgeMock is IBridge { mapping(bytes32 => uint256) private _amounts; mapping(uint256 => bytes) private _headers; mapping (bytes32 => bytes) private _headersByHash; + int private _confirmations; error SendFailed(); + constructor() { + _confirmations = 2; + } + // solhint-disable-next-line no-empty-blocks receive() external payable override {} @@ -35,6 +40,10 @@ contract BridgeMock is IBridge { return int(amount); } + function setConfirmations(int confirmations) external { + _confirmations = confirmations; + } + // solhint-disable-next-line no-empty-blocks function registerBtcTransaction ( bytes calldata atx, int256 height, bytes calldata pmt ) external override {} function addSignature ( bytes calldata pubkey, bytes[] calldata signatures, bytes calldata txhash ) @@ -58,6 +67,9 @@ contract BridgeMock is IBridge { return _headersByHash[blockHash]; } + function getBtcTransactionConfirmations ( bytes32 , bytes32, uint256 , bytes32[] calldata ) + external view override returns (int256) { return _confirmations; } + function getActivePowpegRedeemScript() external pure returns (bytes memory) { bytes memory part1 = hex"522102cd53fc53a07f211641a677d250f6de99caf620e8e77071e811a28b3bcddf0be1210362634ab5"; bytes memory part2 = hex"7dae9cb373a5d536e66a8c4f67468bbcfb063809bab643072d78a1242103c5946b3fbae03a654237da86"; @@ -125,8 +137,6 @@ contract BridgeMock is IBridge { function getFeePerKb ( ) external pure override returns (int256) {return int256(0);} function voteFeePerKbChange ( int256 ) external pure override returns (int256) {return int256(0);} function getMinimumLockTxValue ( ) external pure override returns (int256) {return int256(2);} - function getBtcTransactionConfirmations ( bytes32 , bytes32, uint256 , bytes32[] calldata ) - external pure override returns (int256) {return int256(2);} function getLockingCap ( ) external pure override returns (int256) {return int256(0);} function increaseLockingCap ( int256 ) external pure override returns (bool) {return false;} function hasBtcBlockCoinbaseTransactionInformation ( bytes32 ) external pure override returns diff --git a/contracts/test-contracts/PegOutChangeReceiver.sol b/contracts/test-contracts/PegOutChangeReceiver.sol new file mode 100644 index 00000000..8046358b --- /dev/null +++ b/contracts/test-contracts/PegOutChangeReceiver.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {IPegOut} from "../interfaces/IPegOut.sol"; +import {Quotes} from "../libraries/Quotes.sol"; + + +contract PegOutChangeReceiver { + + Quotes.PegOutQuote private _quote; + bytes private _signature; + bool private _fail; + + error SomeError(); + + constructor() { + _fail = false; + } + + // solhint-disable-next-line + receive() external payable { + if (_fail) { + revert SomeError(); + } + IPegOut(msg.sender).depositPegOut{value: 0}(_quote, _signature); + } + + // solhint-disable-next-line comprehensive-interface + function setFail(bool fail) external { + _fail = fail; + } + + // solhint-disable-next-line comprehensive-interface + function setPegOut( + Quotes.PegOutQuote calldata quote, + bytes calldata signature + ) external { + _quote = quote; + _signature = signature; + } +} diff --git a/scripts/deployment-utils/deploy-libraries.ts b/scripts/deployment-utils/deploy-libraries.ts new file mode 100644 index 00000000..d627cbb7 --- /dev/null +++ b/scripts/deployment-utils/deploy-libraries.ts @@ -0,0 +1,23 @@ +import { DeployedContractInfo } from "./deploy"; +import { deployContract } from "./utils"; + +// TODO temporary map to resolve conflicts between legacy and split version. +// Should be removed once the legacy contracts are deleted +const CONFLICT_MAP: Record = { + Quotes: "contracts/libraries/Quotes.sol:Quotes", +}; + +export async function deployLibraries( + network: string, + ...libraries: ("Quotes" | "BtcUtils" | "SignatureValidator")[] +): Promise>> { + const result: Record> = {}; + const libSet = new Set(libraries); + for (const lib of libSet) { + const actualName = CONFLICT_MAP[lib] ?? lib; + console.debug(`Deploying ${lib}...`); + const deployment = await deployContract(actualName, network); + result[lib] = deployment; + } + return result; +} diff --git a/tasks/utils/quote.ts b/tasks/utils/quote.ts index 473b8630..62b002df 100644 --- a/tasks/utils/quote.ts +++ b/tasks/utils/quote.ts @@ -2,6 +2,7 @@ import * as bs58check from "bs58check"; import { bech32, bech32m } from "bech32"; import { BytesLike } from "ethers"; import { QuotesV2 } from "../../typechain-types"; +import { Quotes } from "../../typechain-types/contracts/libraries"; export interface ApiPeginQuote { fedBTCAddr: string; @@ -77,7 +78,7 @@ export function parsePeginQuote( export function parsePegoutQuote( quote: ApiPegoutQuote -): QuotesV2.PegOutQuoteStruct { +): QuotesV2.PegOutQuoteStruct & Quotes.PegOutQuoteStruct { return { lbcAddress: quote.lbcAddress.toLowerCase(), lpRskAddress: quote.liquidityProviderRskAddress.toLowerCase(), @@ -88,6 +89,8 @@ export function parsePegoutQuote( penaltyFee: quote.penaltyFee, nonce: quote.nonce, deposityAddress: parseBtcAddress(quote.depositAddr), + // this is to support both legacy and new test suite + depositAddress: parseBtcAddress(quote.depositAddr), value: quote.value, agreementTimestamp: quote.agreementTimestamp, depositDateLimit: quote.depositDateLimit, diff --git a/test/pegout.test.ts b/test/pegout.test.ts index 72c46197..28c66b5e 100644 --- a/test/pegout.test.ts +++ b/test/pegout.test.ts @@ -22,7 +22,7 @@ import { import { fromLeHex } from "./utils/encoding"; import * as bs58check from "bs58check"; import * as hardhatHelpers from "@nomicfoundation/hardhat-network-helpers"; -import { read } from "../scripts/deployment-utils/deploy"; +import { deployLibraries } from "../scripts/deployment-utils/deploy-libraries"; describe("LiquidityBridgeContractV2 pegout process should", () => { ( @@ -96,7 +96,7 @@ describe("LiquidityBridgeContractV2 pegout process should", () => { (pegoutAmount + depositReceipt!.fee) * -1n ); - const btcTx = await generateRawTx(lbc, quote, scriptType); + const btcTx = await generateRawTx(lbc, quote, { scriptType }); const lpBalanceAfterRefundAssertion = await createBalanceUpdateAssertion({ source: ethers.provider, @@ -749,10 +749,10 @@ describe("LiquidityBridgeContractV2 pegout process should", () => { }); it("parse raw btc transaction p2pkh script", async () => { - const btcUtilsAddress = read()[hre.network.name].BtcUtils; + const deployment = await deployLibraries(hre.network.name, "BtcUtils"); const BtcUtils = await ethers.getContractAt( "BtcUtils", - btcUtilsAddress.address! + deployment.BtcUtils.address ); const firstRawTX = "0x0100000001013503c427ba46058d2d8ac9221a2f6fd50734a69f19dae65420191e3ada2d40000000006a47304402205d047dbd8c49aea5bd0400b85a57b2da7e139cec632fb138b7bee1d382fd70ca02201aa529f59b4f66fdf86b0728937a91a40962aedd3f6e30bce5208fec0464d54901210255507b238c6f14735a7abe96a635058da47b05b61737a610bef757f009eea2a4ffffffff0200879303000000001976a9143c5f66fe733e0ad361805b3053f23212e5755c8d88ac0000000000000000426a403938343934346435383039323135366335613139643936356239613735383530326536646263326439353337333135656266343839373336333134656233343700000000"; @@ -967,7 +967,7 @@ describe("LiquidityBridgeContractV2 pegout process should", () => { const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = getTestMerkleProof(); await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); - const btcTx = await generateRawTx(lbc, quote, "p2sh"); + const btcTx = await generateRawTx(lbc, quote, { scriptType: "p2sh" }); lbc = lbc.connect(provider.signer); const refundTx = lbc.refundPegOut( quoteHash, @@ -980,10 +980,10 @@ describe("LiquidityBridgeContractV2 pegout process should", () => { }); it("parse btc raw transaction outputs correctly", async () => { - const btcUtilsAddress = read()[hre.network.name].BtcUtils; + const deployment = await deployLibraries(hre.network.name, "BtcUtils"); const BtcUtils = await ethers.getContractAt( "BtcUtils", - btcUtilsAddress.address! + deployment.BtcUtils.address ); const transactions = [ { diff --git a/test/pegout/configuration.test.ts b/test/pegout/configuration.test.ts new file mode 100644 index 00000000..51eba244 --- /dev/null +++ b/test/pegout/configuration.test.ts @@ -0,0 +1,170 @@ +import { + BRIDGE_ADDRESS, + PEGOUT_CONSTANTS, + ZERO_ADDRESS, +} from "../utils/constants"; +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { + deployCollateralManagement, + deployPegOutContractFixture, +} from "./fixtures"; +import { deployLibraries } from "../../scripts/deployment-utils/deploy-libraries"; +import hre, { ethers, upgrades } from "hardhat"; +import { Flyover__factory } from "../../typechain-types"; + +describe("PegOutContract configurations", () => { + describe("initialize function should", function () { + it("initialize properly", async function () { + const { contract, owner } = await loadFixture( + deployPegOutContractFixture + ); + await expect(contract.VERSION()).to.eventually.eq("1.0.0"); + await expect(contract.btcBlockTime()).to.eventually.eq( + PEGOUT_CONSTANTS.TEST_BTC_BLOCK_TIME + ); + await expect(contract.dustThreshold()).to.eventually.eq( + PEGOUT_CONSTANTS.TEST_DUST_THRESHOLD + ); + await expect(contract.owner()).to.eventually.eq(owner.address); + await expect(contract.getFeePercentage()).to.eventually.eq(0n); + await expect(contract.getFeeCollector()).to.eventually.eq(ZERO_ADDRESS); + await expect(contract.getCurrentContribution()).to.eventually.eq(0n); + }); + + it("allow to initialize only once", async () => { + const { contract, initializationParams } = await loadFixture( + deployPegOutContractFixture + ); + await expect( + contract.initialize(...initializationParams) + ).to.be.revertedWithCustomError(contract, "InvalidInitialization"); + }); + + it("revert if there is no code in CollateralManagement", async () => { + const deployResult = await deployCollateralManagement(); + const libraries = await deployLibraries( + hre.network.name, + "Quotes", + "BtcUtils", + "SignatureValidator" + ); + const PegOutContract = await ethers.getContractFactory("PegOutContract", { + libraries: { + Quotes: libraries.Quotes.address, + BtcUtils: libraries.BtcUtils.address, + SignatureValidator: libraries.SignatureValidator.address, + }, + }); + await expect( + upgrades.deployProxy( + PegOutContract, + [ + deployResult.owner.address, + BRIDGE_ADDRESS, + PEGOUT_CONSTANTS.TEST_DUST_THRESHOLD, + deployResult.signers[2].address, + false, + PEGOUT_CONSTANTS.TEST_BTC_BLOCK_TIME, + 0, + ZERO_ADDRESS, + ], + { + unsafeAllow: ["external-library-linking"], + } + ) + ).to.be.revertedWithCustomError( + { interface: Flyover__factory.createInterface() }, + "NoContract" + ); + }); + }); + + describe("setDustThreshold function should", function () { + it("only allow the owner to modify the dust threshold", async function () { + const { contract, signers } = await loadFixture( + deployPegOutContractFixture + ); + const notOwner = signers[0]; + await expect( + contract.connect(notOwner).setDustThreshold(1n) + ).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); + }); + + it("modify the dust threshold properly", async function () { + const { contract, owner } = await loadFixture( + deployPegOutContractFixture + ); + const tx = contract.connect(owner).setDustThreshold(1n); + await expect(tx) + .to.emit(contract, "DustThresholdSet") + .withArgs(PEGOUT_CONSTANTS.TEST_DUST_THRESHOLD, 1n); + await expect(contract.dustThreshold()).to.eventually.eq(1n); + }); + }); + + describe("setBtcBlockTime function should", function () { + it("only allow the owner to modify the BTC block time", async function () { + const { contract, signers } = await loadFixture( + deployPegOutContractFixture + ); + const notOwner = signers[0]; + await expect( + contract.connect(notOwner).setBtcBlockTime(5n) + ).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); + }); + + it("modify the BTC block time properly", async function () { + const { contract, owner } = await loadFixture( + deployPegOutContractFixture + ); + const tx = contract.connect(owner).setBtcBlockTime(5n); + await expect(tx) + .to.emit(contract, "BtcBlockTimeSet") + .withArgs(PEGOUT_CONSTANTS.TEST_BTC_BLOCK_TIME, 5n); + await expect(contract.btcBlockTime()).to.eventually.eq(5n); + }); + }); + + describe("setCollateralManagement function should", function () { + it("only allow the owner to modify the collateralManagement address", async function () { + const { contract, signers } = await loadFixture( + deployPegOutContractFixture + ); + const notOwner = signers[0]; + const otherContract = await deployCollateralManagement().then((result) => + result.collateralManagement.getAddress() + ); + await expect( + contract.connect(notOwner).setCollateralManagement(otherContract) + ).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); + }); + + it("revert if address does not have code", async function () { + const { contract, owner, signers } = await loadFixture( + deployPegOutContractFixture + ); + const eoa = await signers[1].getAddress(); + await expect( + contract.connect(owner).setCollateralManagement(ZERO_ADDRESS) + ).to.be.revertedWithCustomError(contract, "NoContract"); + await expect( + contract.connect(owner).setCollateralManagement(eoa) + ).to.be.revertedWithCustomError(contract, "NoContract"); + }); + + it("modify collateralManagement properly", async function () { + const { contract, owner, initializationParams } = await loadFixture( + deployPegOutContractFixture + ); + const otherContract = await deployCollateralManagement().then((result) => + result.collateralManagement.getAddress() + ); + const tx = contract.connect(owner).setCollateralManagement(otherContract); + expect(initializationParams[3]).to.not.eq(otherContract); + await expect(tx) + .to.emit(contract, "CollateralManagementSet") + .withArgs(initializationParams[3], otherContract); + }); + }); +}); diff --git a/test/pegout/deposit.test.ts b/test/pegout/deposit.test.ts new file mode 100644 index 00000000..d695beea --- /dev/null +++ b/test/pegout/deposit.test.ts @@ -0,0 +1,397 @@ +import { + loadFixture, + mine, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { + getBtcPaymentBlockHeaders, + getTestPegoutQuote, + totalValue, +} from "../utils/quotes"; +import { deployPegOutContractFixture } from "./fixtures"; +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { generateRawTx, getTestMerkleProof } from "../utils/btc"; +import { getBytes } from "ethers"; +import { PEGOUT_CONSTANTS } from "../utils/constants"; +import { matchAnyNumber, matchSelector } from "../utils/matchers"; + +describe("PegOutContract depositPegOut function should", () => { + it("revert if the LP does not have collateral", async function () { + const { contract, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const notLp = signers[3]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: notLp, + refundAddress: user.address, + value, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await notLp.signMessage(getBytes(quoteHash)); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "ProviderNotRegistered") + .withArgs(notLp.address); + }); + + it("revert if the LP does't support peg out", async function () { + const { contract, pegInLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegInLp, + refundAddress: user.address, + value, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegInLp.signMessage(getBytes(quoteHash)); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "ProviderNotRegistered") + .withArgs(pegInLp.address); + }); + + it("revert the amount is not enough", async function () { + const { contract, fullLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: fullLp, + refundAddress: user.address, + value, + }); + const sent = totalValue(quote) - 1n; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await expect( + contract.connect(user).depositPegOut(quote, signature, { value: sent }) + ) + .to.be.revertedWithCustomError(contract, "InsufficientAmount") + .withArgs(sent, totalValue(quote)); + }); + + it("revert if the quote is expired by date", async function () { + const { contract, fullLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1"); + const quoteDepositDateExpired = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: fullLp, + refundAddress: user.address, + value, + }); + const now = Math.floor(Date.now() / 1000); + const quoteExpireDateExpired = structuredClone(quoteDepositDateExpired); + quoteDepositDateExpired.depositDateLimit = now - 10000; + quoteExpireDateExpired.expireDate = now - 1000; + const firstQuoteSignature = await contract + .hashPegOutQuote(quoteDepositDateExpired) + .then((quoteHash) => fullLp.signMessage(getBytes(quoteHash))); + const secondQuoteSignature = await contract + .hashPegOutQuote(quoteExpireDateExpired) + .then((quoteHash) => fullLp.signMessage(getBytes(quoteHash))); + await expect( + contract + .connect(user) + .depositPegOut(quoteDepositDateExpired, firstQuoteSignature, { + value: totalValue(quoteDepositDateExpired), + }) + ) + .to.be.revertedWithCustomError(contract, "QuoteExpiredByTime") + .withArgs( + quoteDepositDateExpired.depositDateLimit, + quoteDepositDateExpired.expireDate + ); + await expect( + contract + .connect(user) + .depositPegOut(quoteExpireDateExpired, secondQuoteSignature, { + value: totalValue(quoteExpireDateExpired), + }) + ) + .to.be.revertedWithCustomError(contract, "QuoteExpiredByTime") + .withArgs( + quoteExpireDateExpired.depositDateLimit, + quoteExpireDateExpired.expireDate + ); + }); + + it("revert if the quote is expired by blocks", async function () { + const { contract, fullLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: fullLp, + refundAddress: user.address, + value, + }); + const latestBlock = await ethers.provider.getBlockNumber(); + quote.expireBlock = latestBlock + 3; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await mine(3); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "QuoteExpiredByBlocks") + .withArgs(quote.expireBlock); + }); + + it("revert if the signature is invalid", async function () { + const { contract, fullLp, pegOutLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const otherSignature = await fullLp.signMessage(getBytes(quoteHash)); + await expect( + contract + .connect(user) + .depositPegOut(quote, otherSignature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "IncorrectSignature") + .withArgs(pegOutLp.address, quoteHash, otherSignature); + }); + + it("revert if the quote was already completed successfully", async function () { + const { contract, pegOutLp, bridgeMock, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + + const btcTx = await generateRawTx(contract, quote); + await expect( + contract + .connect(pegOutLp) + .refundPegOut( + quoteHash, + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ).not.to.be.reverted; + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "QuoteAlreadyCompleted") + .withArgs(quoteHash); + }); + + it("revert if the quote was already paid", async function () { + const { contract, pegOutLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ) + .to.be.revertedWithCustomError(contract, "QuoteAlreadyRegistered") + .withArgs(quoteHash); + }); + + it("receive peg out deposit successfully without paying change", async function () { + const { contract, pegOutLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value, + }); + const paidAmount = totalValue(quote) + ethers.parseEther("0.00000009"); // lest than dust threshold + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + const tx = contract + .connect(user) + .depositPegOut(quote, signature, { value: paidAmount }); + await expect(tx) + .to.emit(contract, "PegOutDeposit") + .withArgs(quoteHash, user.address, paidAmount, matchAnyNumber); + await expect(tx).not.to.emit(contract, "PegOutChangePaid"); + await expect(tx).to.changeEtherBalances( + [user, contract], + [-paidAmount, paidAmount] + ); + await expect( + contract.isQuoteCompleted(quoteHash), + "Deposit should not mark quote as completed" + ).to.eventually.be.false; + }); + + it("receive peg out deposit successfully paying change", async function () { + const { contract, pegOutLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1.03"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value, + }); + const quoteValue = totalValue(quote); + const paidAmount = quoteValue + PEGOUT_CONSTANTS.TEST_DUST_THRESHOLD; + const changeAmount = paidAmount - quoteValue; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + const tx = contract + .connect(user) + .depositPegOut(quote, signature, { value: paidAmount }); + await expect(tx) + .to.emit(contract, "PegOutDeposit") + .withArgs(quoteHash, user.address, paidAmount, matchAnyNumber); + await expect(tx) + .to.emit(contract, "PegOutChangePaid") + .withArgs(quoteHash, user.address, changeAmount); + await expect(tx).to.changeEtherBalances( + [user, contract], + [-quoteValue, quoteValue] + ); + await expect( + contract.isQuoteCompleted(quoteHash), + "Deposit should not mark quote as completed" + ).to.eventually.be.false; + }); + + it("revert if change payment fails", async function () { + const { contract, fullLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1"); + const PegOutChangeReceiver = await ethers.deployContract( + "PegOutChangeReceiver" + ); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: fullLp, + refundAddress: await PegOutChangeReceiver.getAddress(), + value, + }); + const paidAmount = ethers.parseEther("1.5"); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await PegOutChangeReceiver.setFail(true).then((tx) => tx.wait()); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: paidAmount }) + ) + .to.be.revertedWithCustomError(contract, "PaymentFailed") + .withArgs( + quote.rskRefundAddress, + matchAnyNumber, + matchSelector(PegOutChangeReceiver.interface, "SomeError") + ); + }); + + it("revert if change payment has reentrancy", async function () { + const { contract, fullLp, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const value = ethers.parseEther("1"); + const PegOutChangeReceiver = await ethers.deployContract( + "PegOutChangeReceiver" + ); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: fullLp, + refundAddress: await PegOutChangeReceiver.getAddress(), + value, + }); + const paidAmount = ethers.parseEther("1.5"); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await PegOutChangeReceiver.setPegOut(quote, signature).then((tx) => + tx.wait() + ); + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: paidAmount }) + ) + .to.be.revertedWithCustomError(contract, "PaymentFailed") + .withArgs( + quote.rskRefundAddress, + matchAnyNumber, + matchSelector(contract.interface, "ReentrancyGuardReentrantCall") + ); + }); +}); diff --git a/test/pegout/fixtures.ts b/test/pegout/fixtures.ts new file mode 100644 index 00000000..7790caba --- /dev/null +++ b/test/pegout/fixtures.ts @@ -0,0 +1,149 @@ +import hre, { upgrades, ethers } from "hardhat"; +import { + PEGOUT_CONSTANTS, + ProviderType, + ZERO_ADDRESS, +} from "../utils/constants"; +import { deployLibraries } from "../../scripts/deployment-utils/deploy-libraries"; +import { PegOutContract } from "../../typechain-types"; +import { getTestPegoutQuote, totalValue } from "../utils/quotes"; +import { getBytes } from "ethers"; + +// TODO this should be removed once the collateral management has its final implementation and test files, then +// this file should import a function from there +export async function deployCollateralManagement() { + const CollateralManagement = await ethers.getContractFactory( + "CollateralManagementContract" + ); + const FlyoverDiscovery = await ethers.getContractFactory( + "FlyoverDiscoveryContract" + ); + const signers = await ethers.getSigners(); + const lastSigner = signers.pop(); + if (!lastSigner) throw new Error("owner can't be undefined"); + const owner = lastSigner; + + const collateralManagement = await upgrades.deployProxy( + CollateralManagement, + [owner.address, 500n, ethers.parseEther("0.6"), 500n] + ); + + const discovery = await upgrades.deployProxy(FlyoverDiscovery, [ + owner.address, + await collateralManagement.getAddress(), + ]); + await collateralManagement + .connect(owner) + .grantRole( + await collateralManagement.COLLATERAL_ADDER(), + await discovery.getAddress() + ); + + const pegInLp = signers.pop(); + const pegOutLp = signers.pop(); + const fullLp = signers.pop(); + if (!pegInLp || !pegOutLp || !fullLp) + throw new Error("LP can't be undefined"); + + await discovery + .connect(pegInLp) + .register("Pegin Provider", "lp1.com", true, ProviderType.PegIn, { + value: ethers.parseEther("0.6"), + }); + await discovery + .connect(pegOutLp) + .register("PegOut Provider", "lp2.com", true, ProviderType.PegOut, { + value: ethers.parseEther("0.6"), + }); + await discovery + .connect(fullLp) + .register("Full Provider", "lp3.com", true, ProviderType.Both, { + value: ethers.parseEther("1.2"), + }); + + return { + collateralManagement, + discovery, + signers, + owner, + pegInLp, + pegOutLp, + fullLp, + }; +} + +export async function deployPegOutContractFixture() { + const deployResult = await deployCollateralManagement(); + const collateralManagement = deployResult.collateralManagement; + const collateralManagementAddress = await collateralManagement.getAddress(); + const bridgeMock = await ethers.deployContract("BridgeMock"); + + const initializationParams: Parameters = [ + deployResult.owner.address, + await bridgeMock.getAddress(), + PEGOUT_CONSTANTS.TEST_DUST_THRESHOLD, + collateralManagementAddress, + false, + PEGOUT_CONSTANTS.TEST_BTC_BLOCK_TIME, + 0, + ZERO_ADDRESS, + ]; + + const libraries = await deployLibraries( + hre.network.name, + "Quotes", + "BtcUtils", + "SignatureValidator" + ); + const PegOutContract = await ethers.getContractFactory("PegOutContract", { + libraries: { + Quotes: libraries.Quotes.address, + BtcUtils: libraries.BtcUtils.address, + SignatureValidator: libraries.SignatureValidator.address, + }, + }); + + const contract = await upgrades.deployProxy( + PegOutContract, + initializationParams, + { + unsafeAllow: ["external-library-linking"], + } + ); + await collateralManagement + .connect(deployResult.owner) + .grantRole( + await collateralManagement.COLLATERAL_SLASHER(), + await contract.getAddress() + ); + return { contract, bridgeMock, initializationParams, ...deployResult }; +} + +export async function paidPegOutFixture() { + const deployResult = await deployPegOutContractFixture(); + const { signers, contract, pegOutLp } = deployResult; + const user = signers.pop(); + if (!user) throw new Error("user can't be undefined"); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value: ethers.parseEther("1.23"), + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + const depositTx = await contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }); + const depositReceipt = await depositTx.wait(); + + return { + user, + usedLp: pegOutLp, + quote, + quoteHash, + depositTx, + depositReceipt, + ...deployResult, + }; +} diff --git a/test/pegout/hashing.test.ts b/test/pegout/hashing.test.ts new file mode 100644 index 00000000..ad949b42 --- /dev/null +++ b/test/pegout/hashing.test.ts @@ -0,0 +1,121 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { ApiPegoutQuote, parsePegoutQuote } from "../../tasks/utils/quote"; +import { deployPegOutContractFixture } from "./fixtures"; +import { expect } from "chai"; + +describe("PegOutContract hashPegOutQuote function should", () => { + it("revert if quote belongs to other contract", async () => { + const { contract } = await loadFixture(deployPegOutContractFixture); + const quote: ApiPegoutQuote = { + lbcAddress: "0xAA9cAf1e3967600578727F975F283446A3Da6612", + liquidityProviderRskAddress: "0x82a06ebdb97776a2da4041df8f2b2ea8d3257852", + btcRefundAddress: "bc1qlc98wwylr3g6kknh86a8gkdqmhf6vly527h2yv", + rskRefundAddress: "0xF52e06Df2E1cbD73fb686442319cbe5Ce495B996", + lpBtcAddr: "1D2xucTYkxCHvaaZuaKVJTfZQWr4PUjzAy", + callFee: 300000000000000, + penaltyFee: 10000000000000, + nonce: "5570584357569316000", + depositAddr: "bc1qlc98wwylr3g6kknh86a8gkdqmhf6vly527h2yv", + value: "471000000000000000", + agreementTimestamp: 1753461851, + depositDateLimit: 1753469051, + depositConfirmations: 40, + transferConfirmations: 2, + transferTime: 7200, + expireDate: 1753476251, + expireBlocks: 7822676, + gasFee: 5990000000000, + productFeeAmount: 0, + }; + await expect(contract.hashPegOutQuote(parsePegoutQuote(quote))) + .to.be.revertedWithCustomError(contract, "IncorrectContract") + .withArgs(await contract.getAddress(), quote.lbcAddress); + }); + it("hash pegout quote properly", async () => { + const { contract } = await loadFixture(deployPegOutContractFixture); + const testCases: { quote: ApiPegoutQuote; hash: string }[] = [ + { + quote: { + lbcAddress: "0xf4B146FbA71F41E0592668ffbF264F1D186b2Ca8", + liquidityProviderRskAddress: + "0x82a06ebdb97776a2da4041df8f2b2ea8d3257852", + btcRefundAddress: "bc1qlc98wwylr3g6kknh86a8gkdqmhf6vly527h2yv", + rskRefundAddress: "0xF52e06Df2E1cbD73fb686442319cbe5Ce495B996", + lpBtcAddr: "1D2xucTYkxCHvaaZuaKVJTfZQWr4PUjzAy", + callFee: 300000000000000, + penaltyFee: 10000000000000, + nonce: "5570584357569316000", + depositAddr: "bc1qlc98wwylr3g6kknh86a8gkdqmhf6vly527h2yv", + value: "471000000000000000", + agreementTimestamp: 1753461851, + depositDateLimit: 1753469051, + depositConfirmations: 40, + transferConfirmations: 2, + transferTime: 7200, + expireDate: 1753476251, + expireBlocks: 7822676, + gasFee: 5990000000000, + productFeeAmount: 0, + }, + hash: "0x185e5ae2ad8f2210d430c5cdc3a4d3a0ea8c086ffbf6195eda0abc912ea30b27", + }, + { + quote: { + lbcAddress: "0xf4B146FbA71F41E0592668ffbF264F1D186b2Ca8", + liquidityProviderRskAddress: + "0x82a06ebdb97776a2da4041df8f2b2ea8d3257852", + btcRefundAddress: "1KMCKD5ySjvugtyBgiADNhvDJ42QRD9Erp", + rskRefundAddress: "0x02E221A95224F090e492066Bc1B7a35B5Fd94542", + lpBtcAddr: "1D2xucTYkxCHvaaZuaKVJTfZQWr4PUjzAy", + callFee: 300000000000000, + penaltyFee: 10000000000000, + nonce: "3434440345862007300", + depositAddr: "1KMCKD5ySjvugtyBgiADNhvDJ42QRD9Erp", + value: "27108379819732510", + agreementTimestamp: 1753727248, + depositDateLimit: 1753734448, + depositConfirmations: 40, + transferConfirmations: 2, + transferTime: 7200, + expireDate: 1753741648, + expireBlocks: 7833647, + gasFee: 11330000000000, + productFeeAmount: 1, + }, + hash: "0x3ea79cf1c080f9e3c48b4e4db31a57ecf0b09dfe78f605f786d3ef10f3c06aec", + }, + { + quote: { + lbcAddress: "0xf4B146FbA71F41E0592668ffbF264F1D186b2Ca8", + liquidityProviderRskAddress: + "0x82a06ebdb97776a2da4041df8f2b2ea8d3257852", + btcRefundAddress: + "bc1p9yzdqu4de2kjq9j7gsxegzmfze678dvn9qvzjexj87d26as0yacsm7u8ar", + rskRefundAddress: "0x077B8Cd0e024e79eEFc8Ce1Fddc005DbE88A94c7", + lpBtcAddr: "1D2xucTYkxCHvaaZuaKVJTfZQWr4PUjzAy", + callFee: 300000000000000, + penaltyFee: 10000000000000, + nonce: "877548865611330300", + depositAddr: + "bc1p9yzdqu4de2kjq9j7gsxegzmfze678dvn9qvzjexj87d26as0yacsm7u8ar", + value: "1045000000000000000", + agreementTimestamp: 1753945401, + depositDateLimit: 1753952601, + depositConfirmations: 60, + transferConfirmations: 3, + transferTime: 7200, + expireDate: 1753959801, + expireBlocks: 7842574, + gasFee: 3140000000000, + productFeeAmount: 3, + }, + hash: "0x4710b80cdaba6541a6b3a4775ab86ff6fc3bbe317f4a35d9b69e8eec114a0fab", + }, + ]; + for (const testCase of testCases) { + await expect( + contract.hashPegOutQuote(parsePegoutQuote(testCase.quote)) + ).to.eventually.eq(testCase.hash); + } + }); +}); diff --git a/test/pegout/lp-refund.test.ts b/test/pegout/lp-refund.test.ts new file mode 100644 index 00000000..8f922e64 --- /dev/null +++ b/test/pegout/lp-refund.test.ts @@ -0,0 +1,664 @@ +import { + loadFixture, + mine, + mineUpTo, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { deployPegOutContractFixture, paidPegOutFixture } from "./fixtures"; +import { expect } from "chai"; +import { + getBtcPaymentBlockHeaders, + getTestPegoutQuote, + totalValue, +} from "../utils/quotes"; +import { + BtcAddressType, + generateRawTx, + getTestBtcAddress, + getTestMerkleProof, + satToWei, + WEI_TO_SAT_CONVERSION, + weiToSat, +} from "../utils/btc"; +import { getBytes } from "ethers"; +import { ethers } from "hardhat"; +import { PEGOUT_CONSTANTS, ProviderType } from "../utils/constants"; + +const AMOUNTS_TO_TEST_REFUND = [ + "500", + "100", + "50", + "15", + "1", + "1.1", + "1.0001", + "1.00000001", + "1.000000001", + "1.000000000000000001", +]; + +const BTC_ADDRESS_TYPES = ["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr"]; + +describe("PegOutContract refundPegOut function should", () => { + it("revert if LP resigned", async () => { + const { collateralManagement, usedLp, contract, quoteHash, quote } = + await loadFixture(paidPegOutFixture); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const btcTx = await generateRawTx(contract, quote); + + await expect(collateralManagement.connect(usedLp).resign()).not.to.be + .reverted; + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "ProviderNotRegistered") + .withArgs(usedLp.address); + }); + + it("revert if the quote was not paid", async () => { + const { pegOutLp, contract, signers } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: pegOutLp, + refundAddress: user.address, + value: ethers.parseEther("1"), + }); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const btcTx = await generateRawTx(contract, quote); + const quoteHash = await contract.hashPegOutQuote(quote); + + await expect( + contract + .connect(pegOutLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "QuoteNotFound") + .withArgs(getBytes(quoteHash)); + }); + + it("revert if it's no called by the LP", async () => { + const { fullLp, contract, quoteHash, quote, usedLp } = await loadFixture( + paidPegOutFixture + ); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const btcTx = await generateRawTx(contract, quote); + + await expect( + contract + .connect(fullLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "InvalidSender") + .withArgs(usedLp.address, fullLp.address); + }); + + it("revert if the btc tx is not related to the quote", async () => { + const { contract, quoteHash, quote, usedLp } = await loadFixture( + paidPegOutFixture + ); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + + const otherQuote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + liquidityProvider: usedLp, + refundAddress: String(quote.rskRefundAddress), // eslint-disable-line @typescript-eslint/no-base-to-string + value: ethers.parseEther("1"), + }); + const otherQuoteHash = await contract.hashPegOutQuote(otherQuote); + const btcTx = await generateRawTx(contract, otherQuote); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "InvalidQuoteHash") + .withArgs(getBytes(quoteHash), getBytes(otherQuoteHash)); + }); + + it("revert if the null data is malformed", async () => { + const { contract, quoteHash, usedLp } = await loadFixture( + paidPegOutFixture + ); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + + const invalidBtcTx = + "0x0100000001013503c427ba46058d2d8ac9221a2f6fd50734a69f19dae65420191e3ada2d40000000006a47304402205d047dbd8c49aea5bd0400b85a57b2da7e139cec632fb138b7bee1d382fd70ca02201aa529f59b4f66fdf86b0728937a91a40962aedd3f6e30bce5208fec0464d54901210255507b238c6f14735a7abe96a635058da47b05b61737a610bef757f009eea2a4ffffffff0200e1f505000000001976a914be07cb9dfdc7dfa88436fa4128410e2126d6979688ac0000000000000000216a194eb34f85cf4b36975d028a89e6dd057a755bcdd2208a854f2fb202f4ab18f700000000"; + + await expect( + contract.connect(usedLp).refundPegOut( + getBytes(quoteHash), + invalidBtcTx, // the quote hash in this tx has 31 bytes instead of 32 + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "MalformedTransaction") + .withArgs( + getBytes( + "0x194eb34f85cf4b36975d028a89e6dd057a755bcdd2208a854f2fb202f4ab18f7" + ) + ); + }); + + it("revert if contract can't get confirmations from the bridge", async () => { + const { contract, quoteHash, quote, usedLp, bridgeMock } = + await loadFixture(paidPegOutFixture); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(-5); + const btcTx = await generateRawTx(contract, quote); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "UnableToGetConfirmations") + .withArgs(-5); + }); + + it("revert if the btc tx doesn't have enough confirmations", async () => { + const { contract, quoteHash, quote, usedLp, bridgeMock } = + await loadFixture(paidPegOutFixture); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(1); + const btcTx = await generateRawTx(contract, quote); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "NotEnoughConfirmations") + .withArgs(quote.transferConfirmations, 1); + }); + + ["0.9", "0.9999", "0.99999999"].forEach( + // test quote value is 1 + (amount) => { + it("revert if the btc tx doesn't have a high enough amount", async () => { + const { contract, quoteHash, quote, usedLp, bridgeMock } = + await loadFixture(paidPegOutFixture); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash( + blockHeaderHash, + firstConfirmationHeader + ); + await bridgeMock.setConfirmations(quote.transferConfirmations); + const parsedAmount = ethers.parseEther(amount); + const satAmount = weiToSat(parsedAmount); + const btcTx = await generateRawTx(contract, quote, { + scriptType: "p2pkh", + amountOverride: satAmount, + }); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "InsufficientAmount") + .withArgs(satToWei(satAmount), quote.value); + }); + } + ); + + it("revert if the btc tx isn't directed to the user's address", async () => { + const { contract, quoteHash, quote, usedLp, bridgeMock } = + await loadFixture(paidPegOutFixture); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(quote.transferConfirmations); + const modifiedAddress = getTestBtcAddress("p2tr"); + const btcTx = await generateRawTx(contract, quote, { + scriptType: "p2tr", + addressOverride: modifiedAddress, + }); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "InvalidDestination") + .withArgs(quote.depositAddress, modifiedAddress); + }); + + // test the different combinations between address types and precisions + BTC_ADDRESS_TYPES.forEach((type) => { + AMOUNTS_TO_TEST_REFUND.forEach((amount) => { + it(`execute refund successfully for a ${type} destination and ${amount} amount`, async () => { + const { pegOutLp, contract, signers, bridgeMock } = await loadFixture( + deployPegOutContractFixture + ); + const contractAddress = await contract.getAddress(); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: contractAddress, + liquidityProvider: pegOutLp, + refundAddress: user.address, + value: ethers.parseEther(amount), + destinationAddressType: type as BtcAddressType, + productFeePercentage: 1, + }); + const quoteTotal = totalValue(quote); + const refundAmount = + BigInt(quote.value) + BigInt(quote.gasFee) + BigInt(quote.callFee); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const btcTx = await generateRawTx(contract, quote, { + scriptType: type as BtcAddressType, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash( + blockHeaderHash, + firstConfirmationHeader + ); + await bridgeMock.setConfirmations(2); + + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: quoteTotal }) + ).to.changeEtherBalances( + [contractAddress, pegOutLp.address, user.address], + [quoteTotal, 0, -quoteTotal] + ); + + const tx = contract + .connect(pegOutLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ); + await expect(tx).to.changeEtherBalances( + [contractAddress, pegOutLp.address, user.address], + [-refundAmount, refundAmount, 0] + ); + await expect(tx) + .to.emit(contract, "PegOutRefunded") + .withArgs(getBytes(quoteHash)); + await expect(tx) + .to.emit(contract, "DaoContribution") + .withArgs(pegOutLp.address, quote.productFeeAmount); + await expect(contract.getCurrentContribution()).to.eventually.eq( + quote.productFeeAmount + ); + await expect( + contract.isQuoteCompleted(getBytes(quoteHash)), + "Should mark quote as completed" + ).to.eventually.be.true; + + await expect( + contract + .connect(pegOutLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "QuoteAlreadyCompleted") + .withArgs(getBytes(quoteHash)); + }); + + it(`execute refund successfully for a ${type} destination and ${amount} truncated amount`, async () => { + const { pegOutLp, contract, signers, bridgeMock } = await loadFixture( + deployPegOutContractFixture + ); + const contractAddress = await contract.getAddress(); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: contractAddress, + liquidityProvider: pegOutLp, + refundAddress: user.address, + value: ethers.parseEther(amount), + destinationAddressType: type as BtcAddressType, + productFeePercentage: 1, + }); + const quoteTotal = totalValue(quote); + const refundAmount = + BigInt(quote.value) + BigInt(quote.gasFee) + BigInt(quote.callFee); + + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const truncatedAmount = + ethers.parseEther(amount) / WEI_TO_SAT_CONVERSION; + const btcTx = await generateRawTx(contract, quote, { + scriptType: type as BtcAddressType, + amountOverride: truncatedAmount, + }); + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await pegOutLp.signMessage(getBytes(quoteHash)); + + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash( + blockHeaderHash, + firstConfirmationHeader + ); + await bridgeMock.setConfirmations(2); + + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: quoteTotal }) + ).to.changeEtherBalances( + [contractAddress, pegOutLp.address, user.address], + [quoteTotal, 0, -quoteTotal] + ); + + const tx = contract + .connect(pegOutLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ); + await expect(tx).to.changeEtherBalances( + [contractAddress, pegOutLp.address, user.address], + [-refundAmount, refundAmount, 0] + ); + await expect(tx) + .to.emit(contract, "PegOutRefunded") + .withArgs(getBytes(quoteHash)); + await expect(tx) + .to.emit(contract, "DaoContribution") + .withArgs(pegOutLp.address, quote.productFeeAmount); + await expect(contract.getCurrentContribution()).to.eventually.eq( + quote.productFeeAmount + ); + await expect( + contract.isQuoteCompleted(getBytes(quoteHash)), + "Should mark quote as completed" + ).to.eventually.be.true; + + await expect( + contract + .connect(pegOutLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "QuoteAlreadyCompleted") + .withArgs(getBytes(quoteHash)); + }); + }); + }); + + it("execute refund successfully and penalize for being expired by time", async () => { + const { + contract, + quote, + bridgeMock, + usedLp, + quoteHash, + collateralManagement, + } = await loadFixture(paidPegOutFixture); + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(2); + const btcTx = await generateRawTx(contract, quote); + const latestBlock = await ethers.provider.getBlock("latest"); + const interval = + BigInt(quote.expireDate) - BigInt(latestBlock?.timestamp ?? 0) + 1n; + await mine(2, { interval }); + + const tx = contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ); + await expect(tx) + .to.emit(contract, "PegOutRefunded") + .withArgs(getBytes(quoteHash)); + await expect(tx).not.to.emit(contract, "DaoContribution"); + await expect(tx) + .to.emit(collateralManagement, "Penalized") + .withArgs( + usedLp.address, + getBytes(quoteHash), + ProviderType.PegOut, + quote.penaltyFee + ); + }); + + it("execute refund successfully and penalize for being expired by blocks", async () => { + const { + contract, + quote, + bridgeMock, + usedLp, + quoteHash, + collateralManagement, + } = await loadFixture(paidPegOutFixture); + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds: 100, + nConfirmationSeconds: 600, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(2); + const btcTx = await generateRawTx(contract, quote); + await mineUpTo(BigInt(quote.expireBlock) + 1n); + + const tx = contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ); + await expect(tx) + .to.emit(contract, "PegOutRefunded") + .withArgs(getBytes(quoteHash)); + await expect(tx).not.to.emit(contract, "DaoContribution"); + await expect(tx) + .to.emit(collateralManagement, "Penalized") + .withArgs( + usedLp.address, + getBytes(quoteHash), + ProviderType.PegOut, + quote.penaltyFee + ); + }); + + it("execute refund successfully and penalize for sending btc after expected first confirmation", async () => { + const { + contract, + quote, + bridgeMock, + usedLp, + quoteHash, + collateralManagement, + } = await loadFixture(paidPegOutFixture); + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + + const firstConfirmationSeconds = + Number(quote.transferTime) + PEGOUT_CONSTANTS.TEST_BTC_BLOCK_TIME + 500; + const { firstConfirmationHeader } = getBtcPaymentBlockHeaders({ + quote: quote, + firstConfirmationSeconds, + nConfirmationSeconds: firstConfirmationSeconds * 2, + }); + await bridgeMock.setHeaderByHash(blockHeaderHash, firstConfirmationHeader); + await bridgeMock.setConfirmations(2); + const btcTx = await generateRawTx(contract, quote); + + const tx = contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ); + await expect(tx) + .to.emit(contract, "PegOutRefunded") + .withArgs(getBytes(quoteHash)); + await expect(tx).not.to.emit(contract, "DaoContribution"); + await expect(tx) + .to.emit(collateralManagement, "Penalized") + .withArgs( + usedLp.address, + getBytes(quoteHash), + ProviderType.PegOut, + quote.penaltyFee + ); + }); + + it("revert if it can't extract the firstConfirmationHeader", async () => { + const { contract, quote, bridgeMock, usedLp, quoteHash } = + await loadFixture(paidPegOutFixture); + const { blockHeaderHash, partialMerkleTree, merkleBranchHashes } = + getTestMerkleProof(); + await bridgeMock.setHeaderByHash(blockHeaderHash, "0x"); + await bridgeMock.setConfirmations(2); + const btcTx = await generateRawTx(contract, quote); + + await expect( + contract + .connect(usedLp) + .refundPegOut( + getBytes(quoteHash), + btcTx, + blockHeaderHash, + partialMerkleTree, + merkleBranchHashes + ) + ) + .to.be.revertedWithCustomError(contract, "EmptyBlockHeader") + .withArgs(getBytes(blockHeaderHash)); + }); +}); diff --git a/test/pegout/user-refund.test.ts b/test/pegout/user-refund.test.ts new file mode 100644 index 00000000..ec76470a --- /dev/null +++ b/test/pegout/user-refund.test.ts @@ -0,0 +1,224 @@ +import { + loadFixture, + mine, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { deployPegOutContractFixture } from "./fixtures"; +import { getTestPegoutQuote, totalValue } from "../utils/quotes"; +import { ethers } from "hardhat"; +import { getBytes } from "ethers"; +import { expect } from "chai"; +import { matchSelector, matchAnyNumber } from "../utils/matchers"; +import { ProviderType } from "../utils/constants"; + +describe("PegOutContract refundUserPegOut function should", () => { + const BLOCKS_UNTIL_EXPIRATION = 50; + const SECONDS_UNTIL_EXPIRATION = 20000; + it("revert if quote was not paid", async () => { + const { contract, signers, fullLp } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: user.address, + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + const quoteHash = await contract.hashPegOutQuote(quote); + await expect(contract.refundUserPegOut(getBytes(quoteHash))) + .to.be.revertedWithCustomError(contract, "QuoteNotFound") + .withArgs(getBytes(quoteHash)); + }); + + it("revert if quote is not expired by blocks", async () => { + const { contract, signers, fullLp } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: user.address, + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + + const latestBlock = await ethers.provider.getBlock("latest"); + quote.expireDate = (latestBlock?.timestamp ?? 0) + SECONDS_UNTIL_EXPIRATION; + quote.expireBlock = (latestBlock?.number ?? 0) + BLOCKS_UNTIL_EXPIRATION; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await expect( + contract.depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + await mine(2, { interval: SECONDS_UNTIL_EXPIRATION + 1 }); + await expect(contract.refundUserPegOut(getBytes(quoteHash))) + .to.be.revertedWithCustomError(contract, "QuoteNotExpired") + .withArgs(getBytes(quoteHash)); + }); + + it("revert if quote is not expired by date", async () => { + const { contract, signers, fullLp } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: user.address, + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + + const latestBlock = await ethers.provider.getBlock("latest"); + quote.expireBlock = (latestBlock?.number ?? 0) + BLOCKS_UNTIL_EXPIRATION; + quote.expireDate = (latestBlock?.timestamp ?? 0) + SECONDS_UNTIL_EXPIRATION; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + await expect( + contract.depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + await mine(BLOCKS_UNTIL_EXPIRATION + 3, { interval: 1 }); + await expect(contract.refundUserPegOut(getBytes(quoteHash))) + .to.be.revertedWithCustomError(contract, "QuoteNotExpired") + .withArgs(getBytes(quoteHash)); + }); + + it("revert if the payment to the user fails", async () => { + const { contract, signers, fullLp } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const PegOutChangeReceiver = await ethers.deployContract( + "PegOutChangeReceiver" + ); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: await PegOutChangeReceiver.getAddress(), + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + + const latestBlock = await ethers.provider.getBlock("latest"); + quote.expireDate = (latestBlock?.timestamp ?? 0) + SECONDS_UNTIL_EXPIRATION; + quote.expireBlock = (latestBlock?.number ?? 0) + BLOCKS_UNTIL_EXPIRATION; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + + await expect(PegOutChangeReceiver.setFail(true)).not.to.be.reverted; + + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + + await mine(BLOCKS_UNTIL_EXPIRATION + 1, { + interval: SECONDS_UNTIL_EXPIRATION / BLOCKS_UNTIL_EXPIRATION + 1, + }); + + await expect(contract.connect(user).refundUserPegOut(getBytes(quoteHash))) + .to.be.revertedWithCustomError(contract, "PaymentFailed") + .withArgs( + quote.rskRefundAddress, + matchAnyNumber, + matchSelector(PegOutChangeReceiver.interface, "SomeError") + ); + }); + + it("not allow reentrancy", async () => { + const { contract, signers, fullLp } = await loadFixture( + deployPegOutContractFixture + ); + const user = signers[0]; + const PegOutChangeReceiver = await ethers.deployContract( + "PegOutChangeReceiver" + ); + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: await PegOutChangeReceiver.getAddress(), + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + + const latestBlock = await ethers.provider.getBlock("latest"); + quote.expireDate = (latestBlock?.timestamp ?? 0) + 20; + quote.expireBlock = (latestBlock?.number ?? 0) + 5; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + + await expect(PegOutChangeReceiver.setFail(false)).not.to.be.reverted; + await expect(PegOutChangeReceiver.setPegOut(quote, signature)).not.to.be + .reverted; + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalValue(quote) }) + ).not.to.be.reverted; + + await mine(5, { interval: 20 }); + + await expect(contract.connect(user).refundUserPegOut(getBytes(quoteHash))) + .to.be.revertedWithCustomError(contract, "PaymentFailed") + .withArgs( + quote.rskRefundAddress, + matchAnyNumber, + matchSelector(contract.interface, "ReentrancyGuardReentrantCall") + ); + }); + + it("execute the refund and slash the liquidity provider", async () => { + const { contract, signers, fullLp, collateralManagement } = + await loadFixture(deployPegOutContractFixture); + const user = signers[0]; + const quote = getTestPegoutQuote({ + lbcAddress: await contract.getAddress(), + refundAddress: user.address, + liquidityProvider: fullLp, + value: ethers.parseEther("1"), + }); + const totalQuoteValue = totalValue(quote); + + const latestBlock = await ethers.provider.getBlock("latest"); + quote.expireDate = (latestBlock?.timestamp ?? 0) + SECONDS_UNTIL_EXPIRATION; + quote.expireBlock = (latestBlock?.number ?? 0) + BLOCKS_UNTIL_EXPIRATION; + const quoteHash = await contract.hashPegOutQuote(quote); + const signature = await fullLp.signMessage(getBytes(quoteHash)); + + await expect( + contract + .connect(user) + .depositPegOut(quote, signature, { value: totalQuoteValue }) + ).not.to.be.reverted; + + await mine(BLOCKS_UNTIL_EXPIRATION + 1, { + interval: SECONDS_UNTIL_EXPIRATION / BLOCKS_UNTIL_EXPIRATION + 1, + }); + + const tx = contract.connect(user).refundUserPegOut(getBytes(quoteHash)); + + await expect(tx) + .to.emit(contract, "PegOutUserRefunded") + .withArgs(getBytes(quoteHash), quote.rskRefundAddress, totalQuoteValue); + await expect(tx, "Should call collateral management") + .to.emit(collateralManagement, "Penalized") + .withArgs( + fullLp.address, + getBytes(quoteHash), + ProviderType.PegOut, + quote.penaltyFee + ); + await expect(tx).to.changeEtherBalances( + [await contract.getAddress(), user.address], + [-totalQuoteValue, totalQuoteValue] + ); + await expect( + contract.isQuoteCompleted(getBytes(quoteHash)), + "Should mark quote as completed" + ).to.be.eventually.true; + await expect( + contract.connect(user).refundUserPegOut(getBytes(quoteHash)), + "Should remove quote from storage" + ) + .to.be.revertedWithCustomError(contract, "QuoteNotFound") + .withArgs(getBytes(quoteHash)); + }); +}); diff --git a/test/utils/btc.ts b/test/utils/btc.ts index e340a797..d66d0beb 100644 --- a/test/utils/btc.ts +++ b/test/utils/btc.ts @@ -1,17 +1,24 @@ import { bech32, bech32m } from "bech32"; import * as bs58check from "bs58check"; import { BytesLike, hexlify } from "ethers"; -import { LiquidityBridgeContractV2, QuotesV2 } from "../../typechain-types"; +import { + LiquidityBridgeContractV2, + PegOutContract, + QuotesV2, +} from "../../typechain-types"; import { toLeHex } from "./encoding"; +import { Quotes } from "../../typechain-types/contracts/libraries"; export type BtcAddressType = "p2pkh" | "p2sh" | "p2wpkh" | "p2wsh" | "p2tr"; -const WEI_TO_SAT_CONVERSION = 10n ** 10n; +export const WEI_TO_SAT_CONVERSION = 10n ** 10n; export const weiToSat = (wei: bigint) => wei % WEI_TO_SAT_CONVERSION === 0n ? wei / WEI_TO_SAT_CONVERSION : wei / WEI_TO_SAT_CONVERSION + 1n; +export const satToWei = (sat: bigint) => sat * WEI_TO_SAT_CONVERSION; + export function getTestBtcAddress(addressType: BtcAddressType): BytesLike { switch (addressType) { case "p2pkh": @@ -49,14 +56,31 @@ export function getTestBtcAddress(addressType: BtcAddressType): BytesLike { * @returns { Promise } The raw BTC transaction */ export async function generateRawTx( - lbc: LiquidityBridgeContractV2, - quote: QuotesV2.PegOutQuoteStruct, - scriptType: BtcAddressType = "p2pkh" + lbc: Partial<{ + hashPegoutQuote: LiquidityBridgeContractV2["hashPegoutQuote"]; + hashPegOutQuote: PegOutContract["hashPegOutQuote"]; + }>, + quote: QuotesV2.PegOutQuoteStruct & Quotes.PegOutQuoteStruct, + opts: { + scriptType: BtcAddressType; + amountOverride?: bigint; + addressOverride?: BytesLike; + } = { scriptType: "p2pkh" } ) { - const quoteHash = await lbc.hashPegoutQuote(quote); + let quoteHash: BytesLike; + let addressBytes: Uint8Array; + if (lbc.hashPegoutQuote) { + quoteHash = await lbc.hashPegoutQuote(quote); + addressBytes = quote.deposityAddress as Uint8Array; + } else { + quoteHash = (await lbc.hashPegOutQuote?.(quote)) ?? "0x"; + addressBytes = quote.depositAddress as Uint8Array; + } + if (opts.addressOverride) { + addressBytes = opts.addressOverride as Uint8Array; + } let outputScript: number[]; - const addressBytes = quote.deposityAddress as Uint8Array; - switch (scriptType) { + switch (opts.scriptType) { case "p2pkh": outputScript = [0x76, 0xa9, 0x14, ...addressBytes.slice(1), 0x88, 0xac]; break; @@ -77,7 +101,12 @@ export async function generateRawTx( } const outputScriptFragment = hexlify(new Uint8Array(outputScript)).slice(2); const outputSize = (outputScriptFragment.length / 2).toString(16); - const amount = toLeHex(weiToSat(BigInt(quote.value))).padEnd(16, "0"); + let amount: string; + if (opts.amountOverride) { + amount = toLeHex(opts.amountOverride).padEnd(16, "0"); + } else { + amount = toLeHex(weiToSat(BigInt(quote.value))).padEnd(16, "0"); + } const btcTx = `0x0100000001013503c427ba46058d2d8ac9221a2f6fd50734a69f19dae65420191e3ada2d40000000006a47304402205d047dbd8c49aea5bd0400b85a57b2da7e139cec632fb138b7bee1d382fd70ca02201aa529f59b4f66fdf86b0728937a91a40962aedd3f6e30bce5208fec0464d54901210255507b238c6f14735a7abe96a635058da47b05b61737a610bef757f009eea2a4ffffffff02${amount}${outputSize}${outputScriptFragment}0000000000000000226a20${quoteHash.slice( 2 )}00000000`; diff --git a/test/utils/constants.ts b/test/utils/constants.ts index f892d002..d9cd4b16 100644 --- a/test/utils/constants.ts +++ b/test/utils/constants.ts @@ -2,6 +2,8 @@ import { ethers } from "hardhat"; import { LiquidityBridgeContractV2 } from "../../typechain-types"; import * as bs58check from "bs58check"; +export const BRIDGE_ADDRESS = "0x0000000000000000000000000000000001000006"; + export const LP_COLLATERAL = ethers.parseEther("1.5"); export const MIN_COLLATERAL_TEST = ethers.parseEther("0.03"); @@ -36,3 +38,14 @@ export const REGISTER_LP_PARAMS: RegisterLpParams = [ "both", { value: LP_COLLATERAL }, ]; + +export const PEGOUT_CONSTANTS = { + TEST_DUST_THRESHOLD: ethers.parseEther("0.0000001"), + TEST_BTC_BLOCK_TIME: 3600, +} as const; + +export enum ProviderType { + PegIn, + PegOut, + Both, +} diff --git a/test/utils/matchers.ts b/test/utils/matchers.ts new file mode 100644 index 00000000..713d321b --- /dev/null +++ b/test/utils/matchers.ts @@ -0,0 +1,8 @@ +import { Interface } from "ethers"; + +export const matchAnyNumber = (value: unknown) => + typeof value === "bigint" || typeof value === "number"; + +export const matchSelector = + (iface: Interface, error: string) => (value: unknown) => + value === iface.getError(error)?.selector; diff --git a/test/utils/quotes.ts b/test/utils/quotes.ts index 1bf1db14..772d6dda 100644 --- a/test/utils/quotes.ts +++ b/test/utils/quotes.ts @@ -9,6 +9,7 @@ import { randomBytes } from "crypto"; import { BigNumberish, BytesLike } from "ethers"; import { toLeHex } from "./encoding"; import { BtcAddressType, getTestBtcAddress } from "./btc"; +import { Quotes } from "../../typechain-types/contracts/libraries"; const now = () => Math.floor(Date.now() / 1000); // ms to s @@ -57,14 +58,15 @@ export function getTestPegoutQuote(args: { value: BigNumberish; destinationAddressType?: BtcAddressType; productFeePercentage?: number; -}): QuotesV2.PegOutQuoteStruct { +}): QuotesV2.PegOutQuoteStruct & Quotes.PegOutQuoteStruct { // TODO if at some point DAO integration is re activated, this default value should be updated to not be 0 const productFeePercentage = args.productFeePercentage ?? 0; const productFee = (BigInt(productFeePercentage) * BigInt(args.value)) / 100n; const destinationAddressType = args.destinationAddressType ?? "p2pkh"; const nowTimestamp = now(); - const quote: QuotesV2.PegOutQuoteStruct = { + // TODO this is to support both legacy and new test suite, once we adapt the legacy suite we can remove this union + const quote: QuotesV2.PegOutQuoteStruct & Quotes.PegOutQuoteStruct = { lbcAddress: args.lbcAddress, lpRskAddress: args.liquidityProvider.address, btcRefundAddress: DECODED_P2PKH_ZERO_ADDRESS_TESTNET, @@ -73,6 +75,7 @@ export function getTestPegoutQuote(args: { callFee: "100000000000000", penaltyFee: "10000000000000", deposityAddress: getTestBtcAddress(destinationAddressType), + depositAddress: getTestBtcAddress(destinationAddressType), nonce: BigInt("0x" + randomBytes(7).toString("hex")), value: args.value, agreementTimestamp: nowTimestamp,