From c75479a18f0529d954543ae1422971beb3829863 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Sun, 9 Apr 2023 10:55:42 +0300 Subject: [PATCH 01/10] Remove describe.only that got left there for some reason --- .../test/FastBTCBridge.test.ts | 2 +- .../fastbtc-contracts/test/Withdrawer.test.ts | 823 ++++++++++++++++++ 2 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 packages/fastbtc-contracts/test/Withdrawer.test.ts diff --git a/packages/fastbtc-contracts/test/FastBTCBridge.test.ts b/packages/fastbtc-contracts/test/FastBTCBridge.test.ts index 9f42590..93fab4e 100644 --- a/packages/fastbtc-contracts/test/FastBTCBridge.test.ts +++ b/packages/fastbtc-contracts/test/FastBTCBridge.test.ts @@ -780,7 +780,7 @@ describe("FastBTCBridge", function() { }); }); - describe.only('#setNodeConfigValue', () => { + describe('#setNodeConfigValue', () => { const key1 = '0x1234567812345678123456781234567812345678123456781234567812345678'; const key2 = '0x8765432112345678123456781234567812345678123456781234567812345678'; const value1 = '0x1234'; diff --git a/packages/fastbtc-contracts/test/Withdrawer.test.ts b/packages/fastbtc-contracts/test/Withdrawer.test.ts new file mode 100644 index 0000000..9f42590 --- /dev/null +++ b/packages/fastbtc-contracts/test/Withdrawer.test.ts @@ -0,0 +1,823 @@ +import {expect} from 'chai'; +import {beforeEach, describe, it} from 'mocha'; +import {ethers} from 'hardhat'; +import {BigNumber, Contract, Signer} from 'ethers'; +import {parseEther, parseUnits} from 'ethers/lib/utils'; + + +const TRANSFER_STATUS_NOT_APPLICABLE = 0; +const TRANSFER_STATUS_NEW = 1; // not 0 to make checks easier +const TRANSFER_STATUS_SENDING = 2; +const TRANSFER_STATUS_MINED = 3; +const TRANSFER_STATUS_REFUNDED = 4; +const TRANSFER_STATUS_RECLAIMED = 5; +const TRANSFER_STATUS_INVALID = 255 + +describe("FastBTCBridge", function() { + let fastBtcBridge: Contract; + let fastBtcBridgeFromFederator: Contract; + let accessControl: Contract; + let btcAddressValidator: Contract; + let ownerAccount: Signer; + let anotherAccount: Signer; + let ownerAddress: string; + let anotherAddress: string; + let federators: Signer[]; + + beforeEach(async () => { + const accounts = await ethers.getSigners(); + ownerAccount = accounts[0]; + anotherAccount = accounts[1]; + federators = [ + accounts[2], + accounts[3], + accounts[4], + ] + ownerAddress = await ownerAccount.getAddress(); + anotherAddress = await anotherAccount.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"); + 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(); + + fastBtcBridgeFromFederator = fastBtcBridge.connect(federators[0]); + }); + + 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); + } + + async function mineToBlock(targetBlock: number) { + while (await ethers.provider.getBlockNumber() < targetBlock) { + await ethers.provider.send('evm_mine', []); + } + } + + it("#isValidBtcAddress", async () => { + // just test something so we can live in peace + expect(await fastBtcBridge.isValidBtcAddress("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")).to.be.true; + expect(await fastBtcBridge.isValidBtcAddress("2BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")).to.be.false; + }); + + it('#getTransferBatchUpdateHash', async () => { + let updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_SENDING); + expect(updateHash).to.equal('0x849e7c1bf1eaa72e3d54ccafe4e31a87e7fdf91fadde443e59d6e7a4dc7bbf89'); + + updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_MINED); + expect(updateHash).to.equal('0xade6aa218b6b5b2b24c9d124f1354d1433129799b4f057da7fac270110173526'); + + updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_REFUNDED); + expect(updateHash).to.equal('0x407f4d1873d801d54d66816813e572aa318e59136d8e1e663a1c554352ba3772'); + }); + + describe('#transferToRbtc', () => { + beforeEach(async () => { + await ownerAccount.sendTransaction({ + value: parseUnits('1', 'ether'), + to: anotherAddress, + }); + fastBtcBridge = fastBtcBridge.connect(anotherAccount); + }); + + it('transfers rbtc', async () => { + const amountEther = parseEther('0.8'); + + await expect( + await fastBtcBridge.transferToBtc( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + { + value: amountEther, + } + ) + ).to.changeEtherBalances( + [anotherAccount, fastBtcBridge], + [amountEther.mul(-1), amountEther] + ); + }); + + it('emits the correct event', async () => { + const amountEther = parseEther('0.5'); + let amountSatoshi = BigNumber.from(Math.floor(0.5 * 10 ** 8)) + const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); + amountSatoshi = amountSatoshi.sub(feeSatoshi); + + await expect( + fastBtcBridge.transferToBtc( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + { + value: amountEther, + } + ) + ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( + await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 _transferId, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string _btcAddress, + BigNumber.from(0), // uint _nonce, + amountSatoshi, // uint _amountSatoshi, + feeSatoshi, // uint _feeSatoshi, + anotherAddress, // address _rskAddress + ); + }); + + it('nonces increase', async () => { + const amountEther = parseEther('0.1'); + let amountSatoshi = amountEther.div(BigNumber.from(Math.floor(10 ** 18 / 10 ** 8))); + const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); + + amountSatoshi = amountSatoshi.sub(feeSatoshi); + + await expect( + fastBtcBridge.transferToBtc( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + { + value: amountEther, + } + ) + ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( + await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 transferId, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string btcAddress, + BigNumber.from(0), // uint nonce, + amountSatoshi, // uint amountSatoshi, + feeSatoshi, // uint feeSatoshi, + anotherAddress, // address rskAddress + ); + + await expect( + fastBtcBridge.transferToBtc( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + { + value: amountEther, + } + ) + ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( + await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 1), // bytes32 transferId, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string btcAddress, + BigNumber.from(1), // uint nonce, + amountSatoshi, // uint amountSatoshi, + feeSatoshi, // uint feeSatoshi, + anotherAddress, // address rskAddress + ); + }); + }); + + describe('transfer update methods', () => { + let transferAmount: BigNumber; + let transferBtcAddress = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + let transferNonce = BigNumber.from(0); + let transferId: string; + + beforeEach(async () => { + transferAmount = parseEther('0.1'); + transferId = await createExampleTransfer( + anotherAccount, + transferAmount, + transferBtcAddress, + ); + }); + + describe('#markTransfersAsSending', () => { + let updateHashBytes: Uint8Array; + let btcTxHash: string = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; + + beforeEach(async () => { + const updateHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( + btcTxHash, + [transferId], + TRANSFER_STATUS_SENDING + ); + updateHashBytes = ethers.utils.arrayify(updateHash); + }); + + it('does not mark transfers as sent without signatures', async () => { + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending( + updateHashBytes, + [transferId], + [] + ) + ).to.be.reverted; + }); + + it('marks transfers as sent if signed by enough federators', async () => { + let transfer = await fastBtcBridgeFromFederator.getTransfer(transferBtcAddress, transferNonce); + expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); + + const signatures = [ + await federators[0].signMessage(updateHashBytes), + await federators[1].signMessage(updateHashBytes), + ]; + + const execution = fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + [transferId], + signatures + ) + + await expect(execution) + .to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferBatchSending') + .withArgs(btcTxHash, 1) + + await expect(execution) + .to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferStatusUpdated') + .withArgs(transferId, TRANSFER_STATUS_SENDING); + + transfer = await fastBtcBridgeFromFederator.getTransfer(transferBtcAddress, transferNonce); + expect(transfer.status).to.equal(TRANSFER_STATUS_SENDING); + + // test that it's no longer idempotent + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + [transferId], + signatures + ) + ).to.be.reverted; + }); + + it('does not mark transfers as sent if signed by too few federators', async () => { + const signatures = [ + await federators[0].signMessage(updateHashBytes), + ]; + + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + [transferId], + signatures + ) + ).to.be.reverted; + }); + + it('does not mark transfers as sent if signed by non-federators', async () => { + const signatures = [ + await federators[0].signMessage(updateHashBytes), + await anotherAccount.signMessage(updateHashBytes), + ]; + + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + [transferId], + signatures + ) + ).to.be.reverted; + }); + + it('does not mark transfers as sent if wrong hash signed', async () => { + const signatures = [ + await federators[0].signMessage(ethers.utils.arrayify( + await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( + btcTxHash, [transferId], TRANSFER_STATUS_NEW + ), + )), + await federators[1].signMessage(ethers.utils.arrayify( + await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( + btcTxHash, [transferId], TRANSFER_STATUS_NEW + ), + )), + ]; + + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending([transferId], signatures) + ).to.be.reverted; + }); + + it('checks the tx hash inside the update hash', async () => { + const signatures = [ + await federators[0].signMessage(ethers.utils.arrayify( + await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( + btcTxHash.replace(/6/, '7'), [transferId], TRANSFER_STATUS_SENDING + ), + )), + await federators[1].signMessage(ethers.utils.arrayify( + await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( + btcTxHash.replace(/6/, '7'), [transferId], TRANSFER_STATUS_SENDING + ), + )), + ]; + + await expect( + fastBtcBridgeFromFederator.markTransfersAsSending(btcTxHash, [transferId], signatures) + ).to.be.reverted; + }); + }); + + describe('#refundTransfers', () => { + let updateSignatures: string[]; + + beforeEach(async () => { + const updateHash = await fastBtcBridge.getTransferBatchUpdateHash( + [transferId], + TRANSFER_STATUS_REFUNDED + ); + const updateHashBytes = ethers.utils.arrayify(updateHash); + + updateSignatures = [ + await federators[0].signMessage(updateHashBytes), + await federators[1].signMessage(updateHashBytes), + ]; + }); + + it('refunds transfer', async () => { + let transfer = await fastBtcBridge.getTransferByTransferId(transferId); + expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); + await expect( + await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) + ).to.changeEtherBalances( + [anotherAccount, fastBtcBridge], + [transferAmount, transferAmount.mul(-1)] + ); + transfer = await fastBtcBridge.getTransferByTransferId(transferId); + expect(transfer.status).to.equal(TRANSFER_STATUS_REFUNDED); + }); + + it('emits events', async () => { + await expect( + await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) + ).to.changeEtherBalances( + [anotherAccount, fastBtcBridge], + [transferAmount, transferAmount.mul(-1)] + ); + }); + + it('does not refund already refunded transfer', async () => { + await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures); + await expect( + fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) + ).to.be.reverted; + }); + + it('does not refund sent transfers', async () => { + const btcTxHash = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; + const sentHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( + btcTxHash, + [transferId], + TRANSFER_STATUS_SENDING + ); + const sentHashBytes = ethers.utils.arrayify(sentHash); + const sentSignatures = [ + await federators[0].signMessage(sentHashBytes), + await federators[1].signMessage(sentHashBytes), + ]; + await fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + [transferId], + sentSignatures + ) + await expect( + fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) + ).to.be.reverted; + }); + }); + + describe('#reclaimTransfer', () => { + const requiredBlocks = 10; + let transfer: any; + let reclaimableBlock: number; + + beforeEach(async () => { + transfer = await fastBtcBridge.getTransferByTransferId(transferId); + await fastBtcBridge.setRequiredBlocksBeforeReclaim(requiredBlocks) + reclaimableBlock = transfer.blockNumber + requiredBlocks; + }); + + it("doesn't reclaim transfers when not enough blocks have passed", async () => { + await expect( + fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) + ).to.be.revertedWith("Not enough blocks passed before reclaim"); + // next block will be the block we mine to +1, so we mine to -2 + await mineToBlock(reclaimableBlock - 2); + await expect( + fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) + ).to.be.revertedWith("Not enough blocks passed before reclaim"); + }); + + it('reclaims transfer when enough block have passed', async () => { + expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); + await mineToBlock(reclaimableBlock - 1); + await expect( + await fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) + ).to.changeEtherBalances( + [anotherAccount, fastBtcBridge], + [transferAmount, transferAmount.mul(-1)] + ); + transfer = await fastBtcBridge.getTransferByTransferId(transferId); + expect(transfer.status).to.equal(TRANSFER_STATUS_RECLAIMED); + + // cannot reclaim again + await expect( + fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) + ).to.be.reverted; + }); + + it('emits events', async () => { + await mineToBlock(reclaimableBlock); + await expect( + fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) + ).to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferStatusUpdated').withArgs( + transferId, + TRANSFER_STATUS_RECLAIMED + ); + }); + + it('only allows reclaiming own transfers', async () => { + await mineToBlock(reclaimableBlock); + await expect( + fastBtcBridge.reclaimTransfer(transferId) + ).to.be.revertedWith("Can only reclaim own transfers"); + }); + + it('does not reclaim when frozen', async () => { + await fastBtcBridge.connect(ownerAccount).freeze(); + await mineToBlock(reclaimableBlock); + await expect( + fastBtcBridge.reclaimTransfer(transferId) + ).to.be.revertedWith("Freezable: frozen"); + }); + }); + }); + + describe('#getTransferId', () => { + it('computes as expected', async () => { + for (const btcAddress of ['bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', '1foo', 'bar']) { + for (const nonce of [0, 1, 254]) { + const transferId = await fastBtcBridge.getTransferId(btcAddress, nonce); + const computed = ethers.utils.solidityKeccak256( + ['string', 'string', 'string', 'uint256'], + ['transfer:', btcAddress, ':', nonce] + ); + expect(transferId).to.equal(computed); + } + } + }) + }); + + describe("#addFeeStructure", () => { + it("requires owner", async () => { + await expect( + fastBtcBridge.connect(federators[0]).addFeeStructure(1, 5000, 10) + ).to.be.reverted; + + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(1, 5000, 10) + ).to.not.be.reverted; + }); + + it("fails for existing index", async () => { + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(0, 5000, 10) + ).to.be.reverted; + }); + + it("fails for invalid index", async () => { + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(255, 5000, 10) + ).to.not.be.reverted; + + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(256, 5000, 10) + ).to.be.reverted; + }); + }); + + describe("#setCurrentFeeStructure", () => { + it("requires owner", async () => { + await expect( + fastBtcBridge.connect(federators[0]).setCurrentFeeStructure(0) + ).to.be.reverted; + + await expect( + fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(0) + ).to.not.be.reverted; + }); + + it("fails for nonexistent index", async () => { + await expect( + fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(1) + ).to.be.reverted; + }); + + it("emits the event and sets variables and changes actual fees", async () => { + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(1, 1000, 10) + ).to.not.be.reverted; + + await expect( + fastBtcBridge.connect(ownerAccount).addFeeStructure(2, 2000, 20) + ).to.not.be.reverted; + + let result = fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(1); + await expect(result).to.not.be.reverted; + await expect(result).to.emit( + fastBtcBridge, 'BitcoinTransferFeeChanged' + ).withArgs(1000, 10); + + await expect(await fastBtcBridge.currentFeeStructureIndex()).to.equal(1); + await expect(await fastBtcBridge.baseFeeSatoshi()).to.equal(1000); + await expect(await fastBtcBridge.dynamicFee()).to.equal(10); + + await expect( + await fastBtcBridge.connect(anotherAccount).calculateCurrentFeeSatoshi(100000) + ).to.equal(1100); + + + result = fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(2); + await expect(result).to.not.be.reverted; + await expect(result).to.emit( + fastBtcBridge, 'BitcoinTransferFeeChanged' + ).withArgs(2000, 20); + + await expect(await fastBtcBridge.currentFeeStructureIndex()).to.equal(2); + await expect(await fastBtcBridge.baseFeeSatoshi()).to.equal(2000); + await expect(await fastBtcBridge.dynamicFee()).to.equal(20); + + await expect( + await fastBtcBridge.connect(anotherAccount).calculateCurrentFeeSatoshi(100000) + ).to.equal(2200); + }); + }); + + describe('bridge integration', () => { + beforeEach(async () => { + await ownerAccount.sendTransaction({ + value: parseUnits('1', 'ether'), + to: anotherAddress, + }); + fastBtcBridge = fastBtcBridge.connect(anotherAccount); + }); + + it('encodes userData from token bridge', async () => { + const ret = await fastBtcBridge.encodeBridgeUserData( + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + ); + + expect(ret).to.equal( + '0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c' + + '80000000000000000000000000000000000000000000000000000000000000040' + + '000000000000000000000000000000000000000000000000000000000000002a6' + + '263317177353038643671656a7874646734793572337a61727661727930633578' + + '77376b76386633743400000000000000000000000000000000000000000000' + ); + }); + + it('decodes userData from tokenBridge', async () => { + const [rskAddress, btcAddress] = await fastBtcBridge.decodeBridgeUserData( + '0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c' + + '80000000000000000000000000000000000000000000000000000000000000040' + + '000000000000000000000000000000000000000000000000000000000000002a6' + + '263317177353038643671656a7874646734793572337a61727661727930633578' + + '77376b76386633743400000000000000000000000000000000000000000000' + ); + + expect(rskAddress).to.equal('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); + expect(btcAddress).to.equal('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'); + }); + + it('receiveEthFromBridge transfers rbtc', async () => { + const amountEther = parseEther('0.8'); + + const userData = await fastBtcBridge.encodeBridgeUserData( + '0x0000000000000000000000000000000000001337', + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + ); + + await expect( + await fastBtcBridge.receiveEthFromBridge( + userData, + { + value: amountEther, + } + ) + ).to.changeEtherBalances( + [anotherAccount, fastBtcBridge], + [amountEther.mul(-1), amountEther] + ); + }); + + it('receiveEthFromBridge emits the correct event', async () => { + const amountEther = parseEther('0.5'); + let amountSatoshi = BigNumber.from(Math.floor(0.5 * 10 ** 8)) + const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); + amountSatoshi = amountSatoshi.sub(feeSatoshi); + + const userData = await fastBtcBridge.encodeBridgeUserData( + '0x0000000000000000000000000000000000001337', + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + ); + + await expect( + fastBtcBridge.receiveEthFromBridge( + userData, + { + value: amountEther, + } + ) + ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( + await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 _transferId, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string _btcAddress, + BigNumber.from(0), // uint _nonce, + amountSatoshi, // uint _amountSatoshi, + feeSatoshi, // uint _feeSatoshi, + '0x0000000000000000000000000000000000001337', // address _rskAddress + ); + }); + }); + + describe('#withdrawTokens', () => { + const amount1 = parseEther('0.1'); + const amount2 = parseEther('0.05'); + const transferBtcAddress = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + let transferId1: string; + let transferId2: string; + let transfer1: any; + let transfer2: any; + + const markTransfersAsSending = async (transferIds: string[]) => { + // Fake tx hash + const btcTxHash = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; + const updateHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( + btcTxHash, + transferIds, + TRANSFER_STATUS_SENDING + ); + const updateHashBytes = ethers.utils.arrayify(updateHash); + const signatures = [ + await federators[0].signMessage(updateHashBytes), + await federators[1].signMessage(updateHashBytes), + ]; + await fastBtcBridgeFromFederator.markTransfersAsSending( + btcTxHash, + transferIds, + signatures + ); + } + + beforeEach(async () => { + transferId1 = await createExampleTransfer( + anotherAccount, + amount1, + transferBtcAddress, + ); + transfer1 = await fastBtcBridge.getTransferByTransferId(transferId1); + transferId2 = await createExampleTransfer( + anotherAccount, + amount2, + transferBtcAddress, + ); + transfer2 = await fastBtcBridge.getTransferByTransferId(transferId2); + }); + + it('cannot withdraw unsent rBTC', async () => { + await expect( + fastBtcBridge.withdrawRbtc(amount1, ownerAddress) + ).to.be.reverted; + }); + + it('can withdraw up to sent rBTC', async () => { + await markTransfersAsSending([transferId1]); + + await expect( + fastBtcBridge.withdrawRbtc(amount1.add(1), ownerAddress) + ).to.be.reverted; + + await expect( + await fastBtcBridge.withdrawRbtc(amount1, ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount1); + + await expect( + fastBtcBridge.withdrawRbtc(1, ownerAddress) + ).to.be.reverted; + + await markTransfersAsSending([transferId2]); + await expect( + await fastBtcBridge.withdrawRbtc(amount2, ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount2); + + await expect( + fastBtcBridge.withdrawRbtc(1, ownerAddress) + ).to.be.reverted; + }); + + it('can withdraw up to sent rBTC 2', async () => { + await markTransfersAsSending([transferId1]); + + await expect( + await fastBtcBridge.withdrawRbtc(amount1.div(2), ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount1.div(2)); + + await expect( + fastBtcBridge.withdrawRbtc(amount1.div(2).add(1), ownerAddress) + ).to.be.reverted; + + await markTransfersAsSending([transferId2]); + + await expect( + await fastBtcBridge.withdrawRbtc(amount1.div(2).add(1), ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount1.div(2).add(1)); + + await expect( + fastBtcBridge.withdrawRbtc(amount2, ownerAddress) + ).to.be.reverted; + + await expect( + await fastBtcBridge.withdrawRbtc(amount2.sub(1), ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount2.sub(1)); + + await expect( + fastBtcBridge.withdrawRbtc(1, ownerAddress) + ).to.be.reverted; + }); + + it('cannot withdraw reclaimed transfers', async () => { + await fastBtcBridge.setRequiredBlocksBeforeReclaim(0) + await markTransfersAsSending([transferId1]); + await fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId2); + + await expect( + fastBtcBridge.withdrawRbtc(amount1.add(1), ownerAddress) + ).to.be.reverted; + + await expect( + await fastBtcBridge.withdrawRbtc(amount1, ownerAddress) + ).to.changeEtherBalance(ownerAccount, amount1); + + await expect( + fastBtcBridge.withdrawRbtc(1, ownerAddress) + ).to.be.reverted; + }); + }); + + describe.only('#setNodeConfigValue', () => { + const key1 = '0x1234567812345678123456781234567812345678123456781234567812345678'; + const key2 = '0x8765432112345678123456781234567812345678123456781234567812345678'; + const value1 = '0x1234'; + const value2 = '0x4321'; + + it('is not allowed for arbitrary account', async () => { + await expect( + fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1) + ).to.be.reverted; + }); + + it('is allowed for owner', async () => { + await fastBtcBridge.setNodeConfigValue(key1, value1); + }); + + it('sets keys separately', async () => { + await fastBtcBridge.setNodeConfigValue(key1, value1); + await fastBtcBridge.setNodeConfigValue(key2, value2); + expect(await fastBtcBridge.nodeConfig(key1)).to.equal(value1); + expect(await fastBtcBridge.nodeConfig(key2)).to.equal(value2); + await fastBtcBridge.setNodeConfigValue(key2, value1); + expect(await fastBtcBridge.nodeConfig(key2)).to.equal(value1); + }); + + it('is allowed for arbitrary account after grant and not after revoke', async () => { + await accessControl.addConfigAdmin(anotherAccount.getAddress()); + await fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1); + await accessControl.removeConfigAdmin(anotherAccount.getAddress()); + await expect(fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1)).to.be.reverted; + }); + + it('can be deleted only by authorized', async () => { + await fastBtcBridge.setNodeConfigValue(key1, value1); + await expect(fastBtcBridge.connect(anotherAccount).deleteNodeConfigValue(key1)).to.be.reverted; + await accessControl.addConfigAdmin(anotherAccount.getAddress()); + await fastBtcBridge.connect(anotherAccount).deleteNodeConfigValue(key1); + expect(await fastBtcBridge.nodeConfig(key1)).to.equal('0x'); + }); + }); +}); From 861ff3c2e3519aff2c42449fde278604a9f52c45 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Sun, 9 Apr 2023 12:50:30 +0300 Subject: [PATCH 02/10] Contract for withdrawing RBTC to the FastBTC-in multisig contract --- .../contracts/Withdrawer.sol | 144 ++++++++++++++++++ .../IWithdrawerFastBTCAccessControl.sol | 12 ++ .../interfaces/IWithdrawerFastBTCBridge.sol | 18 +++ packages/fastbtc-contracts/test/utils.ts | 0 4 files changed, 174 insertions(+) create mode 100644 packages/fastbtc-contracts/contracts/Withdrawer.sol create mode 100644 packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCAccessControl.sol create mode 100644 packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol create mode 100644 packages/fastbtc-contracts/test/utils.ts diff --git a/packages/fastbtc-contracts/contracts/Withdrawer.sol b/packages/fastbtc-contracts/contracts/Withdrawer.sol new file mode 100644 index 0000000..cb3ee50 --- /dev/null +++ b/packages/fastbtc-contracts/contracts/Withdrawer.sol @@ -0,0 +1,144 @@ +//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; + } + + 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..f852eed --- /dev/null +++ b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol @@ -0,0 +1,18 @@ +//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; +} diff --git a/packages/fastbtc-contracts/test/utils.ts b/packages/fastbtc-contracts/test/utils.ts new file mode 100644 index 0000000..e69de29 From 0ec300e844958f41daf00149e06f8406bfdd9c81 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Mon, 10 Apr 2023 11:25:54 +0300 Subject: [PATCH 03/10] Withdrawer tests --- .../fastbtc-contracts/test/Withdrawer.test.ts | 852 +++--------------- packages/fastbtc-contracts/test/utils.ts | 15 + 2 files changed, 145 insertions(+), 722 deletions(-) diff --git a/packages/fastbtc-contracts/test/Withdrawer.test.ts b/packages/fastbtc-contracts/test/Withdrawer.test.ts index 9f42590..ffca4c4 100644 --- a/packages/fastbtc-contracts/test/Withdrawer.test.ts +++ b/packages/fastbtc-contracts/test/Withdrawer.test.ts @@ -2,27 +2,27 @@ import {expect} from 'chai'; import {beforeEach, describe, it} from 'mocha'; import {ethers} from 'hardhat'; import {BigNumber, Contract, Signer} from 'ethers'; -import {parseEther, parseUnits} from 'ethers/lib/utils'; +import {parseEther} from 'ethers/lib/utils'; +import { setNextBlockTimestamp } from './utils'; -const TRANSFER_STATUS_NOT_APPLICABLE = 0; -const TRANSFER_STATUS_NEW = 1; // not 0 to make checks easier const TRANSFER_STATUS_SENDING = 2; -const TRANSFER_STATUS_MINED = 3; -const TRANSFER_STATUS_REFUNDED = 4; -const TRANSFER_STATUS_RECLAIMED = 5; -const TRANSFER_STATUS_INVALID = 255 +const ZERO = BigNumber.from('0') +const ONE_BTC_IN_SATOSHI = BigNumber.from('10').pow('8'); +const ONE_SATOSHI_IN_WEI = parseEther('1').div(ONE_BTC_IN_SATOSHI); -describe("FastBTCBridge", function() { + +describe("Withdrawer", function() { + let withdrawer: Contract; let fastBtcBridge: Contract; - let fastBtcBridgeFromFederator: Contract; let accessControl: Contract; - let btcAddressValidator: 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(); @@ -33,8 +33,11 @@ describe("FastBTCBridge", function() { 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(); @@ -44,7 +47,7 @@ describe("FastBTCBridge", function() { } const BTCAddressValidator = await ethers.getContractFactory("BTCAddressValidator"); - btcAddressValidator = await BTCAddressValidator.deploy( + const btcAddressValidator = await BTCAddressValidator.deploy( accessControl.address, 'bc1', ['1', '3'] @@ -53,771 +56,176 @@ describe("FastBTCBridge", function() { const FastBTCBridge = await ethers.getContractFactory("FastBTCBridge"); fastBtcBridge = await FastBTCBridge.deploy( accessControl.address, - btcAddressValidator.address + btcAddressValidator.address, ); await fastBtcBridge.deployed(); + await fastBtcBridge.setMaxTransferSatoshi(ONE_BTC_IN_SATOSHI.mul('100')); - fastBtcBridgeFromFederator = fastBtcBridge.connect(federators[0]); - }); - - 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, - } + const Withdrawer = await ethers.getContractFactory("Withdrawer"); + withdrawer = await Withdrawer.deploy( + fastBtcBridge.address, + receiverAddress, ); + await withdrawer.deployed(); - return await fastBtcBridge.getTransferId(transferBtcAddress, nonce); - } - - async function mineToBlock(targetBlock: number) { - while (await ethers.provider.getBlockNumber() < targetBlock) { - await ethers.provider.send('evm_mine', []); - } - } + // we connect it to the federator as that's the most common case + withdrawer = withdrawer.connect(federators[0]); - it("#isValidBtcAddress", async () => { - // just test something so we can live in peace - expect(await fastBtcBridge.isValidBtcAddress("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")).to.be.true; - expect(await fastBtcBridge.isValidBtcAddress("2BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")).to.be.false; + // we add it as admin by default, as intended, because it simplifies other tests + await accessControl.grantRole(await accessControl.ROLE_ADMIN(), withdrawer.address); }); - it('#getTransferBatchUpdateHash', async () => { - let updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_SENDING); - expect(updateHash).to.equal('0x849e7c1bf1eaa72e3d54ccafe4e31a87e7fdf91fadde443e59d6e7a4dc7bbf89'); + describe("#withdrawRbtcToReceiver", () => { + let maxWithdrawable: BigNumber; - updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_MINED); - expect(updateHash).to.equal('0xade6aa218b6b5b2b24c9d124f1354d1433129799b4f057da7fac270110173526'); - - updateHash = await fastBtcBridge.getTransferBatchUpdateHash([], TRANSFER_STATUS_REFUNDED); - expect(updateHash).to.equal('0x407f4d1873d801d54d66816813e572aa318e59136d8e1e663a1c554352ba3772'); - }); - - describe('#transferToRbtc', () => { beforeEach(async () => { - await ownerAccount.sendTransaction({ - value: parseUnits('1', 'ether'), - to: anotherAddress, - }); - fastBtcBridge = fastBtcBridge.connect(anotherAccount); - }); - - it('transfers rbtc', async () => { - const amountEther = parseEther('0.8'); - - await expect( - await fastBtcBridge.transferToBtc( - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - { - value: amountEther, - } - ) - ).to.changeEtherBalances( - [anotherAccount, fastBtcBridge], - [amountEther.mul(-1), amountEther] - ); - }); - - it('emits the correct event', async () => { - const amountEther = parseEther('0.5'); - let amountSatoshi = BigNumber.from(Math.floor(0.5 * 10 ** 8)) - const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); - amountSatoshi = amountSatoshi.sub(feeSatoshi); - - await expect( - fastBtcBridge.transferToBtc( - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - { - value: amountEther, - } - ) - ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( - await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 _transferId, - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string _btcAddress, - BigNumber.from(0), // uint _nonce, - amountSatoshi, // uint _amountSatoshi, - feeSatoshi, // uint _feeSatoshi, - anotherAddress, // address _rskAddress - ); + maxWithdrawable = await withdrawer.maxWithdrawable(); + await fundFastBtcBridge(maxWithdrawable.add(ONE_SATOSHI_IN_WEI)); }); - it('nonces increase', async () => { - const amountEther = parseEther('0.1'); - let amountSatoshi = amountEther.div(BigNumber.from(Math.floor(10 ** 18 / 10 ** 8))); - const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); - - amountSatoshi = amountSatoshi.sub(feeSatoshi); - - await expect( - fastBtcBridge.transferToBtc( - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - { - value: amountEther, - } - ) - ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( - await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 transferId, - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string btcAddress, - BigNumber.from(0), // uint nonce, - amountSatoshi, // uint amountSatoshi, - feeSatoshi, // uint feeSatoshi, - anotherAddress, // address rskAddress - ); + 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( - fastBtcBridge.transferToBtc( - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - { - value: amountEther, - } + withdrawer.withdrawRbtcToReceiver( + amount, ) - ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( - await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 1), // bytes32 transferId, - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string btcAddress, - BigNumber.from(1), // uint nonce, - amountSatoshi, // uint amountSatoshi, - feeSatoshi, // uint feeSatoshi, - anotherAddress, // address rskAddress + ).to.emit(withdrawer, 'Withdrawal').withArgs( + amount, ); - }); - }); - - describe('transfer update methods', () => { - let transferAmount: BigNumber; - let transferBtcAddress = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; - let transferNonce = BigNumber.from(0); - let transferId: string; - - beforeEach(async () => { - transferAmount = parseEther('0.1'); - transferId = await createExampleTransfer( - anotherAccount, - transferAmount, - transferBtcAddress, - ); - }); - - describe('#markTransfersAsSending', () => { - let updateHashBytes: Uint8Array; - let btcTxHash: string = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; - - beforeEach(async () => { - const updateHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( - btcTxHash, - [transferId], - TRANSFER_STATUS_SENDING - ); - updateHashBytes = ethers.utils.arrayify(updateHash); - }); - - it('does not mark transfers as sent without signatures', async () => { - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending( - updateHashBytes, - [transferId], - [] - ) - ).to.be.reverted; - }); - - it('marks transfers as sent if signed by enough federators', async () => { - let transfer = await fastBtcBridgeFromFederator.getTransfer(transferBtcAddress, transferNonce); - expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); - - const signatures = [ - await federators[0].signMessage(updateHashBytes), - await federators[1].signMessage(updateHashBytes), - ]; - - const execution = fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - [transferId], - signatures - ) - - await expect(execution) - .to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferBatchSending') - .withArgs(btcTxHash, 1) - - await expect(execution) - .to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferStatusUpdated') - .withArgs(transferId, TRANSFER_STATUS_SENDING); - - transfer = await fastBtcBridgeFromFederator.getTransfer(transferBtcAddress, transferNonce); - expect(transfer.status).to.equal(TRANSFER_STATUS_SENDING); - - // test that it's no longer idempotent - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - [transferId], - signatures - ) - ).to.be.reverted; - }); - - it('does not mark transfers as sent if signed by too few federators', async () => { - const signatures = [ - await federators[0].signMessage(updateHashBytes), - ]; - - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - [transferId], - signatures - ) - ).to.be.reverted; - }); - - it('does not mark transfers as sent if signed by non-federators', async () => { - const signatures = [ - await federators[0].signMessage(updateHashBytes), - await anotherAccount.signMessage(updateHashBytes), - ]; - - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - [transferId], - signatures - ) - ).to.be.reverted; - }); - - it('does not mark transfers as sent if wrong hash signed', async () => { - const signatures = [ - await federators[0].signMessage(ethers.utils.arrayify( - await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( - btcTxHash, [transferId], TRANSFER_STATUS_NEW - ), - )), - await federators[1].signMessage(ethers.utils.arrayify( - await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( - btcTxHash, [transferId], TRANSFER_STATUS_NEW - ), - )), - ]; - - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending([transferId], signatures) - ).to.be.reverted; - }); - - it('checks the tx hash inside the update hash', async () => { - const signatures = [ - await federators[0].signMessage(ethers.utils.arrayify( - await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( - btcTxHash.replace(/6/, '7'), [transferId], TRANSFER_STATUS_SENDING - ), - )), - await federators[1].signMessage(ethers.utils.arrayify( - await fastBtcBridgeFromFederator.getTransferBatchUpdateHashWithTxHash( - btcTxHash.replace(/6/, '7'), [transferId], TRANSFER_STATUS_SENDING - ), - )), - ]; - - await expect( - fastBtcBridgeFromFederator.markTransfersAsSending(btcTxHash, [transferId], signatures) - ).to.be.reverted; - }); - }); - - describe('#refundTransfers', () => { - let updateSignatures: string[]; - - beforeEach(async () => { - const updateHash = await fastBtcBridge.getTransferBatchUpdateHash( - [transferId], - TRANSFER_STATUS_REFUNDED - ); - const updateHashBytes = ethers.utils.arrayify(updateHash); - - updateSignatures = [ - await federators[0].signMessage(updateHashBytes), - await federators[1].signMessage(updateHashBytes), - ]; - }); - - it('refunds transfer', async () => { - let transfer = await fastBtcBridge.getTransferByTransferId(transferId); - expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); - await expect( - await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) - ).to.changeEtherBalances( - [anotherAccount, fastBtcBridge], - [transferAmount, transferAmount.mul(-1)] - ); - transfer = await fastBtcBridge.getTransferByTransferId(transferId); - expect(transfer.status).to.equal(TRANSFER_STATUS_REFUNDED); - }); - - it('emits events', async () => { - await expect( - await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) - ).to.changeEtherBalances( - [anotherAccount, fastBtcBridge], - [transferAmount, transferAmount.mul(-1)] - ); - }); - - it('does not refund already refunded transfer', async () => { - await fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures); - await expect( - fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) - ).to.be.reverted; - }); - - it('does not refund sent transfers', async () => { - const btcTxHash = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; - const sentHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( - btcTxHash, - [transferId], - TRANSFER_STATUS_SENDING - ); - const sentHashBytes = ethers.utils.arrayify(sentHash); - const sentSignatures = [ - await federators[0].signMessage(sentHashBytes), - await federators[1].signMessage(sentHashBytes), - ]; - await fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - [transferId], - sentSignatures - ) - await expect( - fastBtcBridgeFromFederator.refundTransfers([transferId], updateSignatures) - ).to.be.reverted; - }); - }); - - describe('#reclaimTransfer', () => { - const requiredBlocks = 10; - let transfer: any; - let reclaimableBlock: number; - - beforeEach(async () => { - transfer = await fastBtcBridge.getTransferByTransferId(transferId); - await fastBtcBridge.setRequiredBlocksBeforeReclaim(requiredBlocks) - reclaimableBlock = transfer.blockNumber + requiredBlocks; - }); - - it("doesn't reclaim transfers when not enough blocks have passed", async () => { - await expect( - fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) - ).to.be.revertedWith("Not enough blocks passed before reclaim"); - // next block will be the block we mine to +1, so we mine to -2 - await mineToBlock(reclaimableBlock - 2); - await expect( - fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) - ).to.be.revertedWith("Not enough blocks passed before reclaim"); - }); - - it('reclaims transfer when enough block have passed', async () => { - expect(transfer.status).to.equal(TRANSFER_STATUS_NEW); - await mineToBlock(reclaimableBlock - 1); - await expect( - await fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) - ).to.changeEtherBalances( - [anotherAccount, fastBtcBridge], - [transferAmount, transferAmount.mul(-1)] - ); - transfer = await fastBtcBridge.getTransferByTransferId(transferId); - expect(transfer.status).to.equal(TRANSFER_STATUS_RECLAIMED); - - // cannot reclaim again - await expect( - fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) - ).to.be.reverted; - }); - - it('emits events', async () => { - await mineToBlock(reclaimableBlock); - await expect( - fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId) - ).to.emit(fastBtcBridgeFromFederator, 'BitcoinTransferStatusUpdated').withArgs( - transferId, - TRANSFER_STATUS_RECLAIMED - ); - }); - - it('only allows reclaiming own transfers', async () => { - await mineToBlock(reclaimableBlock); - await expect( - fastBtcBridge.reclaimTransfer(transferId) - ).to.be.revertedWith("Can only reclaim own transfers"); - }); - - it('does not reclaim when frozen', async () => { - await fastBtcBridge.connect(ownerAccount).freeze(); - await mineToBlock(reclaimableBlock); - await expect( - fastBtcBridge.reclaimTransfer(transferId) - ).to.be.revertedWith("Freezable: frozen"); - }); - }); - }); - - describe('#getTransferId', () => { - it('computes as expected', async () => { - for (const btcAddress of ['bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', '1foo', 'bar']) { - for (const nonce of [0, 1, 254]) { - const transferId = await fastBtcBridge.getTransferId(btcAddress, nonce); - const computed = ethers.utils.solidityKeccak256( - ['string', 'string', 'string', 'uint256'], - ['transfer:', btcAddress, ':', nonce] - ); - expect(transferId).to.equal(computed); - } - } - }) - }); - - describe("#addFeeStructure", () => { - it("requires owner", async () => { - await expect( - fastBtcBridge.connect(federators[0]).addFeeStructure(1, 5000, 10) - ).to.be.reverted; - - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(1, 5000, 10) - ).to.not.be.reverted; - }); - - it("fails for existing index", async () => { - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(0, 5000, 10) - ).to.be.reverted; - }); - - it("fails for invalid index", async () => { - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(255, 5000, 10) - ).to.not.be.reverted; - - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(256, 5000, 10) - ).to.be.reverted; - }); - }); - - describe("#setCurrentFeeStructure", () => { - it("requires owner", async () => { - await expect( - fastBtcBridge.connect(federators[0]).setCurrentFeeStructure(0) - ).to.be.reverted; - - await expect( - fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(0) - ).to.not.be.reverted; - }); - - it("fails for nonexistent index", async () => { - await expect( - fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(1) - ).to.be.reverted; - }); - - it("emits the event and sets variables and changes actual fees", async () => { - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(1, 1000, 10) - ).to.not.be.reverted; - - await expect( - fastBtcBridge.connect(ownerAccount).addFeeStructure(2, 2000, 20) - ).to.not.be.reverted; - let result = fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(1); - await expect(result).to.not.be.reverted; - await expect(result).to.emit( - fastBtcBridge, 'BitcoinTransferFeeChanged' - ).withArgs(1000, 10); + const receiverBalanceAfter = await ethers.provider.getBalance(receiverAddress); + const fastBtcBridgeBalanceAfter = await ethers.provider.getBalance(fastBtcBridge.address); - await expect(await fastBtcBridge.currentFeeStructureIndex()).to.equal(1); - await expect(await fastBtcBridge.baseFeeSatoshi()).to.equal(1000); - await expect(await fastBtcBridge.dynamicFee()).to.equal(10); - - await expect( - await fastBtcBridge.connect(anotherAccount).calculateCurrentFeeSatoshi(100000) - ).to.equal(1100); - - - result = fastBtcBridge.connect(ownerAccount).setCurrentFeeStructure(2); - await expect(result).to.not.be.reverted; - await expect(result).to.emit( - fastBtcBridge, 'BitcoinTransferFeeChanged' - ).withArgs(2000, 20); - - await expect(await fastBtcBridge.currentFeeStructureIndex()).to.equal(2); - await expect(await fastBtcBridge.baseFeeSatoshi()).to.equal(2000); - await expect(await fastBtcBridge.dynamicFee()).to.equal(20); - - await expect( - await fastBtcBridge.connect(anotherAccount).calculateCurrentFeeSatoshi(100000) - ).to.equal(2200); - }); - }); - - describe('bridge integration', () => { - beforeEach(async () => { - await ownerAccount.sendTransaction({ - value: parseUnits('1', 'ether'), - to: anotherAddress, - }); - fastBtcBridge = fastBtcBridge.connect(anotherAccount); + expect(receiverBalanceAfter.sub(receiverBalanceBefore)).to.equal(amount); + expect(fastBtcBridgeBalanceAfter.sub(fastBtcBridgeBalanceBefore)).to.equal(amount.mul('-1')); }); - it('encodes userData from token bridge', async () => { - const ret = await fastBtcBridge.encodeBridgeUserData( - '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - ); - - expect(ret).to.equal( - '0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c' + - '80000000000000000000000000000000000000000000000000000000000000040' + - '000000000000000000000000000000000000000000000000000000000000002a6' + - '263317177353038643671656a7874646734793572337a61727661727930633578' + - '77376b76386633743400000000000000000000000000000000000000000000' + it('cannot withdraw zero amount', async () => { + await expect(withdrawer.withdrawRbtcToReceiver(ZERO)).to.be.revertedWith( + 'cannot withdraw zero amount' ); }); - it('decodes userData from tokenBridge', async () => { - const [rskAddress, btcAddress] = await fastBtcBridge.decodeBridgeUserData( - '0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c' + - '80000000000000000000000000000000000000000000000000000000000000040' + - '000000000000000000000000000000000000000000000000000000000000002a6' + - '263317177353038643671656a7874646734793572337a61727661727930633578' + - '77376b76386633743400000000000000000000000000000000000000000000' + it('cannot withdraw more than maxWithdrawable', async () => { + await expect(withdrawer.withdrawRbtcToReceiver( + maxWithdrawable.add('1')) + ).to.be.revertedWith( + 'amount too high' ); - - expect(rskAddress).to.equal('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); - expect(btcAddress).to.equal('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'); }); - it('receiveEthFromBridge transfers rbtc', async () => { - const amountEther = parseEther('0.8'); - - const userData = await fastBtcBridge.encodeBridgeUserData( - '0x0000000000000000000000000000000000001337', - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - ); + 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( - await fastBtcBridge.receiveEthFromBridge( - userData, - { - value: amountEther, - } + withdrawer.withdrawRbtcToReceiver( + amount, ) - ).to.changeEtherBalances( - [anotherAccount, fastBtcBridge], - [amountEther.mul(-1), amountEther] + ).to.emit(withdrawer, 'Withdrawal').withArgs( + amount, ); - }); - it('receiveEthFromBridge emits the correct event', async () => { - const amountEther = parseEther('0.5'); - let amountSatoshi = BigNumber.from(Math.floor(0.5 * 10 ** 8)) - const feeSatoshi = await fastBtcBridge.calculateCurrentFeeSatoshi(amountSatoshi); - amountSatoshi = amountSatoshi.sub(feeSatoshi); + const currentTimestamp = (await ethers.provider.getBlock('latest')).timestamp; - const userData = await fastBtcBridge.encodeBridgeUserData( - '0x0000000000000000000000000000000000001337', - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', - ); + 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( - fastBtcBridge.receiveEthFromBridge( - userData, - { - value: amountEther, - } + withdrawer.withdrawRbtcToReceiver( + amount ) - ).to.emit(fastBtcBridge, 'NewBitcoinTransfer').withArgs( - await fastBtcBridge.getTransferId('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0), // bytes32 _transferId, - 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', // string _btcAddress, - BigNumber.from(0), // uint _nonce, - amountSatoshi, // uint _amountSatoshi, - feeSatoshi, // uint _feeSatoshi, - '0x0000000000000000000000000000000000001337', // address _rskAddress + ).to.be.revertedWith( + 'too soon' ); - }); - }); - describe('#withdrawTokens', () => { - const amount1 = parseEther('0.1'); - const amount2 = parseEther('0.05'); - const transferBtcAddress = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; - let transferId1: string; - let transferId2: string; - let transfer1: any; - let transfer2: any; - - const markTransfersAsSending = async (transferIds: string[]) => { - // Fake tx hash - const btcTxHash = '0x6162636465666768696a6b6c6d6e6f707172737475767778797a414243444546'; - const updateHash = await fastBtcBridge.getTransferBatchUpdateHashWithTxHash( - btcTxHash, - transferIds, - TRANSFER_STATUS_SENDING - ); - const updateHashBytes = ethers.utils.arrayify(updateHash); - const signatures = [ - await federators[0].signMessage(updateHashBytes), - await federators[1].signMessage(updateHashBytes), - ]; - await fastBtcBridgeFromFederator.markTransfersAsSending( - btcTxHash, - transferIds, - signatures - ); - } + const minTimeBetweenWithdrawals = await withdrawer.minTimeBetweenWithdrawals(); + await setNextBlockTimestamp(minTimeBetweenWithdrawals.add(currentTimestamp)); - beforeEach(async () => { - transferId1 = await createExampleTransfer( - anotherAccount, - amount1, - transferBtcAddress, - ); - transfer1 = await fastBtcBridge.getTransferByTransferId(transferId1); - transferId2 = await createExampleTransfer( - anotherAccount, - amount2, - transferBtcAddress, + await withdrawer.withdrawRbtcToReceiver( + amount, ); - transfer2 = await fastBtcBridge.getTransferByTransferId(transferId2); - }); - - it('cannot withdraw unsent rBTC', async () => { - await expect( - fastBtcBridge.withdrawRbtc(amount1, ownerAddress) - ).to.be.reverted; - }); - - it('can withdraw up to sent rBTC', async () => { - await markTransfersAsSending([transferId1]); - - await expect( - fastBtcBridge.withdrawRbtc(amount1.add(1), ownerAddress) - ).to.be.reverted; - - await expect( - await fastBtcBridge.withdrawRbtc(amount1, ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount1); - - await expect( - fastBtcBridge.withdrawRbtc(1, ownerAddress) - ).to.be.reverted; - - await markTransfersAsSending([transferId2]); - await expect( - await fastBtcBridge.withdrawRbtc(amount2, ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount2); - await expect( - fastBtcBridge.withdrawRbtc(1, ownerAddress) - ).to.be.reverted; + 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('can withdraw up to sent rBTC 2', async () => { - await markTransfersAsSending([transferId1]); - - await expect( - await fastBtcBridge.withdrawRbtc(amount1.div(2), ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount1.div(2)); + it('only a federator can withdraw', async () => { + const amount = parseEther('0.123') await expect( - fastBtcBridge.withdrawRbtc(amount1.div(2).add(1), ownerAddress) - ).to.be.reverted; - - await markTransfersAsSending([transferId2]); - - await expect( - await fastBtcBridge.withdrawRbtc(amount1.div(2).add(1), ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount1.div(2).add(1)); - - await expect( - fastBtcBridge.withdrawRbtc(amount2, ownerAddress) - ).to.be.reverted; - - await expect( - await fastBtcBridge.withdrawRbtc(amount2.sub(1), ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount2.sub(1)); - - await expect( - fastBtcBridge.withdrawRbtc(1, ownerAddress) + withdrawer.connect(ownerAccount).withdrawRbtcToReceiver( + amount + ) ).to.be.reverted; - }); - - it('cannot withdraw reclaimed transfers', async () => { - await fastBtcBridge.setRequiredBlocksBeforeReclaim(0) - await markTransfersAsSending([transferId1]); - await fastBtcBridge.connect(anotherAccount).reclaimTransfer(transferId2); await expect( - fastBtcBridge.withdrawRbtc(amount1.add(1), ownerAddress) + withdrawer.connect(anotherAccount).withdrawRbtcToReceiver( + amount + ) ).to.be.reverted; - await expect( - await fastBtcBridge.withdrawRbtc(amount1, ownerAddress) - ).to.changeEtherBalance(ownerAccount, amount1); - - await expect( - fastBtcBridge.withdrawRbtc(1, ownerAddress) - ).to.be.reverted; + // no revert here: + await withdrawer.connect(federators[1]).withdrawRbtcToReceiver( + amount + ); }); }); - describe.only('#setNodeConfigValue', () => { - const key1 = '0x1234567812345678123456781234567812345678123456781234567812345678'; - const key2 = '0x8765432112345678123456781234567812345678123456781234567812345678'; - const value1 = '0x1234'; - const value2 = '0x4321'; - - it('is not allowed for arbitrary account', async () => { - await expect( - fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1) - ).to.be.reverted; - }); - - it('is allowed for owner', async () => { - await fastBtcBridge.setNodeConfigValue(key1, value1); - }); + async function fundFastBtcBridge( + amount: BigNumber + ): Promise { + const transferId = await createExampleTransfer( + anotherAccount, + amount, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' + ); - it('sets keys separately', async () => { - await fastBtcBridge.setNodeConfigValue(key1, value1); - await fastBtcBridge.setNodeConfigValue(key2, value2); - expect(await fastBtcBridge.nodeConfig(key1)).to.equal(value1); - expect(await fastBtcBridge.nodeConfig(key2)).to.equal(value2); - await fastBtcBridge.setNodeConfigValue(key2, value1); - expect(await fastBtcBridge.nodeConfig(key2)).to.equal(value1); - }); + 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 + ) + } - it('is allowed for arbitrary account after grant and not after revoke', async () => { - await accessControl.addConfigAdmin(anotherAccount.getAddress()); - await fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1); - await accessControl.removeConfigAdmin(anotherAccount.getAddress()); - await expect(fastBtcBridge.connect(anotherAccount).setNodeConfigValue(key1, value1)).to.be.reverted; + 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, + } + ); - it('can be deleted only by authorized', async () => { - await fastBtcBridge.setNodeConfigValue(key1, value1); - await expect(fastBtcBridge.connect(anotherAccount).deleteNodeConfigValue(key1)).to.be.reverted; - await accessControl.addConfigAdmin(anotherAccount.getAddress()); - await fastBtcBridge.connect(anotherAccount).deleteNodeConfigValue(key1); - expect(await fastBtcBridge.nodeConfig(key1)).to.equal('0x'); - }); - }); + return await fastBtcBridge.getTransferId(transferBtcAddress, nonce); + } }); diff --git a/packages/fastbtc-contracts/test/utils.ts b/packages/fastbtc-contracts/test/utils.ts index e69de29..665ea3e 100644 --- a/packages/fastbtc-contracts/test/utils.ts +++ 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() + ]); +} From 07ff330222fe4578c60b4ce0e4a748070d7a8ad6 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Mon, 17 Apr 2023 15:07:00 +0300 Subject: [PATCH 04/10] Withdrawer backend integration --- packages/fastbtc-node/src/config.ts | 8 + packages/fastbtc-node/src/core/node.ts | 8 + packages/fastbtc-node/src/inversify.config.ts | 2 + .../src/withdrawer/abi/Withdrawer.json | 226 ++++++++++++++++++ packages/fastbtc-node/src/withdrawer/index.ts | 8 + .../fastbtc-node/src/withdrawer/withdrawer.ts | 108 +++++++++ 6 files changed, 360 insertions(+) create mode 100644 packages/fastbtc-node/src/withdrawer/abi/Withdrawer.json create mode 100644 packages/fastbtc-node/src/withdrawer/index.ts create mode 100644 packages/fastbtc-node/src/withdrawer/withdrawer.ts 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(); + } + } +} From 64de9f5bcaa506adbd30b70f86e0ad510114cdc2 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Mon, 17 Apr 2023 16:00:01 +0300 Subject: [PATCH 05/10] Withdrawer deploy script --- .../deploy/04_deploy_withdrawer.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/fastbtc-contracts/deploy/04_deploy_withdrawer.ts 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; From bb184d4f94bdc93edf5c3c3015d62ce6c74d1428 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Wed, 19 Apr 2023 15:07:38 +0300 Subject: [PATCH 06/10] Handle newer and older version of the FastBTCBridge contract --- packages/fastbtc-contracts/contracts/Withdrawer.sol | 8 +++++++- .../contracts/interfaces/IWithdrawerFastBTCBridge.sol | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/fastbtc-contracts/contracts/Withdrawer.sol b/packages/fastbtc-contracts/contracts/Withdrawer.sol index cb3ee50..0d70168 100644 --- a/packages/fastbtc-contracts/contracts/Withdrawer.sol +++ b/packages/fastbtc-contracts/contracts/Withdrawer.sol @@ -120,7 +120,13 @@ contract Withdrawer is FastBTCAccessControllable { return 0; } - withdrawable = address(fastBtcBridge).balance; + /// @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; } diff --git a/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol index f852eed..0ab13ae 100644 --- a/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol +++ b/packages/fastbtc-contracts/contracts/interfaces/IWithdrawerFastBTCBridge.sol @@ -15,4 +15,7 @@ interface IWithdrawerFastBTCBridge { 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); } From 6bcc982db9b7979e6e83c3d675c2c270cb8a28e4 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Wed, 19 Apr 2023 15:08:15 +0300 Subject: [PATCH 07/10] Add withdrawer address to hardhat config --- packages/fastbtc-contracts/hardhat.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/fastbtc-contracts/hardhat.config.ts b/packages/fastbtc-contracts/hardhat.config.ts index 70e2121..1ea2b3c 100644 --- a/packages/fastbtc-contracts/hardhat.config.ts +++ b/packages/fastbtc-contracts/hardhat.config.ts @@ -15,6 +15,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) { From de72748ea84fa442bf567e527a4b20855421c14d Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Wed, 19 Apr 2023 15:19:17 +0300 Subject: [PATCH 08/10] Withdrawer integration tests --- Makefile | 5 +++++ README.md | 10 ++++++++++ integration_test/nodes/docker-env-common | 4 ++++ integration_test/scripts/test_withdrawer.sh | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100755 integration_test/scripts/test_withdrawer.sh 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 From ed6e17d3abccbc747d72cb7b737f8d5acc376da2 Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Wed, 19 Apr 2023 15:44:24 +0300 Subject: [PATCH 09/10] This error message should be the other way around, no? --- packages/fastbtc-contracts/contracts/FastBTCBridge.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fastbtc-contracts/contracts/FastBTCBridge.sol b/packages/fastbtc-contracts/contracts/FastBTCBridge.sol index 33c2764..9e5edeb 100644 --- a/packages/fastbtc-contracts/contracts/FastBTCBridge.sol +++ b/packages/fastbtc-contracts/contracts/FastBTCBridge.sol @@ -775,7 +775,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); From d12cb38897915bfdecce829f2cb3403673835b0d Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Thu, 20 Apr 2023 13:57:38 +0300 Subject: [PATCH 10/10] More Withdrawer tests --- .../fastbtc-contracts/test/Withdrawer.test.ts | 165 +++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/packages/fastbtc-contracts/test/Withdrawer.test.ts b/packages/fastbtc-contracts/test/Withdrawer.test.ts index ffca4c4..986b7fa 100644 --- a/packages/fastbtc-contracts/test/Withdrawer.test.ts +++ b/packages/fastbtc-contracts/test/Withdrawer.test.ts @@ -7,7 +7,6 @@ import { setNextBlockTimestamp } from './utils'; const TRANSFER_STATUS_SENDING = 2; -const ZERO = BigNumber.from('0') const ONE_BTC_IN_SATOSHI = BigNumber.from('10').pow('8'); const ONE_SATOSHI_IN_WEI = parseEther('1').div(ONE_BTC_IN_SATOSHI); @@ -104,7 +103,7 @@ describe("Withdrawer", function() { }); it('cannot withdraw zero amount', async () => { - await expect(withdrawer.withdrawRbtcToReceiver(ZERO)).to.be.revertedWith( + await expect(withdrawer.withdrawRbtcToReceiver(0)).to.be.revertedWith( 'cannot withdraw zero amount' ); }); @@ -181,6 +180,168 @@ describe("Withdrawer", function() { }); }); + 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 {