diff --git a/Makefile b/Makefile index 864fb53..0810ea9 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,11 @@ test-reclaiming: @cd packages/fastbtc-contracts && make @integration_test/scripts/test_user_reclaiming.sh +.PHONY: test-withdrawer +test-withdrawer: + @cd packages/fastbtc-contracts && make + @integration_test/scripts/test_withdrawer.sh + .PHONY: run-testnet run-testnet: @docker-compose -f docker-compose-base.yml -f docker-compose-testnet.yml up --build diff --git a/README.md b/README.md index 50ea5f0..73bdf22 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ $ make run-demo-regtest-slow-replenisher ``` +#### Withdrawer (FastBTC-in ManagedWallet replenishment) + +``` +# In one tab: +$ make run-demo-regtest +# In another tab: +$ make test-withdrawer +``` + + ### Advanced details The test setup (launched with `make run-demo-regtest`) will expose the Hardhat RPC server at `http://localhost:18545` diff --git a/integration_test/nodes/docker-env-common b/integration_test/nodes/docker-env-common index fdc63e4..b7c9a55 100644 --- a/integration_test/nodes/docker-env-common +++ b/integration_test/nodes/docker-env-common @@ -13,3 +13,7 @@ FASTBTC_BTC_RPC_URL=http://host.docker.internal:18543/wallet/multisig FASTBTC_BTC_RPC_USERNAME=fastbtc FASTBTC_BTC_RPC_PASSWORD=hunter2 FASTBTC_BTC_MASTER_PUBLIC_KEYS=tpubD6NzVbkrYhZ4WokHnVXX8CVBt1S88jkmeG78yWbLxn7Wd89nkNDe2J8b6opP4K38mRwXf9d9VVN5uA58epPKjj584R1rnDDbk6oHUD1MoWD,tpubD6NzVbkrYhZ4WpZfRZip3ALqLpXhHUbe6UyG8iiTzVDuvNUyysyiUJWejtbszZYrDaUM8UZpjLmHyvtV7r1QQNFmTqciAz1fYSYkw28Ux6y,tpubD6NzVbkrYhZ4WQZnWqU8ieBsujhoZKZLF6wMvTApJ4ZiGmipk481DyM2su3y5BDeB9fFLwSmmmsGDGJum79he2fnuQMnpWhe3bGir7Mf4uS + +FASTBTC_WITHDRAWER_CONTRACT_ADDRESS=0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 +FASTBTC_WITHDRAWER_THRESHOLD=10.0 +FASTBTC_WITHDRAWER_MAX_AMOUNT=10.0 diff --git a/integration_test/scripts/test_withdrawer.sh b/integration_test/scripts/test_withdrawer.sh new file mode 100755 index 0000000..edc0cb2 --- /dev/null +++ b/integration_test/scripts/test_withdrawer.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" +THIS_DIR=$(pwd) +FASTBTC_BRIDGE=0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9 +FASTBTC_IN=0x0000000000000000000000000000000000001337 + +cd ../../packages/fastbtc-contracts +echo "User BTC balance before: $($THIS_DIR/bitcoin-cli.sh -rpcwallet=user getbalance) BTC" +echo "FastBTCBridge rBTC balance before: $(npx hardhat --network integration-test get-rbtc-balance $FASTBTC_BRIDGE) rBTC" +echo "FastBTC-in rBTC balance before: $(npx hardhat --network integration-test get-rbtc-balance $FASTBTC_IN) rBTC" +NUM_TRANSFERS=4 +npx hardhat --network integration-test free-money 0xB3b77A8Bc6b6fD93D591C0F34f202eC02e9af2e8 5 +npx hardhat --network integration-test transfer-rbtc-to-btc 0xc1daad254b7005eca65780d47213d3de15bd92fcce83777487c5082c6d27600a bcrt1qq8zjw66qrgmynrq3gqdx79n7fcchtaudq4rrf0 0.5 --bridge-address 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 --repeat $NUM_TRANSFERS + +echo "$NUM_TRANSFERS transfers sent. They should be visible in a couple of minutes, and replenishment of FastBTC-in ($FASTBTC_IN) should also take place." +echo "Polling balances, Ctrl-C to exit" +while true ; do + echo "User BTC: $($THIS_DIR/bitcoin-cli.sh -rpcwallet=user getbalance) FastBTCBridge rBTC: $(npx hardhat --network integration-test get-rbtc-balance $FASTBTC_BRIDGE) FastBTC-in rBTC: $(npx hardhat --network integration-test get-rbtc-balance $FASTBTC_IN)" + sleep 10 +done diff --git a/packages/fastbtc-contracts/contracts/FastBTCBridge.sol b/packages/fastbtc-contracts/contracts/FastBTCBridge.sol index 54bdcf8..fb40a85 100644 --- a/packages/fastbtc-contracts/contracts/FastBTCBridge.sol +++ b/packages/fastbtc-contracts/contracts/FastBTCBridge.sol @@ -819,7 +819,7 @@ contract FastBTCBridge is ReentrancyGuard, FastBTCAccessControllable, Pausable, { require( amount <= totalAdminWithdrawableRbtc, - "Can only withdraw unsent transfers" + "Can only withdraw sent transfers" ); totalAdminWithdrawableRbtc -= amount; receiver.sendValue(amount); diff --git a/packages/fastbtc-contracts/contracts/Withdrawer.sol b/packages/fastbtc-contracts/contracts/Withdrawer.sol new file mode 100644 index 0000000..0d70168 --- /dev/null +++ b/packages/fastbtc-contracts/contracts/Withdrawer.sol @@ -0,0 +1,150 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "./FastBTCAccessControllable.sol"; +import "./interfaces/IWithdrawerFastBTCBridge.sol"; +import "./interfaces/IWithdrawerFastBTCAccessControl.sol"; + +/// @title Contract for withdrawing balances to another contract, e.g. the FastBTC-in ManagedWallet contract +/// @notice This contract is set as an admin to the FastBTCBridge contract, and can then be used by federators +/// to withdraw balances to a pre-set contract. +contract Withdrawer is FastBTCAccessControllable { + /// @dev Emitted when rBTC is withdrawn. + event Withdrawal( + uint256 amount + ); + + /// @dev Emitted when the max amount that can be withdrawn in a single transaction is changed. + event MaxWithdrawableUpdated( + uint256 newMaxWithdrawable + ); + + /// @dev Emitted when the min time between withdrawals is changed. + event MinTimeBetweenWithdrawalsUpdated( + uint256 newMinTimeBetweenWithdrawals + ); + + /// @dev The FastBTCBridge contract. + IWithdrawerFastBTCBridge public immutable fastBtcBridge; + + /// @dev The address the rBTC is withdrawn to. Intentionally non-changeable for security. + address payable public immutable receiver; + + /// @dev Max amount withdrawable in a single transaction + uint256 public maxWithdrawable = 10 ether; + + /// @dev Minimum time that has to pass between withdrawals + uint256 public minTimeBetweenWithdrawals = 1 days; + + /// @dev Last time the contract was withdrawn from + uint256 public lastWithdrawTimestamp = 0; + + constructor( + IWithdrawerFastBTCBridge _fastBtcBridge, + address payable _receiver + ) + FastBTCAccessControllable(_fastBtcBridge.accessControl()) + { + fastBtcBridge = _fastBtcBridge; + receiver = _receiver; + } + + // MAIN API + // ======== + + /// @dev Withdraw rBTC from the contract to the pre-set receiver. Can only be called by federators. + /// @notice This intentionally only requires a single federator, as + /// @param amount The amount of rBTC to withdraw (in wei). + function withdrawRbtcToReceiver( + uint256 amount + ) + external + onlyFederator + { + require(amount > 0, "cannot withdraw zero amount"); + require(amount <= maxWithdrawable, "amount too high"); + require(block.timestamp - lastWithdrawTimestamp >= minTimeBetweenWithdrawals, "too soon"); + + lastWithdrawTimestamp = block.timestamp; + + fastBtcBridge.withdrawRbtc(amount, receiver); + + emit Withdrawal(amount); + } + + // ADMIN API + // ========= + + /// @dev Set the max amount that can be withdrawn in a single transaction. + /// Can only be called by admins. + /// @param _maxWithdrawable The max amount that can be withdrawn in a single transaction. + function setMaxWithdrawable( + uint256 _maxWithdrawable + ) + external + onlyAdmin + { + if (_maxWithdrawable == maxWithdrawable) { + return; + } + maxWithdrawable = _maxWithdrawable; + emit MaxWithdrawableUpdated(_maxWithdrawable); + } + + /// @dev Set the min time between withdrawals. + /// Can only be called by admins. + /// @param _minTimeBetweenWithdrawals The min time between withdrawals. + function setMinTimeBetweenWithdrawals( + uint256 _minTimeBetweenWithdrawals + ) + external + onlyAdmin + { + if (_minTimeBetweenWithdrawals == minTimeBetweenWithdrawals) { + return; + } + minTimeBetweenWithdrawals = _minTimeBetweenWithdrawals; + emit MinTimeBetweenWithdrawalsUpdated(_minTimeBetweenWithdrawals); + } + + // PUBLIC VIEWS + // ============ + + /// @dev Get the amount of rBTC that can be withdrawn this very moment. + function amountWithdrawable() external view returns (uint256 withdrawable) { + if (!hasWithdrawPermissions()) { + return 0; + } + + if (block.timestamp - lastWithdrawTimestamp < minTimeBetweenWithdrawals) { + return 0; + } + + /// @dev the older version of FastBTCBridge doesn't have this function, so we will revert to balance check + try fastBtcBridge.totalAdminWithdrawableRbtc() returns (uint256 totalAdminWithdrawableRbtc) { + withdrawable = totalAdminWithdrawableRbtc; + } catch { + withdrawable = address(fastBtcBridge).balance; + } + + if (withdrawable > maxWithdrawable) { + withdrawable = maxWithdrawable; + } + } + + /// @dev Check if the contract has withdraw permissions. + function hasWithdrawPermissions() public view returns (bool) { + IWithdrawerFastBTCAccessControl control = IWithdrawerFastBTCAccessControl(address(accessControl)); + return control.hasRole(control.ROLE_ADMIN(), address(this)); + } + + /// @dev Get the timestamp of the next time the contract can be withdrawn from. + function nextPossibleWithdrawTimestamp() external view returns (uint256) { + return lastWithdrawTimestamp + minTimeBetweenWithdrawals; + } + + /// @dev Get the balance of the receiver address. + function receiverBalance() external view returns (uint256) { + return receiver.balance; + } +} diff --git a/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCAccessControl.sol b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCAccessControl.sol new file mode 100644 index 0000000..b4641e3 --- /dev/null +++ b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCAccessControl.sol @@ -0,0 +1,12 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +/// @title Interface to FastBTCAccessControl from the point of view of the Withdrawer contract +interface IWithdrawerFastBTCAccessControl { + /// @dev The role that has admin privileges on the contract, with permissions to manage other roles and call + /// admin-only functions. + function ROLE_ADMIN() external view returns(bytes32); + + /// @dev Is `role` granted to `account`? + function hasRole(bytes32 role, address account) external view returns (bool); +} diff --git a/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol new file mode 100644 index 0000000..0ab13ae --- /dev/null +++ b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol @@ -0,0 +1,21 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +/// @title Interface to FastBTCBridge, from the point of view of the Withdrawer contract +interface IWithdrawerFastBTCBridge { + /// @dev return the address of the FastBTCAccessControl contract + function accessControl() external view returns (address); + + /// @dev Withdraw rBTC from the contract. + /// Can only be called by admins. + /// @param amount The amount of rBTC to withdraw (in wei). + /// @param receiver The address to send the rBTC to. + function withdrawRbtc( + uint256 amount, + address payable receiver + ) + external; + + /// @dev The amount of rBTC that is sent to Bitcoin and can thus be withdrawn by admins. + function totalAdminWithdrawableRbtc() external view returns(uint256); +} diff --git a/packages/fastbtc-contracts/deploy/04_deploy_withdrawer.ts b/packages/fastbtc-contracts/deploy/04_deploy_withdrawer.ts new file mode 100644 index 0000000..8c3237d --- /dev/null +++ b/packages/fastbtc-contracts/deploy/04_deploy_withdrawer.ts @@ -0,0 +1,56 @@ +import {HardhatRuntimeEnvironment} from 'hardhat/types'; +import {DeployFunction} from 'hardhat-deploy/types'; + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const {deploy} = hre.deployments; + const {deployer} = await hre.getNamedAccounts(); + + let receiverAddress; + if (hre.network.name === 'hardhat') { + // Just use some random address here, it doesn't matter + receiverAddress = '0x0000000000000000000000000000000000001337'; + } else if (hre.network.name === 'rsk') { + // mainnet ManagedWallet + receiverAddress = '0xE43cafBDd6674DF708CE9DFF8762AF356c2B454d'; + } else if (hre.network.name === 'rsk-testnet') { + // testnet ManagedWallet + receiverAddress = '0xACBE05e7236F7d073295C99E629620DA58284AaD' + } else { + throw new Error(`Unknown network: ${hre.network.name}`); + } + + console.log(`Deploying Withdrawer contract with receiver (ManagedWallet) ${receiverAddress}`); + + const fastBtcBridgeDeployment = await hre.deployments.get('FastBTCBridge'); + const result = await deploy('Withdrawer', { + from: deployer, + args: [fastBtcBridgeDeployment.address, receiverAddress], + log: true, + }); + + if (result.newlyDeployed) { + const accessControlDeployment = await hre.deployments.get('FastBTCAccessControl'); + const accessControl = await hre.ethers.getContractAt( + 'FastBTCAccessControl', + accessControlDeployment.address + ); + const adminRole = await accessControl.ROLE_ADMIN(); + + if (hre.network.name === 'hardhat') { + console.log("Setting Withdrawer as an admin.") + await accessControl.grantRole(adminRole, result.address); + } else { + console.log("\n\n!!! NOTE !!!"); + console.log(`Withdrawer contract is deployed to ${result.address}, set the access control manually!`) + const txData = accessControl.interface.encodeFunctionData('grantRole', [ + adminRole, + result.address + ]); + console.log("To set the permissions, send a transaction with data") + console.log(txData) + console.log(`To the FastBTCAccessControl contract at ${accessControl.address}`); + console.log("\n\n") + } + } +}; +export default func; diff --git a/packages/fastbtc-contracts/hardhat.config.ts b/packages/fastbtc-contracts/hardhat.config.ts index 8d56c09..8986995 100644 --- a/packages/fastbtc-contracts/hardhat.config.ts +++ b/packages/fastbtc-contracts/hardhat.config.ts @@ -17,6 +17,7 @@ const INTEGRATION_TEST_ADDRESSES: Record = { 'FastBTCAccessControl': '0xe7f1725e7734ce288f8367e1bb143e90bb3f0512', 'BTCAddressValidator': '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0', 'FastBTCBridge': '0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9', + 'Withdrawer': '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9', } async function getDeploymentAddress(givenAddress: string|undefined, hre: HardhatRuntimeEnvironment, name: string): Promise { if (givenAddress) { diff --git a/packages/fastbtc-contracts/test/Withdrawer.test.ts b/packages/fastbtc-contracts/test/Withdrawer.test.ts new file mode 100644 index 0000000..986b7fa --- /dev/null +++ b/packages/fastbtc-contracts/test/Withdrawer.test.ts @@ -0,0 +1,392 @@ +import {expect} from 'chai'; +import {beforeEach, describe, it} from 'mocha'; +import {ethers} from 'hardhat'; +import {BigNumber, Contract, Signer} from 'ethers'; +import {parseEther} from 'ethers/lib/utils'; +import { setNextBlockTimestamp } from './utils'; + + +const TRANSFER_STATUS_SENDING = 2; +const ONE_BTC_IN_SATOSHI = BigNumber.from('10').pow('8'); +const ONE_SATOSHI_IN_WEI = parseEther('1').div(ONE_BTC_IN_SATOSHI); + + +describe("Withdrawer", function() { + let withdrawer: Contract; + let fastBtcBridge: Contract; + let accessControl: Contract; + let ownerAccount: Signer; + let anotherAccount: Signer; + let ownerAddress: string; + let anotherAddress: string; + let federators: Signer[]; + let receiverAccount: Signer; + let receiverAddress: string; + + beforeEach(async () => { + const accounts = await ethers.getSigners(); + ownerAccount = accounts[0]; + anotherAccount = accounts[1]; + federators = [ + accounts[2], + accounts[3], + accounts[4], + ] + receiverAccount = accounts[5]; + + ownerAddress = await ownerAccount.getAddress(); + anotherAddress = await anotherAccount.getAddress(); + receiverAddress = await receiverAccount.getAddress(); + + const FastBTCAccessControl = await ethers.getContractFactory("FastBTCAccessControl"); + accessControl = await FastBTCAccessControl.deploy(); + + for (const federator of federators) { + await accessControl.addFederator(await federator.getAddress()); + } + + const BTCAddressValidator = await ethers.getContractFactory("BTCAddressValidator"); + const btcAddressValidator = await BTCAddressValidator.deploy( + accessControl.address, + 'bc1', + ['1', '3'] + ); + + const FastBTCBridge = await ethers.getContractFactory("FastBTCBridge"); + fastBtcBridge = await FastBTCBridge.deploy( + accessControl.address, + btcAddressValidator.address, + ); + await fastBtcBridge.deployed(); + await fastBtcBridge.setMaxTransferSatoshi(ONE_BTC_IN_SATOSHI.mul('100')); + + const Withdrawer = await ethers.getContractFactory("Withdrawer"); + withdrawer = await Withdrawer.deploy( + fastBtcBridge.address, + receiverAddress, + ); + await withdrawer.deployed(); + + // we connect it to the federator as that's the most common case + withdrawer = withdrawer.connect(federators[0]); + + // we add it as admin by default, as intended, because it simplifies other tests + await accessControl.grantRole(await accessControl.ROLE_ADMIN(), withdrawer.address); + }); + + describe("#withdrawRbtcToReceiver", () => { + let maxWithdrawable: BigNumber; + + beforeEach(async () => { + maxWithdrawable = await withdrawer.maxWithdrawable(); + await fundFastBtcBridge(maxWithdrawable.add(ONE_SATOSHI_IN_WEI)); + }); + + it('will withdraw up to maxWithdrawable', async () => { + const receiverBalanceBefore = await ethers.provider.getBalance(receiverAddress); + const fastBtcBridgeBalanceBefore = await ethers.provider.getBalance(fastBtcBridge.address); + + const amount = maxWithdrawable; + await expect( + withdrawer.withdrawRbtcToReceiver( + amount, + ) + ).to.emit(withdrawer, 'Withdrawal').withArgs( + amount, + ); + + const receiverBalanceAfter = await ethers.provider.getBalance(receiverAddress); + const fastBtcBridgeBalanceAfter = await ethers.provider.getBalance(fastBtcBridge.address); + + expect(receiverBalanceAfter.sub(receiverBalanceBefore)).to.equal(amount); + expect(fastBtcBridgeBalanceAfter.sub(fastBtcBridgeBalanceBefore)).to.equal(amount.mul('-1')); + }); + + it('cannot withdraw zero amount', async () => { + await expect(withdrawer.withdrawRbtcToReceiver(0)).to.be.revertedWith( + 'cannot withdraw zero amount' + ); + }); + + it('cannot withdraw more than maxWithdrawable', async () => { + await expect(withdrawer.withdrawRbtcToReceiver( + maxWithdrawable.add('1')) + ).to.be.revertedWith( + 'amount too high' + ); + }); + + it('can withdraw again only after minTimeBetweenWithdrawals has passed', async () => { + const receiverBalanceBefore = await ethers.provider.getBalance(receiverAddress); + const fastBtcBridgeBalanceBefore = await ethers.provider.getBalance(fastBtcBridge.address); + + let amount = parseEther('1'); + await expect( + withdrawer.withdrawRbtcToReceiver( + amount, + ) + ).to.emit(withdrawer, 'Withdrawal').withArgs( + amount, + ); + + const currentTimestamp = (await ethers.provider.getBlock('latest')).timestamp; + + let receiverBalanceAfter = await ethers.provider.getBalance(receiverAddress); + let fastBtcBridgeBalanceAfter = await ethers.provider.getBalance(fastBtcBridge.address); + + expect(receiverBalanceAfter.sub(receiverBalanceBefore)).to.equal(amount); + expect(fastBtcBridgeBalanceAfter.sub(fastBtcBridgeBalanceBefore)).to.equal(amount.mul('-1')); + + await expect( + withdrawer.withdrawRbtcToReceiver( + amount + ) + ).to.be.revertedWith( + 'too soon' + ); + + const minTimeBetweenWithdrawals = await withdrawer.minTimeBetweenWithdrawals(); + await setNextBlockTimestamp(minTimeBetweenWithdrawals.add(currentTimestamp)); + + await withdrawer.withdrawRbtcToReceiver( + amount, + ); + + receiverBalanceAfter = await ethers.provider.getBalance(receiverAddress); + fastBtcBridgeBalanceAfter = await ethers.provider.getBalance(fastBtcBridge.address); + expect(receiverBalanceAfter.sub(receiverBalanceBefore)).to.equal(amount.mul('2')); + expect(fastBtcBridgeBalanceAfter.sub(fastBtcBridgeBalanceBefore)).to.equal(amount.mul('-2')); + }); + + it('only a federator can withdraw', async () => { + const amount = parseEther('0.123') + + await expect( + withdrawer.connect(ownerAccount).withdrawRbtcToReceiver( + amount + ) + ).to.be.reverted; + + await expect( + withdrawer.connect(anotherAccount).withdrawRbtcToReceiver( + amount + ) + ).to.be.reverted; + + // no revert here: + await withdrawer.connect(federators[1]).withdrawRbtcToReceiver( + amount + ); + }); + }); + + describe('#setMaxWithdrawable', () => { + it('only admin can set maxWithdrawable', async () => { + await expect(withdrawer.connect(federators[0]).setMaxWithdrawable(parseEther('1'))).to.be.reverted; + await expect(withdrawer.connect(anotherAccount).setMaxWithdrawable(parseEther('1'))).to.be.reverted; + await expect(withdrawer.connect(ownerAccount).setMaxWithdrawable(parseEther('1'))).to.not.be.reverted; + }); + + it('can set maxWithdrawable to zero', async () => { + await expect(withdrawer.connect(ownerAccount).setMaxWithdrawable(0)).to.not.be.reverted; + }); + + it('calling changes maxWithdrawable', async () => { + const amount = parseEther('1.2345'); + await withdrawer.connect(ownerAccount).setMaxWithdrawable(amount); + expect(await withdrawer.maxWithdrawable()).to.equal(amount); + }); + + it('calling emits the MaxWithdrawableUpdated event', async () => { + const amount = parseEther('1.2345'); + await expect( + withdrawer.connect(ownerAccount).setMaxWithdrawable(amount) + ).to.emit(withdrawer, 'MaxWithdrawableUpdated').withArgs(amount); + }); + }); + + describe('#setMinTimeBetweenWithdrawals', () => { + // same tests as above, basicallydd + it('only admin can set minTimeBetweenWithdrawals', async () => { + await expect(withdrawer.connect(federators[0]).setMinTimeBetweenWithdrawals(1)).to.be.reverted; + await expect(withdrawer.connect(anotherAccount).setMinTimeBetweenWithdrawals(1)).to.be.reverted; + await expect(withdrawer.connect(ownerAccount).setMinTimeBetweenWithdrawals(1)).to.not.be.reverted; + }); + + it('can set minTimeBetweenWithdrawals to zero', async () => { + await expect(withdrawer.connect(ownerAccount).setMinTimeBetweenWithdrawals(0)).to.not.be.reverted; + }); + + it('calling changes minTimeBetweenWithdrawals', async () => { + const time = 12345; + await withdrawer.connect(ownerAccount).setMinTimeBetweenWithdrawals(time); + expect(await withdrawer.minTimeBetweenWithdrawals()).to.equal(time); + }); + + it('calling emits the MinTimeBetweenWithdrawalsUpdated event', async () => { + const time = 12345; + await expect( + withdrawer.connect(ownerAccount).setMinTimeBetweenWithdrawals(time) + ).to.emit(withdrawer, 'MinTimeBetweenWithdrawalsUpdated').withArgs(time); + }); + }); + + describe('#hasWithdrawPermissions', () => { + it('returns true if the contract is an admin of FastBTCBridge', async () => { + expect(await withdrawer.hasWithdrawPermissions()).to.be.true; + }); + + it('returns false if the contract is not an admin of FastBTCBridge', async () => { + await accessControl.connect(ownerAccount).revokeRole( + await accessControl.ROLE_ADMIN(), + withdrawer.address, + ); + expect(await withdrawer.hasWithdrawPermissions()).to.be.false; + }); + }); + + describe('#nextPossibleWithdrawTimestamp', () => { + it('is initially minTimeBetweenWithdrawals', async () => { + const minTimeBetweenWithdrawals = await withdrawer.minTimeBetweenWithdrawals(); + expect(await withdrawer.nextPossibleWithdrawTimestamp()).to.equal(minTimeBetweenWithdrawals); + }); + + it('increases after a withdrawal', async () => { + const amount = parseEther('0.12345'); + await fundFastBtcBridge(amount); + const result = await withdrawer.withdrawRbtcToReceiver(amount); + + const block = await ethers.provider.getBlock(result.blockNumber); + const minTimeBetweenWithdrawals = await withdrawer.minTimeBetweenWithdrawals(); + + expect(await withdrawer.nextPossibleWithdrawTimestamp()).to.equal(block.timestamp + minTimeBetweenWithdrawals.toNumber()); + }); + }); + + describe('#receiverBalance', () => { + it('returns the balance of the receiver', async () => { + await ownerAccount.sendTransaction({ + to: receiverAddress, + value: parseEther('0.12345'), + }); + expect(await withdrawer.receiverBalance()).to.equal(await receiverAccount.getBalance()); + }); + + }); + + describe('#amountWithdrawable', () => { + it('returns totalAdminWithdrawableRbtc if everything is ok and the method is supported by FastBTCBridge', async () => { + const excessBalance = parseEther('10'); + + await ethers.provider.send('hardhat_setBalance', [ + fastBtcBridge.address, + excessBalance.toHexString(), + ]); + + expect(await fastBtcBridge.totalAdminWithdrawableRbtc()).to.equal(0); + expect(await withdrawer.amountWithdrawable()).to.equal(0); + + const expectedWithdrawableAmount = parseEther('1.337'); + await fundFastBtcBridge(expectedWithdrawableAmount); + + expect(await fastBtcBridge.totalAdminWithdrawableRbtc()).to.equal(expectedWithdrawableAmount); + + expect(await ethers.provider.getBalance(fastBtcBridge.address)).to.equal( + expectedWithdrawableAmount.add(excessBalance) + ); + expect(await withdrawer.amountWithdrawable()).to.equal(expectedWithdrawableAmount); + }); + + it('returns contract balance if everything is ok but totalAdminWithdrawableRbtc is not supported by FastBTCBridge', async () => { + const Withdrawer = await ethers.getContractFactory("Withdrawer"); + + // This bears some explanation: `Withdrawer` is a `FastBTCAccessControllable` contract itself, + // so it has the `accessControl` method that points to the correct `FastBTCAccessControl` instance. + // That means we can pretend that the previous `Withdrawer` instance is the `FastBTCBridge` contract, + // as far as the constructor or the newly deployed `Withdrawer` is concerned. + // Naturally, `Withdrawer` does not have the `totalAdminWithdrawableRbtc` function, so we can + // test this edge case here. + const fakeFastBtcBridge = withdrawer; + const newWithdrawer = await Withdrawer.deploy( + fakeFastBtcBridge.address, + receiverAddress, + ); + await accessControl.grantRole(await accessControl.ROLE_ADMIN(), newWithdrawer.address); + + const contractBalance = parseEther('10'); + + await ethers.provider.send('hardhat_setBalance', [ + fakeFastBtcBridge.address, + contractBalance.toHexString(), + ]); + + expect(await newWithdrawer.amountWithdrawable()).to.equal(contractBalance); + }); + + it('returns 0 if the contract does not have withdraw permissions', async () => { + await fundFastBtcBridge(parseEther('1.2345')); + await accessControl.connect(ownerAccount).revokeRole( + await accessControl.ROLE_ADMIN(), + withdrawer.address, + ); + expect(await withdrawer.amountWithdrawable()).to.equal(0); + }); + + it('returns 0 if enough time has not passed from the last withdrawal', async () => { + const amount = parseEther('0.1'); + + await fundFastBtcBridge(amount); + await withdrawer.withdrawRbtcToReceiver(amount.div(2)); + + expect(await withdrawer.amountWithdrawable()).to.equal(0); + }); + }); + + async function fundFastBtcBridge( + amount: BigNumber + ): Promise { + const transferId = await createExampleTransfer( + anotherAccount, + amount, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' + ); + + const btcTxHash: string = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; + const updateHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( + btcTxHash, + [transferId], + TRANSFER_STATUS_SENDING + ); + const updateHashBytes = ethers.utils.arrayify(updateHash); + const signatures = [ + await federators[0].signMessage(updateHashBytes), + await federators[1].signMessage(updateHashBytes), + ]; + + await fastBtcBridge.connect(federators[0]).markTransfersAsSending( + btcTxHash, + [transferId], + signatures + ) + } + + async function createExampleTransfer( + transferAccount: Signer, + transferAmount: BigNumber, + transferBtcAddress: string, + ): Promise { + await ownerAccount.sendTransaction({ + value: transferAmount, + to: await transferAccount.getAddress(), + }); + const nonce = await fastBtcBridge.getNextNonce(transferBtcAddress); + await fastBtcBridge.connect(transferAccount).transferToBtc( + transferBtcAddress, + { + value: transferAmount, + } + ); + + return await fastBtcBridge.getTransferId(transferBtcAddress, nonce); + } +}); diff --git a/packages/fastbtc-contracts/test/utils.ts b/packages/fastbtc-contracts/test/utils.ts new file mode 100644 index 0000000..665ea3e --- /dev/null +++ b/packages/fastbtc-contracts/test/utils.ts @@ -0,0 +1,15 @@ +import {ethers} from 'hardhat'; +import { BigNumber, BigNumberish } from 'ethers'; + +export async function mineToBlock(targetBlock: number) { + while (await ethers.provider.getBlockNumber() < targetBlock) { + await ethers.provider.send('evm_mine', []); + } +} + + +export async function setNextBlockTimestamp(timestamp: BigNumberish) { + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(timestamp).toHexString() + ]); +} diff --git a/packages/fastbtc-node/src/config.ts b/packages/fastbtc-node/src/config.ts index e037c43..a3af159 100644 --- a/packages/fastbtc-node/src/config.ts +++ b/packages/fastbtc-node/src/config.ts @@ -2,10 +2,12 @@ import * as fs from "fs"; import {readFileSync} from "fs"; import * as process from "process"; import * as express from "express"; +import {parseEther} from "ethers/lib/utils"; import {decryptSecrets} from "./utils/secrets"; import {ReplenisherConfig, ReplenisherSecrets} from './replenisher/config'; import {interfaces} from "inversify"; import Context = interfaces.Context; +import {BigNumber} from 'ethers'; export interface ConfigSecrets { dbUrl: string; @@ -33,6 +35,9 @@ export interface Config { btcKeyDerivationPath: string; statsdUrl?: string; secrets: () => ConfigSecrets; + withdrawerContractAddress?: string; + withdrawerThresholdWei: BigNumber; + withdrawerMaxAmountWei: BigNumber; replenisherConfig: ReplenisherConfig|undefined; } @@ -157,6 +162,9 @@ export const envConfigProviderFactory = async ( btcRpcUsername: env.FASTBTC_BTC_RPC_USERNAME ?? '', btcKeyDerivationPath: env.FASTBTC_BTC_KEY_DERIVATION_PATH ?? 'm/0/0/0', statsdUrl: env.FASTBTC_STATSD_URL, + withdrawerContractAddress: env.FASTBTC_WITHDRAWER_CONTRACT_ADDRESS, + withdrawerThresholdWei: parseEther(env.FASTBTC_WITHDRAWER_THRESHOLD || '10.0'), + withdrawerMaxAmountWei: parseEther(env.FASTBTC_WITHDRAWER_MAX_AMOUNT || '10.0'), secrets: () => ( { btcRpcPassword: env.FASTBTC_BTC_RPC_PASSWORD ?? '', diff --git a/packages/fastbtc-node/src/core/node.ts b/packages/fastbtc-node/src/core/node.ts index 45ad899..add3d8b 100644 --- a/packages/fastbtc-node/src/core/node.ts +++ b/packages/fastbtc-node/src/core/node.ts @@ -15,6 +15,7 @@ import {StatsD} from "hot-shots"; import {TYPES} from "../stats"; import StatusChecker from './statuschecker'; import {BitcoinReplenisher} from '../replenisher/replenisher'; +import {RBTCWithdrawer} from '../withdrawer/withdrawer'; type FastBTCNodeConfig = Pick< Config, @@ -115,6 +116,7 @@ export class FastBTCNode { @inject(TYPES.StatsD) private statsd: StatsD, @inject(StatusChecker) private statusChecker: StatusChecker, @inject(BitcoinReplenisher) private replenisher: BitcoinReplenisher, + @inject(RBTCWithdrawer) private withdrawer: RBTCWithdrawer, ) { this.networkUtil = new NetworkUtil(network, this.logger); network.onNodeAvailable(this.onNodeAvailable); @@ -185,6 +187,12 @@ export class FastBTCNode { this.logger.exception(e, 'Replenisher error'); } + try { + await this.withdrawer.handleWithdrawerIteration(); + } catch (e) { + this.logger.exception(e, 'Withdrawer error'); + } + let transferBatch = await this.bitcoinTransferService.getCurrentTransferBatch(); transferBatch = await this.updateTransferBatchFromTransientInitiatorData(transferBatch); this.logger.throttledInfo(`transfers queued: ${transferBatch.transfers.length}`); diff --git a/packages/fastbtc-node/src/inversify.config.ts b/packages/fastbtc-node/src/inversify.config.ts index 0352596..2a724dc 100644 --- a/packages/fastbtc-node/src/inversify.config.ts +++ b/packages/fastbtc-node/src/inversify.config.ts @@ -8,6 +8,7 @@ import * as core from './core'; import * as stats from './stats'; import * as replenisher from './replenisher'; import * as alerts from './alerts'; +import * as withdrawer from './withdrawer'; async function bootstrap(): Promise { const container = new Container(); @@ -24,6 +25,7 @@ async function bootstrap(): Promise { core.setupInversify(container); stats.setupInversify(container); alerts.setupInversify(container); + withdrawer.setupInversify(container); return container; } diff --git a/packages/fastbtc-node/src/withdrawer/abi/Withdrawer.json b/packages/fastbtc-node/src/withdrawer/abi/Withdrawer.json new file mode 100644 index 0000000..0e2bfc2 --- /dev/null +++ b/packages/fastbtc-node/src/withdrawer/abi/Withdrawer.json @@ -0,0 +1,226 @@ +[ + { + "inputs": [ + { + "internalType": "contract IWithdrawerFastBTCBridge", + "name": "_fastBtcBridge", + "type": "address" + }, + { + "internalType": "address payable", + "name": "_receiver", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newMaxWithdrawable", + "type": "uint256" + } + ], + "name": "MaxWithdrawableUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newMinTimeBetweenWithdrawals", + "type": "uint256" + } + ], + "name": "MinTimeBetweenWithdrawalsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Withdrawal", + "type": "event" + }, + { + "inputs": [], + "name": "accessControl", + "outputs": [ + { + "internalType": "contract IFastBTCAccessControl", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "amountWithdrawable", + "outputs": [ + { + "internalType": "uint256", + "name": "withdrawable", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "fastBtcBridge", + "outputs": [ + { + "internalType": "contract IWithdrawerFastBTCBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hasWithdrawPermissions", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastWithdrawTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxWithdrawable", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minTimeBetweenWithdrawals", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nextPossibleWithdrawTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "receiver", + "outputs": [ + { + "internalType": "address payable", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "receiverBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxWithdrawable", + "type": "uint256" + } + ], + "name": "setMaxWithdrawable", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_minTimeBetweenWithdrawals", + "type": "uint256" + } + ], + "name": "setMinTimeBetweenWithdrawals", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawRbtcToReceiver", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/packages/fastbtc-node/src/withdrawer/index.ts b/packages/fastbtc-node/src/withdrawer/index.ts new file mode 100644 index 0000000..feb5132 --- /dev/null +++ b/packages/fastbtc-node/src/withdrawer/index.ts @@ -0,0 +1,8 @@ +import {interfaces} from 'inversify'; +import Container = interfaces.Container; + +import {RBTCWithdrawer, RBTCWithdrawerImpl} from './withdrawer'; + +export function setupInversify(container: Container) { + container.bind(RBTCWithdrawer).to(RBTCWithdrawerImpl).inSingletonScope(); +} diff --git a/packages/fastbtc-node/src/withdrawer/withdrawer.ts b/packages/fastbtc-node/src/withdrawer/withdrawer.ts new file mode 100644 index 0000000..5e67435 --- /dev/null +++ b/packages/fastbtc-node/src/withdrawer/withdrawer.ts @@ -0,0 +1,108 @@ +import {inject, injectable} from 'inversify'; +import Logger from "../logger"; +import {BigNumber, Contract, ethers} from 'ethers'; +import {Config} from '../config'; +import {EthersProvider, EthersSigner} from '../rsk/base'; +import withdrawerAbi from './abi/Withdrawer.json'; +import {formatEther} from 'ethers/lib/utils'; + +export interface WithdrawerConfig { + withdrawerContractAddress?: string; + withdrawerMaxAmountWei: BigNumber; + withdrawerThresholdWei: BigNumber; +} + +export interface RBTCWithdrawer { + handleWithdrawerIteration(): Promise; +} + +export const RBTCWithdrawer = Symbol.for('RBTCWithdrawer') + +@injectable() +export class RBTCWithdrawerImpl implements RBTCWithdrawer { + private logger = new Logger('withdrawer'); + private withdrawerContract?: Contract; + private maxAmountWei: BigNumber; + private thresholdWei: BigNumber; + + // rudimentary throttling to avoid wasting all gas + private lastFailureTimestamp: number = 0; + private timeBetweenFailures: number = 2 * 60 * 60 * 1000; + + constructor( + @inject(EthersProvider) private ethersProvider: ethers.providers.Provider, + @inject(EthersSigner) ethersSigner: ethers.Signer, + @inject(Config) config: WithdrawerConfig, + ) { + if (config.withdrawerContractAddress) { + this.withdrawerContract = new ethers.Contract( + config.withdrawerContractAddress, + withdrawerAbi, + ethersSigner, + ); + } else { + this.logger.warn('No withdrawer contract address specified, withdrawer disabled'); + this.withdrawerContract = undefined; + } + this.maxAmountWei = config.withdrawerMaxAmountWei; + this.thresholdWei = config.withdrawerThresholdWei; + } + + async handleWithdrawerIteration(): Promise { + if (!this.withdrawerContract) { + this.logger.throttledInfo('No withdrawer contract, skipping iteration'); + return; + } + + const timeSinceLastFailure = Date.now() - this.lastFailureTimestamp; + if (timeSinceLastFailure < this.timeBetweenFailures) { + const timeToWait = this.timeBetweenFailures - timeSinceLastFailure; + this.logger.info( + `Last withdrawal failed ${timeSinceLastFailure/1000}s ago, waiting ${timeToWait/1000}s before trying again` + ); + return; + } + + const receiverBalance = await this.withdrawerContract.receiverBalance(); + if (receiverBalance.gte(this.thresholdWei)) { + this.logger.throttledInfo('Receiver balance is above threshold, skipping withdrawal'); + return; + } + + const hasWithdrawPermissions = await this.withdrawerContract.hasWithdrawPermissions(); + if (!hasWithdrawPermissions) { + this.logger.warn('Withdrawer contract does not have permissions to withdraw!'); + return; + } + + const amountWithdrawable = await this.withdrawerContract.amountWithdrawable(); + if (amountWithdrawable.isZero()) { + this.logger.throttledInfo( + 'No withdrawable funds or not enough time passed since last withdrawal, skipping' + ); + return; + } + + const receiver = await this.withdrawerContract.receiver(); + const amountToWithdraw = amountWithdrawable.gt(this.maxAmountWei) ? this.maxAmountWei : amountWithdrawable; + this.logger.info(`Withdrawing ${formatEther(amountToWithdraw)} rBTC to receiver ${receiver}`); + + try { + const result = await this.withdrawerContract.withdrawRbtcToReceiver(amountToWithdraw); + const txHash = result.hash; + this.logger.info(`Withdrew ${formatEther(amountToWithdraw)} rBTC to receiver ${receiver}, txHash: ${txHash}`); + const receipt = await this.ethersProvider.waitForTransaction( + txHash, + 1, // 1 confirmation enough for now + 5 * 60 * 1000, // wait max 5 minutes + ); + if (!receipt.status) { + this.logger.error('Withdrawer tx failed: %s', txHash); + this.lastFailureTimestamp = Date.now(); + } + } catch (e) { + this.logger.exception(e, 'Withdrawer error'); + this.lastFailureTimestamp = Date.now(); + } + } +}