diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 028642ae3..21b3cf8b7 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -243,6 +243,7 @@ contract Bridge is address oldRebateStaking, address newRebateStaking ); + event PegKeeperUpdated(address pegKeeper, bool allowed); /// @notice Emitted when a deposit's vault field is corrected via governance. /// @dev This event is used for transparency when fixing deposits that were @@ -357,6 +358,8 @@ contract Bridge is /// keccak256(fundingTxHash || fundingOutputIndex) where: /// - fundingTxHash (little-endian): 0x7ee3fcd03309745af5f35f07572b68affb3f551d8de70b194773644a47c9bb9c /// - fundingOutputIndex: 1 + // Upgrade-only guards omit revert strings to keep Bridge under the size cap. + // solhint-disable reason-string function initializeV2_FixVaultZeroDeposit() external reinitializer(2) { // Deposit key: keccak256(fundingTxHash || fundingOutputIndex) uint256 depositKey = 0xf3bc9cd6f46f4c206bc8711e40bb5692e8fe5f0ac4d4da0a709dc71bb751c98a; @@ -366,9 +369,9 @@ contract Bridge is // Safety checks Deposit.DepositRequest storage deposit = self.deposits[depositKey]; - require(deposit.revealedAt != 0, "Deposit not revealed"); - require(deposit.vault == address(0), "Vault already set"); - require(deposit.sweptAt == 0, "Deposit already swept"); + require(deposit.revealedAt != 0); + require(deposit.vault == address(0)); + require(deposit.sweptAt == 0); // Fix the vault deposit.vault = tbtcVault; @@ -390,16 +393,10 @@ contract Bridge is { address oldRebateStaking = self.rebateStaking; - require( - oldRebateStaking != newRebateStaking, - "Rebate staking unchanged" - ); + require(oldRebateStaking != newRebateStaking); if (newRebateStaking != address(0)) { - require( - newRebateStaking.code.length > 0, - "Rebate staking must be a contract" - ); + require(newRebateStaking.code.length > 0); } self.rebateStaking = newRebateStaking; @@ -407,6 +404,40 @@ contract Bridge is emit RebateStakingRepaired(oldRebateStaking, newRebateStaking); } + // solhint-enable reason-string + + /// @notice Configures an initial peg keeper during a proxy upgrade. + /// @param initialPegKeeper The initial peg keeper address. + /// @dev Uses reinitializer(6) to allow a one-time configuration of the peg + /// keeper. This function can only be called once per proxy deployment. + function initializeV6_ConfigurePegKeeper(address initialPegKeeper) + external + reinitializer(6) + { + // solhint-disable-next-line no-inline-assembly + assembly { + if or( + iszero(initialPegKeeper), + iszero( + eq( + caller(), + sload( + 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 + ) + ) + ) + ) { + revert(0, 0) + } + } + + self.updatePegKeeper(initialPegKeeper, true); + + emit RebateStakingRepaired(self.rebateStaking, address(0)); + self.rebateStakingDisabled = true; + self.rebateStaking = address(0); + } + /// @notice Used by the depositor to reveal information about their P2(W)SH /// Bitcoin deposit to the Bridge on Ethereum chain. The off-chain /// wallet listens for revealed deposit events and may decide to @@ -1611,6 +1642,17 @@ contract Bridge is self.updateTreasury(treasury); } + /// @notice Updates a peg keeper status. Peg keepers are exempt from + /// deposit and redemption treasury fees. + /// @param pegKeeper Peg keeper address. + /// @param allowed True if the address should be allowed, false otherwise. + function updatePegKeeper(address pegKeeper, bool allowed) + external + onlyGovernance + { + self.updatePegKeeper(pegKeeper, allowed); + } + /// @notice Collection of all revealed deposits indexed by /// keccak256(fundingTxHash | fundingOutputIndex). /// The fundingTxHash is bytes32 (ordered as in Bitcoin internally) @@ -2037,6 +2079,11 @@ contract Bridge is return self.rebateStaking; } + /// @return True if the address is allowed as a peg keeper. + function isPegKeeper(address pegKeeper) external view returns (bool) { + return self.pegKeepers[pegKeeper]; + } + /// @notice Sets the redemption watchtower address. /// @param redemptionWatchtower Address of the redemption watchtower. /// @dev Requirements: diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index b1a6f206a..4f93bff45 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -32,6 +32,7 @@ contract BridgeGovernance is Ownable { using BridgeGovernanceParameters for BridgeGovernanceParameters.WalletData; using BridgeGovernanceParameters for BridgeGovernanceParameters.FraudData; using BridgeGovernanceParameters for BridgeGovernanceParameters.TreasuryData; + using BridgeGovernanceParameters for BridgeGovernanceParameters.PegKeeperData; BridgeGovernanceParameters.DepositData internal depositData; BridgeGovernanceParameters.RedemptionData internal redemptionData; @@ -39,6 +40,7 @@ contract BridgeGovernance is Ownable { BridgeGovernanceParameters.WalletData internal walletData; BridgeGovernanceParameters.FraudData internal fraudData; BridgeGovernanceParameters.TreasuryData internal treasuryData; + BridgeGovernanceParameters.PegKeeperData internal pegKeeperData; Bridge internal bridge; @@ -287,6 +289,13 @@ contract BridgeGovernance is Ownable { event TreasuryUpdateStarted(address newTreasury, uint256 timestamp); event TreasuryUpdated(address treasury); + event PegKeeperUpdateStarted( + address pegKeeper, + bool allowed, + uint256 timestamp + ); + event PegKeeperUpdateCanceled(address pegKeeper, bool allowed); + event PegKeeperUpdated(address pegKeeper, bool allowed); constructor(Bridge _bridge, uint256 _governanceDelay) { bridge = _bridge; @@ -1762,6 +1771,33 @@ contract BridgeGovernance is Ownable { bridge.updateTreasury(newTreasury); } + /// @notice Begins the peg keeper status update process. + /// @dev Can be called only by the contract owner. + /// @param _pegKeeper Peg keeper address. + /// @param _allowed True if the address should be allowed, false otherwise. + function beginPegKeeperUpdate(address _pegKeeper, bool _allowed) + external + onlyOwner + { + pegKeeperData.beginPegKeeperUpdate(_pegKeeper, _allowed); + } + + /// @notice Finalizes the peg keeper status update process. + /// @dev Can be called only by the contract owner, after the governance + /// delay elapses. + function finalizePegKeeperUpdate() external onlyOwner { + address pegKeeper = pegKeeperData.pegKeeper; + bool allowed = pegKeeperData.allowed; + pegKeeperData.finalizePegKeeperUpdate(governanceDelay()); + bridge.updatePegKeeper(pegKeeper, allowed); + } + + /// @notice Cancels a pending peg keeper status update. + /// @dev Can be called only by the contract owner. + function cancelPegKeeperUpdate() external onlyOwner { + pegKeeperData.cancelPegKeeperUpdate(); + } + /// @notice Gets the governance delay parameter. function governanceDelay() internal view returns (uint256) { return governanceDelays[0]; diff --git a/solidity/contracts/bridge/BridgeGovernanceParameters.sol b/solidity/contracts/bridge/BridgeGovernanceParameters.sol index dfbcf8c0f..1bbebe645 100644 --- a/solidity/contracts/bridge/BridgeGovernanceParameters.sol +++ b/solidity/contracts/bridge/BridgeGovernanceParameters.sol @@ -22,6 +22,12 @@ library BridgeGovernanceParameters { uint256 treasuryChangeInitiated; } + struct PegKeeperData { + address pegKeeper; + bool allowed; + uint256 pegKeeperChangeInitiated; + } + struct DepositData { uint64 newDepositDustThreshold; uint256 depositDustThresholdChangeInitiated; @@ -330,6 +336,13 @@ library BridgeGovernanceParameters { event TreasuryUpdateStarted(address newTreasury, uint256 timestamp); event TreasuryUpdated(address treasury); + event PegKeeperUpdateStarted( + address pegKeeper, + bool allowed, + uint256 timestamp + ); + event PegKeeperUpdateCanceled(address pegKeeper, bool allowed); + event PegKeeperUpdated(address pegKeeper, bool allowed); /// @notice Reverts if called before the governance delay elapses. /// @param changeInitiatedTimestamp Timestamp indicating the beginning @@ -1571,4 +1584,55 @@ library BridgeGovernanceParameters { self.newTreasury = address(0); self.treasuryChangeInitiated = 0; } + + /// @notice Begins the peg keeper status update process. + /// @param _pegKeeper Peg keeper address. + /// @param _allowed True if the address should be allowed, false otherwise. + function beginPegKeeperUpdate( + PegKeeperData storage self, + address _pegKeeper, + bool _allowed + ) external { + require( + self.pegKeeperChangeInitiated == 0, + "Peg keeper update already initiated" + ); + + /* solhint-disable not-rely-on-time */ + self.pegKeeper = _pegKeeper; + self.allowed = _allowed; + self.pegKeeperChangeInitiated = block.timestamp; + emit PegKeeperUpdateStarted(_pegKeeper, _allowed, block.timestamp); + /* solhint-enable not-rely-on-time */ + } + + /// @notice Finalizes the peg keeper status update process. + /// @dev Can be called after the governance delay elapses. + function finalizePegKeeperUpdate( + PegKeeperData storage self, + uint256 governanceDelay + ) + external + onlyAfterGovernanceDelay(self.pegKeeperChangeInitiated, governanceDelay) + { + emit PegKeeperUpdated(self.pegKeeper, self.allowed); + + self.pegKeeper = address(0); + self.allowed = false; + self.pegKeeperChangeInitiated = 0; + } + + /// @notice Cancels a pending peg keeper status update. + function cancelPegKeeperUpdate(PegKeeperData storage self) internal { + require( + self.pegKeeperChangeInitiated != 0, + "Peg keeper update not initiated" + ); + + emit PegKeeperUpdateCanceled(self.pegKeeper, self.allowed); + + self.pegKeeper = address(0); + self.allowed = false; + self.pegKeeperChangeInitiated = 0; + } } diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index 648e11069..4a770bc2f 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -325,6 +325,11 @@ library BridgeState { // governance wiring; changing it afterwards requires a dedicated // upgrade path of the Bridge implementation. address rebateStaking; + // Permanently disables one-time rebate staking wiring. + bool rebateStakingDisabled; + // DAO-designated peg keepers exempt from deposit and redemption + // treasury fees. + mapping(address => bool) pegKeepers; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are @@ -332,7 +337,7 @@ library BridgeState { // the struct in the upcoming versions we need to reduce the array size. // See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps // slither-disable-next-line unused-state - uint256[48] __gap; + uint256[47] __gap; } event DepositParametersUpdated( @@ -385,6 +390,8 @@ library BridgeState { event TreasuryUpdated(address treasury); + event PegKeeperUpdated(address pegKeeper, bool allowed); + event RedemptionWatchtowerSet(address redemptionWatchtower); // Event emitted when the rebate staking address is initialized. Declared @@ -846,6 +853,19 @@ library BridgeState { emit TreasuryUpdated(_treasury); } + /// @notice Updates a peg keeper status. Peg keepers are exempt from + /// deposit and redemption treasury fees. + /// @param _pegKeeper Peg keeper address. + /// @param _allowed True if the address should be allowed, false otherwise. + function updatePegKeeper( + Storage storage self, + address _pegKeeper, + bool _allowed + ) internal { + self.pegKeepers[_pegKeeper] = _allowed; + emit PegKeeperUpdated(_pegKeeper, _allowed); + } + /// @notice Sets the redemption watchtower address. /// @param _redemptionWatchtower Address of the redemption watchtower. /// @dev Requirements: @@ -882,6 +902,8 @@ library BridgeState { function setRebateStaking(Storage storage self, address _rebateStaking) internal { + require(!self.rebateStakingDisabled, "Rebate staking disabled"); + require(self.rebateStaking == address(0), "Rebate staking already set"); require( diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 6086f2d4e..88c2e0d0a 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -343,13 +343,17 @@ library Deposit { : 0; deposit.extraData = extraData; - if (deposit.treasuryFee > 0 && self.rebateStaking != address(0)) { - deposit.treasuryFee = RebateStaking(self.rebateStaking) - .applyForRebate( - deposit.depositor, - deposit.treasuryFee, - RebateStaking.TreasuryFeeType.Deposit - ); + if (deposit.treasuryFee > 0) { + if (self.pegKeepers[deposit.depositor]) { + deposit.treasuryFee = 0; + } else if (self.rebateStaking != address(0)) { + deposit.treasuryFee = RebateStaking(self.rebateStaking) + .applyForRebate( + deposit.depositor, + deposit.treasuryFee, + RebateStaking.TreasuryFeeType.Deposit + ); + } } _emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal); diff --git a/solidity/contracts/bridge/RebateStaking.sol b/solidity/contracts/bridge/RebateStaking.sol index 8f11d402f..b98d7a95a 100644 --- a/solidity/contracts/bridge/RebateStaking.sol +++ b/solidity/contracts/bridge/RebateStaking.sol @@ -20,6 +20,7 @@ import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeab import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StorageSlotUpgradeable.sol"; /// @title Contract for staking T token to get rebate on minting/redemption fees contract RebateStaking is Initializable, OwnableUpgradeable { @@ -37,6 +38,9 @@ contract RebateStaking is Initializable, OwnableUpgradeable { error NotAStaker(); error WrongDelegatee(); error AddressAlreadyTaken(); + error ContractDeprecated(); + error RebateStakingNotDeprecated(); + error CallerNotProxyAdmin(); enum RebateTreasuryFeeMode { Both, @@ -49,6 +53,9 @@ contract RebateStaking is Initializable, OwnableUpgradeable { Redemption } + bytes32 internal constant EIP_1967_ADMIN_SLOT = + 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + struct Rebate { uint32 timestamp; uint64 feeRebate; @@ -81,6 +88,7 @@ contract RebateStaking is Initializable, OwnableUpgradeable { mapping(address => Stake) public stakes; mapping(address => address) public delegates; + bool public deprecated; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should @@ -89,7 +97,7 @@ contract RebateStaking is Initializable, OwnableUpgradeable { // the struct in the upcoming versions we need to reduce the array size. // See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps // slither-disable-next-line unused-state - uint256[49] private __gap; + uint256[48] private __gap; event RollingWindowUpdated(uint256 rollingWindow); event UnstakingPeriodUpdated(uint256 unstakingPeriod); @@ -103,8 +111,10 @@ contract RebateStaking is Initializable, OwnableUpgradeable { event Staked(address staker, uint256 amount); event UnstakeStarted(address staker, uint256 amount); event UnstakeFinished(address staker, uint256 amount); + event StakeWithdrawn(address staker, address receiver, uint256 amount); event DelegateeSet(address staker, address delegatee); event TransferFinished(address oldStaker, address newStaker); + event RebateStakingDeprecated(); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -116,6 +126,11 @@ contract RebateStaking is Initializable, OwnableUpgradeable { _; } + modifier onlyWhenActive() { + if (deprecated) revert ContractDeprecated(); + _; + } + /// @dev Initializes upgradable contract on deployment. function initialize( address _bridge, @@ -138,12 +153,28 @@ contract RebateStaking is Initializable, OwnableUpgradeable { __Ownable_init(); } + /// @notice Permanently deprecates rebate staking during a proxy upgrade. + /// @dev Intended for ProxyAdmin.upgradeAndCall so the upgrade and + /// deprecation happen atomically through the timelock-controlled + /// proxy admin. Can be called only by the proxy admin. + // solhint-disable-next-line func-name-mixedcase + function initializeV2_Deprecate() external reinitializer(2) { + if (msg.sender != proxyAdmin()) revert CallerNotProxyAdmin(); + if (deprecated) return; + + _deprecate(); + } + /// @notice Updates the rolling window. /// @param _newRollingWindow Duration of the rolling window. /// @dev Requirements: /// - The caller must be the contract owner, /// - The new rolling window cannot be zero - function updateRollingWindow(uint256 _newRollingWindow) external onlyOwner { + function updateRollingWindow(uint256 _newRollingWindow) + external + onlyOwner + onlyWhenActive + { if (_newRollingWindow == 0) revert RollingWindowCannotBeZero(); rollingWindow = _newRollingWindow; emit RollingWindowUpdated(rollingWindow); @@ -156,6 +187,7 @@ contract RebateStaking is Initializable, OwnableUpgradeable { function updateUnstakingPeriod(uint256 _newUnstakingPeriod) external onlyOwner + onlyWhenActive { unstakingPeriod = _newUnstakingPeriod; emit UnstakingPeriodUpdated(unstakingPeriod); @@ -168,11 +200,20 @@ contract RebateStaking is Initializable, OwnableUpgradeable { function updateRebatePerToken(uint256 _newRebatePerToken) external onlyOwner + onlyWhenActive { rebatePerToken = _newRebatePerToken; emit RebatePerTokenUpdated(rebatePerToken); } + /// @notice Permanently deprecates rebate staking. + /// @dev Stops new rebates and staking, sets the unstaking period to zero + /// so existing unstaking processes can finish immediately, and sets + /// the rebate coefficient to zero so no rebate capacity remains. + function deprecate() external onlyOwner { + _deprecate(); + } + /// @notice Sets the rebate treasury fee mode for caller. /// @param _rebateTreasuryFeeMode New rebate treasury fee mode: /// - 0: rebates for both deposits and redemptions, @@ -313,6 +354,10 @@ contract RebateStaking is Initializable, OwnableUpgradeable { uint64 treasuryFee, TreasuryFeeType treasuryFeeType ) external onlyBridge returns (uint64) { + if (deprecated) { + return treasuryFee; + } + user = getStaker(user); Stake storage stakeInfo = stakes[user]; @@ -409,6 +454,7 @@ contract RebateStaking is Initializable, OwnableUpgradeable { /// @param amount Amount of tokens to stake function stake(uint96 amount) external { if (amount == 0) revert AmountCannotBeZero(); + if (deprecated) revert ContractDeprecated(); address otherStaker = delegates[msg.sender]; if (otherStaker != address(0)) { @@ -461,6 +507,45 @@ contract RebateStaking is Initializable, OwnableUpgradeable { token.safeTransfer(receiver, amount); } + /// @notice Withdraws the caller's entire stake after rebate staking is + /// deprecated. + /// @param receiver Address of stake receiver. + function withdrawStake(address receiver) external { + if (!deprecated) revert RebateStakingNotDeprecated(); + if (receiver == address(0)) revert ZeroAddress(); + + Stake storage stakeInfo = stakes[msg.sender]; + uint96 amount = stakeInfo.stakedAmount; + if (amount == 0) revert NotAStaker(); + + stakeInfo.stakedAmount = 0; + stakeInfo.unstakingAmount = 0; + stakeInfo.unstakingTimestamp = 0; + if (stakeInfo.delegatee != address(0)) { + delegates[stakeInfo.delegatee] = address(0); + stakeInfo.delegatee = address(0); + } + + emit StakeWithdrawn(msg.sender, receiver, amount); + token.safeTransfer(receiver, amount); + } + + function _deprecate() internal { + if (deprecated) revert ContractDeprecated(); + + deprecated = true; + unstakingPeriod = 0; + rebatePerToken = 0; + + emit RebateStakingDeprecated(); + emit UnstakingPeriodUpdated(0); + emit RebatePerTokenUpdated(0); + } + + function proxyAdmin() internal view returns (address admin) { + return StorageSlotUpgradeable.getAddressSlot(EIP_1967_ADMIN_SLOT).value; + } + /// @notice Returns size of rebate array /// @param user Address of depositor or redeemer function getRebateLength(address user) external view returns (uint256) { diff --git a/solidity/contracts/bridge/Redemption.sol b/solidity/contracts/bridge/Redemption.sol index ae7905dc0..2b1484709 100644 --- a/solidity/contracts/bridge/Redemption.sol +++ b/solidity/contracts/bridge/Redemption.sol @@ -538,12 +538,16 @@ library Redemption { uint64 treasuryFee = self.redemptionTreasuryFeeDivisor > 0 ? amount / self.redemptionTreasuryFeeDivisor : 0; - if (treasuryFee > 0 && self.rebateStaking != address(0)) { - treasuryFee = RebateStaking(self.rebateStaking).applyForRebate( - redeemer, - treasuryFee, - RebateStaking.TreasuryFeeType.Redemption - ); + if (treasuryFee > 0) { + if (balanceOwner == redeemer && self.pegKeepers[redeemer]) { + treasuryFee = 0; + } else if (self.rebateStaking != address(0)) { + treasuryFee = RebateStaking(self.rebateStaking).applyForRebate( + redeemer, + treasuryFee, + RebateStaking.TreasuryFeeType.Redemption + ); + } } uint64 txMaxFee = self.redemptionTxMaxFee; diff --git a/solidity/test/bridge/Bridge.PegKeeper.test.ts b/solidity/test/bridge/Bridge.PegKeeper.test.ts new file mode 100644 index 000000000..36a79b28c --- /dev/null +++ b/solidity/test/bridge/Bridge.PegKeeper.test.ts @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import { ethers, helpers, upgrades, waffle } from "hardhat" +import { expect } from "chai" +import { BigNumber, BigNumberish } from "ethers" +import { BytesLike } from "@ethersproject/bytes" +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import type { + Bank, + BankStub, + Bridge, + BridgeGovernance, + BridgeStub, + RebateStaking, +} from "../../typechain" + +import bridgeFixture from "../fixtures/bridge" +import { constants, walletState } from "../fixtures" +import { SingleP2SHDeposit } from "../data/deposit-sweep" + +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { impersonateAccount } = helpers.account +const { increaseTime, lastBlockTime } = helpers.time +const { AddressZero } = ethers.constants + +describe("Bridge - Peg keeper", () => { + let deployer: SignerWithAddress + let governance: SignerWithAddress + let thirdParty: SignerWithAddress + let esdm: SignerWithAddress + + let bank: Bank & BankStub + let bridge: Bridge & BridgeStub + let bridgeGovernance: BridgeGovernance + let rebateStaking: RebateStaking + + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const mainUtxo = { + txHash: + "0x3835ecdee2daa83c9a19b5012104ace55ecab197b5e16489c26d372e475f5d2a", + txOutputIndex: 0, + txOutputValue: 10000000, + } + const redeemerOutputScriptP2WPKH = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + const redeemerOutputScriptP2PKH = + "0x1976a914f4eedc8f40d4b8e30771f792b065ebec0abaddef88ac" + const requestedAmount = BigNumber.from(1901000) + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ deployer, governance, esdm } = await helpers.signers.getNamedSigners()) + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ thirdParty, bank, bridge, bridgeGovernance, rebateStaking } = + await waffle.loadFixture(bridgeFixture)) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + }) + + describe("governance", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("should require owner to begin peg keeper update", async () => { + await expect( + bridgeGovernance + .connect(thirdParty) + .beginPegKeeperUpdate(thirdParty.address, true) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + + it("should begin peg keeper update without applying it", async () => { + const tx = await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.false + + await expect(tx) + .to.emit(bridgeGovernance, "PegKeeperUpdateStarted") + .withArgs(thirdParty.address, true, await lastBlockTime()) + }) + + it("should reject overwriting a pending peg keeper update", async () => { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + await expect( + bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(deployer.address, false) + ).to.be.revertedWith("Peg keeper update already initiated") + }) + + it("should cancel a pending peg keeper update", async () => { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + const tx = await bridgeGovernance + .connect(governance) + .cancelPegKeeperUpdate() + + await expect(tx) + .to.emit(bridgeGovernance, "PegKeeperUpdateCanceled") + .withArgs(thirdParty.address, true) + + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(deployer.address, true) + await increaseTime(constants.governanceDelay) + await bridgeGovernance.connect(governance).finalizePegKeeperUpdate() + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.false + expect(await bridge.isPegKeeper(deployer.address)).to.be.true + }) + + it("should require owner to cancel peg keeper update", async () => { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + await expect( + bridgeGovernance.connect(thirdParty).cancelPegKeeperUpdate() + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + + it("should require a pending peg keeper update to cancel", async () => { + await expect( + bridgeGovernance.connect(governance).cancelPegKeeperUpdate() + ).to.be.revertedWith("Peg keeper update not initiated") + }) + + it("should require governance delay before finalizing peg keeper update", async () => { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + await increaseTime(constants.governanceDelay - 60) + + await expect( + bridgeGovernance.connect(governance).finalizePegKeeperUpdate() + ).to.be.revertedWith("Governance delay has not elapsed") + }) + + it("should finalize peg keeper update", async () => { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, true) + + await increaseTime(constants.governanceDelay) + + const tx = await bridgeGovernance + .connect(governance) + .finalizePegKeeperUpdate() + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.true + + await expect(tx) + .to.emit(bridgeGovernance, "PegKeeperUpdated") + .withArgs(thirdParty.address, true) + await expect(tx) + .to.emit(bridge, "PegKeeperUpdated") + .withArgs(thirdParty.address, true) + }) + + it("should allow removing a peg keeper", async () => { + await setPegKeeper(thirdParty.address) + + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(thirdParty.address, false) + await increaseTime(constants.governanceDelay) + await bridgeGovernance.connect(governance).finalizePegKeeperUpdate() + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.false + }) + + it("should allow multiple peg keepers", async () => { + await setPegKeeper(thirdParty.address) + await setPegKeeper(deployer.address) + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.true + expect(await bridge.isPegKeeper(deployer.address)).to.be.true + + await setPegKeeper(thirdParty.address, false) + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.false + expect(await bridge.isPegKeeper(deployer.address)).to.be.true + }) + }) + + describe("deposits", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("should waive deposit treasury fee for peg keeper", async () => { + const deposit = SingleP2SHDeposit.deposits[0] + const pegKeeper = await impersonateAccount(deposit.depositor, { + from: governance, + value: 10, + }) + + await setPegKeeper(pegKeeper.address) + await setLiveWallet(deposit.reveal.walletPubKeyHash as string) + + await bridge.connect(pegKeeper).revealDeposit( + { + version: deposit.fundingTx.version, + inputVector: deposit.fundingTx.inputVector, + outputVector: deposit.fundingTx.outputVector, + locktime: deposit.fundingTx.locktime, + }, + deposit.reveal + ) + + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [deposit.fundingTx.hash, deposit.reveal.fundingOutputIndex] + ) + + const depositRequest = await bridge.deposits(depositKey) + expect(depositRequest.depositor).to.equal(pegKeeper.address) + expect(depositRequest.treasuryFee).to.equal(0) + }) + }) + + describe("redemptions", () => { + beforeEach(async () => { + await createSnapshot() + + await setLiveWallet(walletPubKeyHash) + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("should waive redemption treasury fee when balance owner and redeemer are the peg keeper", async () => { + await setPegKeeper(thirdParty.address) + await makeRedemptionAllowance(thirdParty, requestedAmount) + + await bridge + .connect(thirdParty) + .requestRedemption( + walletPubKeyHash, + mainUtxo, + redeemerOutputScriptP2WPKH, + requestedAmount + ) + + const redemptionKey = buildRedemptionKey( + walletPubKeyHash, + redeemerOutputScriptP2WPKH + ) + const redemption = await bridge.pendingRedemptions(redemptionKey) + const wallet = await bridge.wallets(walletPubKeyHash) + + expect(redemption.redeemer).to.equal(thirdParty.address) + expect(redemption.treasuryFee).to.equal(0) + expect(wallet.pendingRedemptionsValue).to.equal(requestedAmount) + }) + + it("should not waive redemption treasury fee when only redeemer is the peg keeper", async () => { + await setPegKeeper(thirdParty.address) + + const balanceOwner = deployer + await bank.setBalance(balanceOwner.address, requestedAmount) + + const redemptionData = ethers.utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + thirdParty.address, + walletPubKeyHash, + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue, + redeemerOutputScriptP2PKH, + ] + ) + + await bank + .connect(balanceOwner) + .approveBalanceAndCall(bridge.address, requestedAmount, redemptionData) + + const redemptionKey = buildRedemptionKey( + walletPubKeyHash, + redeemerOutputScriptP2PKH + ) + const redemption = await bridge.pendingRedemptions(redemptionKey) + + expect(redemption.redeemer).to.equal(thirdParty.address) + expect(redemption.treasuryFee).to.equal( + requestedAmount.div(constants.redemptionTreasuryFeeDivisor) + ) + }) + }) + + describe("upgrade initializer", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("should reject initializer from non-proxy-admin", async () => { + await expect( + bridge + .connect(thirdParty) + .initializeV6_ConfigurePegKeeper(thirdParty.address) + ).to.be.reverted + }) + + it("should reject zero address initial peg keeper", async () => { + const bridgeFactory = await getBridgeFactory() + const newImplementation = await bridgeFactory.deploy() + await newImplementation.deployed() + + const proxyAdminWithUpgrade = await getProxyAdminWithUpgrade() + const upgradeData = bridgeFactory.interface.encodeFunctionData( + "initializeV6_ConfigurePegKeeper", + [AddressZero] + ) + + await expect( + proxyAdminWithUpgrade.upgradeAndCall( + bridge.address, + newImplementation.address, + upgradeData + ) + ).to.be.reverted + }) + + it("should configure peg keeper and disable rebate staking", async () => { + await bridgeGovernance + .connect(governance) + .setRebateStaking(rebateStaking.address) + + const bridgeFactory = await getBridgeFactory() + const newImplementation = await bridgeFactory.deploy() + await newImplementation.deployed() + + const proxyAdminWithUpgrade = await getProxyAdminWithUpgrade() + const upgradeData = bridgeFactory.interface.encodeFunctionData( + "initializeV6_ConfigurePegKeeper", + [thirdParty.address] + ) + + const tx = await proxyAdminWithUpgrade.upgradeAndCall( + bridge.address, + newImplementation.address, + upgradeData + ) + + await expect(tx) + .to.emit(bridge, "PegKeeperUpdated") + .withArgs(thirdParty.address, true) + await expect(tx) + .to.emit(bridge, "RebateStakingRepaired") + .withArgs(rebateStaking.address, AddressZero) + + expect(await bridge.isPegKeeper(thirdParty.address)).to.be.true + expect(await bridge.getRebateStaking()).to.equal(AddressZero) + }) + + it("should prevent rebate staking from being re-enabled after initializer", async () => { + await bridgeGovernance + .connect(governance) + .setRebateStaking(rebateStaking.address) + + const bridgeFactory = await getBridgeFactory() + const newImplementation = await bridgeFactory.deploy() + await newImplementation.deployed() + + const proxyAdminWithUpgrade = await getProxyAdminWithUpgrade() + const upgradeData = bridgeFactory.interface.encodeFunctionData( + "initializeV6_ConfigurePegKeeper", + [thirdParty.address] + ) + + await proxyAdminWithUpgrade.upgradeAndCall( + bridge.address, + newImplementation.address, + upgradeData + ) + + expect(await bridge.getRebateStaking()).to.equal(AddressZero) + await expect( + bridgeGovernance + .connect(governance) + .setRebateStaking(rebateStaking.address) + ).to.be.revertedWith("Rebate staking disabled") + }) + }) + + async function setPegKeeper(pegKeeper: string, allowed = true) { + await bridgeGovernance + .connect(governance) + .beginPegKeeperUpdate(pegKeeper, allowed) + await increaseTime(constants.governanceDelay) + await bridgeGovernance.connect(governance).finalizePegKeeperUpdate() + } + + async function getBridgeFactory() { + const bridgeLibraries = { + Deposit: (await helpers.contracts.getContract("Deposit")).address, + DepositSweep: (await helpers.contracts.getContract("DepositSweep")) + .address, + Redemption: (await helpers.contracts.getContract("Redemption")).address, + Wallets: (await helpers.contracts.getContract("Wallets")).address, + Fraud: (await helpers.contracts.getContract("Fraud")).address, + MovingFunds: (await helpers.contracts.getContract("MovingFunds")).address, + } + + return ethers.getContractFactory("BridgeStub", { + signer: deployer, + libraries: bridgeLibraries, + }) + } + + async function getProxyAdminWithUpgrade() { + const proxyAdmin = await upgrades.admin.getInstance() + + return ethers.getContractAt( + [ + "function upgradeAndCall(address proxy, address implementation, bytes data)", + ], + proxyAdmin.address, + esdm + ) + } + + async function setLiveWallet(walletPublicKeyHash: string) { + await bridge.setWallet(walletPublicKeyHash, { + ecdsaWalletID: ethers.constants.HashZero, + mainUtxoHash: ethers.constants.HashZero, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ethers.constants.HashZero, + }) + } + + async function makeRedemptionAllowance( + redeemer: SignerWithAddress, + amount: BigNumberish + ) { + await bank.setBalance(redeemer.address, amount) + await bank + .connect(redeemer) + .increaseBalanceAllowance(bridge.address, amount) + } + + function buildRedemptionKey( + redemptionWalletPubKeyHash: BytesLike, + redeemerOutputScript: BytesLike + ): string { + return ethers.utils.solidityKeccak256( + ["bytes32", "bytes20"], + [ + ethers.utils.solidityKeccak256(["bytes"], [redeemerOutputScript]), + redemptionWalletPubKeyHash, + ] + ) + } +}) diff --git a/solidity/test/bridge/RebateStaking.test.ts b/solidity/test/bridge/RebateStaking.test.ts index 44933d78b..3fc744898 100644 --- a/solidity/test/bridge/RebateStaking.test.ts +++ b/solidity/test/bridge/RebateStaking.test.ts @@ -1,7 +1,7 @@ -import { helpers, waffle, ethers } from "hardhat" +import { helpers, waffle, ethers, upgrades } from "hardhat" import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import { expect } from "chai" -import { Contract, ContractTransaction } from "ethers" +import { BigNumberish, Contract, ContractTransaction } from "ethers" import type { Bridge, BridgeGovernance, @@ -28,6 +28,7 @@ describe("RebateStaking", () => { let t: Contract let rebateStaking: RebateStaking let deployer: SignerWithAddress + let esdm: SignerWithAddress let thirdParty: SignerWithAddress const defaultStakeAmount = to1e18(100000000) @@ -42,6 +43,8 @@ describe("RebateStaking", () => { t, rebateStaking, } = await waffle.loadFixture(bridgeFixture)) + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ esdm } = await helpers.signers.getNamedSigners()) await bridgeGovernance .connect(governance) @@ -1462,6 +1465,245 @@ describe("RebateStaking", () => { }) }) + describe("deprecate", () => { + const stakeAmount = defaultStakeAmount + const treasuryFee = ethers.BigNumber.from(950) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("should require owner to deprecate rebate staking", async () => { + await expect( + rebateStaking.connect(governance).deprecate() + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + + it("should mark rebate staking as deprecated", async () => { + const tx = await rebateStaking.connect(deployer).deprecate() + + expect(await rebateStaking.deprecated()).to.be.equal(true) + expect(await rebateStaking.unstakingPeriod()).to.be.equal(0) + expect(await rebateStaking.rebatePerToken()).to.be.equal(0) + + await expect(tx).to.emit(rebateStaking, "RebateStakingDeprecated") + await expect(tx) + .to.emit(rebateStaking, "UnstakingPeriodUpdated") + .withArgs(0) + await expect(tx) + .to.emit(rebateStaking, "RebatePerTokenUpdated") + .withArgs(0) + }) + + it("should reject upgrade deprecation initializer from non-proxy-admin", async () => { + await expect( + rebateStaking.connect(deployer).initializeV2_Deprecate() + ).to.be.revertedWith("CallerNotProxyAdmin") + }) + + it("should allow proxy admin upgrade-and-call to deprecate rebate staking", async () => { + await stakeFor(thirdParty, stakeAmount) + + const rebateStakingFactory = await ethers.getContractFactory( + "RebateStaking", + deployer + ) + const newImplementation = await rebateStakingFactory.deploy() + await newImplementation.deployed() + + const proxyAdmin = await upgrades.admin.getInstance() + const proxyAdminWithUpgrade = await ethers.getContractAt( + [ + "function upgradeAndCall(address proxy, address implementation, bytes data)", + ], + proxyAdmin.address, + esdm + ) + + const upgradeData = rebateStakingFactory.interface.encodeFunctionData( + "initializeV2_Deprecate" + ) + + const tx = await proxyAdminWithUpgrade.upgradeAndCall( + rebateStaking.address, + newImplementation.address, + upgradeData + ) + + await expect(tx).to.emit(rebateStaking, "RebateStakingDeprecated") + expect(await rebateStaking.deprecated()).to.be.equal(true) + expect(await rebateStaking.unstakingPeriod()).to.be.equal(0) + expect(await rebateStaking.rebatePerToken()).to.be.equal(0) + + const receiverBalanceBefore = await t.balanceOf(governance.address) + await rebateStaking.connect(thirdParty).withdrawStake(governance.address) + + expect(await t.balanceOf(governance.address)).to.be.equal( + receiverBalanceBefore.add(stakeAmount) + ) + }) + + it("should allow upgrade deprecation initializer after owner deprecation", async () => { + await rebateStaking.connect(deployer).deprecate() + + const rebateStakingFactory = await ethers.getContractFactory( + "RebateStaking", + deployer + ) + const newImplementation = await rebateStakingFactory.deploy() + await newImplementation.deployed() + + const proxyAdmin = await upgrades.admin.getInstance() + const proxyAdminWithUpgrade = await ethers.getContractAt( + [ + "function upgradeAndCall(address proxy, address implementation, bytes data)", + ], + proxyAdmin.address, + esdm + ) + + const upgradeData = rebateStakingFactory.interface.encodeFunctionData( + "initializeV2_Deprecate" + ) + + await proxyAdminWithUpgrade.upgradeAndCall( + rebateStaking.address, + newImplementation.address, + upgradeData + ) + + expect(await rebateStaking.deprecated()).to.be.equal(true) + }) + + it("should not allow deprecation twice", async () => { + await rebateStaking.connect(deployer).deprecate() + + await expect( + rebateStaking.connect(deployer).deprecate() + ).to.be.revertedWith("ContractDeprecated") + }) + + it("should reject active-only operations after deprecation", async () => { + await rebateStaking.connect(deployer).deprecate() + + await expect( + rebateStaking.connect(deployer).updateRollingWindow(1) + ).to.be.revertedWith("ContractDeprecated") + await expect( + rebateStaking.connect(deployer).updateUnstakingPeriod(1) + ).to.be.revertedWith("ContractDeprecated") + await expect( + rebateStaking.connect(deployer).updateRebatePerToken(1) + ).to.be.revertedWith("ContractDeprecated") + + await t.connect(deployer).mint(thirdParty.address, stakeAmount) + await t.connect(thirdParty).approve(rebateStaking.address, stakeAmount) + + await expect( + rebateStaking.connect(thirdParty).stake(stakeAmount) + ).to.be.revertedWith("ContractDeprecated") + }) + + it("should stop applying rebates after deprecation", async () => { + await stakeFor(thirdParty, stakeAmount) + await rebateStaking.connect(deployer).deprecate() + + const tx = await bridge.applyForRebate(thirdParty.address, treasuryFee) + + expect(await bridge.lastTreasuryFee()).to.be.equal(treasuryFee) + expect(await rebateStaking.getRebateCap(thirdParty.address)).to.be.equal( + 0 + ) + expect( + await rebateStaking.getAvailableRebate(thirdParty.address) + ).to.be.equal(0) + expect(await rebateStaking.getRebateLength(thirdParty.address)).to.equal( + 0 + ) + + await expect(tx).to.not.emit(rebateStaking, "RebateReceived") + }) + + it("should allow existing unstaking to finalize immediately", async () => { + await stakeFor(thirdParty, stakeAmount) + await rebateStaking.connect(thirdParty).startUnstaking(stakeAmount) + + await rebateStaking.connect(deployer).deprecate() + + const receiverBalanceBefore = await t.balanceOf(governance.address) + await rebateStaking + .connect(thirdParty) + .finalizeUnstaking(governance.address) + + expect(await t.balanceOf(governance.address)).to.be.equal( + receiverBalanceBefore.add(stakeAmount) + ) + expect(await rebateStaking.getStake(thirdParty.address)).to.be.equal(0) + }) + + it("should withdraw the caller's entire stake after deprecation", async () => { + const unstakeAmount = stakeAmount.div(4) + await stakeFor(thirdParty, stakeAmount) + await rebateStaking.connect(thirdParty).startUnstaking(unstakeAmount) + await rebateStaking.connect(thirdParty).setDelegatee(deployer.address) + await rebateStaking.connect(thirdParty).setRebateTreasuryFeeMode(1) + await rebateStaking.connect(deployer).deprecate() + + const receiverBalanceBefore = await t.balanceOf(governance.address) + const tx = await rebateStaking + .connect(thirdParty) + .withdrawStake(governance.address) + + expect(await t.balanceOf(governance.address)).to.be.equal( + receiverBalanceBefore.add(stakeAmount) + ) + expect(await rebateStaking.getStake(thirdParty.address)).to.be.equal(0) + + const [unstakingAmount, unstakingTimestamp] = + await rebateStaking.getUnstakingAmount(thirdParty.address) + expect(unstakingAmount).to.be.equal(0) + expect(unstakingTimestamp).to.be.equal(0) + expect(await rebateStaking.getDelegatee(thirdParty.address)).to.be.equal( + ZERO_ADDRESS + ) + expect(await rebateStaking.delegates(deployer.address)).to.be.equal( + ZERO_ADDRESS + ) + expect( + await rebateStaking.getRebateTreasuryFeeMode(thirdParty.address) + ).to.be.equal(rebateTreasuryFeeMode.depositOnly) + + await expect(tx) + .to.emit(rebateStaking, "StakeWithdrawn") + .withArgs(thirdParty.address, governance.address, stakeAmount) + }) + + it("should reject stake withdrawal when unavailable", async () => { + await expect( + rebateStaking.connect(thirdParty).withdrawStake(thirdParty.address) + ).to.be.revertedWith("RebateStakingNotDeprecated") + + await rebateStaking.connect(deployer).deprecate() + + await expect( + rebateStaking.connect(thirdParty).withdrawStake(ZERO_ADDRESS) + ).to.be.revertedWith("ZeroAddress") + await expect( + rebateStaking.connect(thirdParty).withdrawStake(thirdParty.address) + ).to.be.revertedWith("NotAStaker") + }) + + async function stakeFor(staker: SignerWithAddress, amount: BigNumberish) { + await t.connect(deployer).mint(staker.address, amount) + await t.connect(staker).approve(rebateStaking.address, amount) + await rebateStaking.connect(staker).stake(amount) + } + }) + describe("forceStakeTransfer", () => { const stakeAmount = defaultStakeAmount const rebateCap = to1e18(1)