diff --git a/solidity/contracts/bank/Bank.sol b/solidity/contracts/bank/Bank.sol index 183342dfc..38c84e01c 100644 --- a/solidity/contracts/bank/Bank.sol +++ b/solidity/contracts/bank/Bank.sol @@ -141,7 +141,7 @@ contract Bank is Ownable { function approveBalanceAndCall( address spender, uint256 amount, - bytes memory extraData + bytes calldata extraData ) external { _approveBalance(msg.sender, spender, amount); IReceiveBalanceApproval(spender).receiveBalanceApproval( diff --git a/solidity/contracts/bank/IReceiveBalanceApproval.sol b/solidity/contracts/bank/IReceiveBalanceApproval.sol index d7cd1767c..e67297f42 100644 --- a/solidity/contracts/bank/IReceiveBalanceApproval.sol +++ b/solidity/contracts/bank/IReceiveBalanceApproval.sol @@ -30,7 +30,7 @@ interface IReceiveBalanceApproval { /// @param amount The amount of the Bank balance approved by the owner /// to be used by the contract. /// @param extraData The `extraData` passed to `Bank.approveBalanceAndCall`. - // @dev The implementation must ensure this function can only be called + /// @dev The implementation must ensure this function can only be called /// by the Bank. The Bank does _not_ guarantee that the `amount` /// approved by the `owner` currently exists on their balance. That is, /// the `owner` could approve more balance than they currently have. @@ -40,6 +40,6 @@ interface IReceiveBalanceApproval { function receiveBalanceApproval( address owner, uint256 amount, - bytes memory extraData + bytes calldata extraData ) external; } diff --git a/solidity/contracts/vault/TBTCVault.sol b/solidity/contracts/vault/TBTCVault.sol index 0f852f3ba..d3d1e714c 100644 --- a/solidity/contracts/vault/TBTCVault.sol +++ b/solidity/contracts/vault/TBTCVault.sol @@ -110,7 +110,7 @@ contract TBTCVault is IVault, Governable { function receiveBalanceApproval( address owner, uint256 amount, - bytes memory + bytes calldata ) external override onlyBank { require( bank.balanceOf(owner) >= amount, @@ -136,7 +136,7 @@ contract TBTCVault is IVault, Governable { } } - /// @notice Burns `amount` of TBTC from the caller's account and transfers + /// @notice Burns `amount` of TBTC from the caller's balance and transfers /// `amount` back to the caller's balance in the Bank. /// @dev Caller must have at least `amount` of TBTC approved to /// TBTC Vault. @@ -145,23 +145,51 @@ contract TBTCVault is IVault, Governable { _unmint(msg.sender, amount); } - /// @notice Burns `amount` of TBTC from the caller's account and transfers - /// `amount` back to the caller's balance in the Bank. - /// @dev This function is doing the same as `unmint` but it allows to - /// execute unminting without an additional approval transaction. - /// The function can be called only via `approveAndCall` of TBTC token. + /// @notice Burns `amount` of TBTC from the caller's balance and transfers + /// `amount` of Bank balance to the Bridge requesting redemption + /// based on the provided `redemptionData`. + /// @dev Caller must have at least `amount` of TBTC approved to + /// TBTC Vault. + /// @param amount Amount of TBTC to unmint and request to redeem in Bridge. + /// @param redemptionData Redemption data in a format expected from + /// `redemptionData` parameter of Bridge's `receiveBalanceApproval` + /// function. + function unmintAndRedeem(uint256 amount, bytes calldata redemptionData) + external + { + _unmintAndRedeem(msg.sender, amount, redemptionData); + } + + /// @notice Burns `amount` of TBTC from the caller's balance. If `extraData` + /// is empty, transfers `amount` back to the caller's balance in the + /// Bank. If `extraData` is not empty, requests redemption in the + /// Bridge using the `extraData` as a `redemptionData` parameter to + /// Bridge's `receiveBalanceApproval` function. + /// @dev This function is doing the same as `unmint` or `unmintAndRedeem` + /// (depending on `extraData` parameter) but it allows to execute + /// unminting without a separate approval transaction. The function can + /// be called only via `approveAndCall` of TBTC token. /// @param from TBTC token holder executing unminting. /// @param amount Amount of TBTC to unmint. /// @param token TBTC token address. + /// @param extraData Redemption data in a format expected from + /// `redemptionData` parameter of Bridge's `receiveBalanceApproval` + /// function. If empty, `receiveApproval` is not requesting a + /// redemption of Bank balance but is instead performing just TBTC + /// unminting to a Bank balance. function receiveApproval( address from, uint256 amount, address token, - bytes calldata + bytes calldata extraData ) external { require(token == address(tbtcToken), "Token is not TBTC"); require(msg.sender == token, "Only TBTC caller allowed"); - _unmint(from, amount); + if (extraData.length == 0) { + _unmint(from, amount); + } else { + _unmintAndRedeem(from, amount, extraData); + } } // slither-disable-next-line calls-loop @@ -175,4 +203,14 @@ contract TBTCVault is IVault, Governable { tbtcToken.burnFrom(unminter, amount); bank.transferBalance(unminter, amount); } + + function _unmintAndRedeem( + address redeemer, + uint256 amount, + bytes calldata redemptionData + ) internal { + emit Unminted(redeemer, amount); + tbtcToken.burnFrom(redeemer, amount); + bank.approveBalanceAndCall(bank.bridge(), amount, redemptionData); + } } diff --git a/solidity/test/bridge/VendingMachine.test.ts b/solidity/test/bridge/VendingMachine.test.ts index 6d17ed9cf..8434208ce 100644 --- a/solidity/test/bridge/VendingMachine.test.ts +++ b/solidity/test/bridge/VendingMachine.test.ts @@ -9,7 +9,7 @@ import { to1ePrecision, getBlockTime, } from "../helpers/contract-test-helpers" -import vendingMachineFixture from "../fixtures/vendingMachine" +import bridgeFixture from "../fixtures/bridge" const ZERO_ADDRESS = ethers.constants.AddressZero @@ -41,10 +41,10 @@ describe("VendingMachine", () => { thirdParty, ] = await helpers.signers.getUnnamedSigners() - // eslint-disable-next-line @typescript-eslint/no-extra-semi - ;({ tbtcV1, tbtcV2, vendingMachine } = await waffle.loadFixture( - vendingMachineFixture - )) + await waffle.loadFixture(bridgeFixture) + tbtcV1 = await helpers.contracts.getContract("TBTCToken") + tbtcV2 = await helpers.contracts.getContract("TBTC") + vendingMachine = await helpers.contracts.getContract("VendingMachine") await tbtcV1 .connect(deployer) diff --git a/solidity/test/fixtures/bridge.ts b/solidity/test/fixtures/bridge.ts index 2a58bea3e..c4b35415c 100644 --- a/solidity/test/fixtures/bridge.ts +++ b/solidity/test/fixtures/bridge.ts @@ -7,6 +7,8 @@ import type { BridgeStub, IWalletRegistry, TestRelay, + TBTC, + TBTCVault, } from "../../typechain" /** @@ -19,6 +21,10 @@ export default async function bridgeFixture() { await helpers.signers.getNamedSigners() const [thirdParty] = await helpers.signers.getUnnamedSigners() + const tbtc: TBTC = await helpers.contracts.getContract("TBTC") + + const tbtcVault: TBTCVault = await helpers.contracts.getContract("TBTCVault") + const bank: Bank & BankStub = await helpers.contracts.getContract("Bank") const bridge: Bridge & BridgeStub = await helpers.contracts.getContract( @@ -58,6 +64,8 @@ export default async function bridgeFixture() { governance, thirdParty, treasury, + tbtc, + tbtcVault, bank, relay, walletRegistry, diff --git a/solidity/test/fixtures/vendingMachine.ts b/solidity/test/fixtures/vendingMachine.ts deleted file mode 100644 index 61ece7474..000000000 --- a/solidity/test/fixtures/vendingMachine.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { deployments, helpers } from "hardhat" -import { TestERC20, TBTC, VendingMachine } from "../../typechain" - -// eslint-disable-next-line import/prefer-default-export -export default async function vendingMachineFixture(): Promise<{ - tbtcV1: TestERC20 - tbtcV2: TBTC - vendingMachine: VendingMachine -}> { - await deployments.fixture("VendingMachine") - - const tbtcV1: TestERC20 = await helpers.contracts.getContract("TBTCToken") - const tbtcV2: TBTC = await helpers.contracts.getContract("TBTC") - - const vendingMachine: VendingMachine = await helpers.contracts.getContract( - "VendingMachine" - ) - - return { tbtcV1, tbtcV2, vendingMachine } -} diff --git a/solidity/test/vault/TBTCVault.Redemption.test.ts b/solidity/test/vault/TBTCVault.Redemption.test.ts new file mode 100644 index 000000000..65237ebc2 --- /dev/null +++ b/solidity/test/vault/TBTCVault.Redemption.test.ts @@ -0,0 +1,623 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" +import { expect } from "chai" +import { ContractTransaction } from "ethers" +import { BytesLike } from "@ethersproject/bytes" + +import { walletState } from "../fixtures" +import bridgeFixture from "../fixtures/bridge" + +import type { + Bank, + BankStub, + Bridge, + BridgeStub, + TBTC, + TBTCVault, + VendingMachine, +} from "../../typechain" + +const { to1e18 } = helpers.number +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { increaseTime, lastBlockTime } = helpers.time +const { defaultAbiCoder } = ethers.utils + +describe("TBTCVault - Redemption", () => { + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const mainUtxo = { + txHash: + "0x3835ecdee2daa83c9a19b5012104ace55ecab197b5e16489c26d372e475f5d2a", + txOutputIndex: 0, + txOutputValue: 10000000, + } + + let bridge: Bridge & BridgeStub + let bank: Bank & BankStub + let tbtc: TBTC + let tbtcVault: TBTCVault + + let deployer: SignerWithAddress + let account1: SignerWithAddress + let account2: SignerWithAddress + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ deployer } = await helpers.signers.getNamedSigners()) + + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ bridge, bank, tbtcVault, tbtc } = await waffle.loadFixture( + bridgeFixture + )) + + // Deployment scripts deploy both `VendingMachine` and `TBTCVault` but they + // do not transfer the ownership of `TBTC` token to `TBTCVault`. + // We need to do it manually in tests covering `TBTCVault` behavior. + // Also, please note that `03_transfer_roles.ts` assigning `VendingMachine` + // upgrade initiator role to Keep Technical Wallet is skipped for Hardhat + // env deployment. That's why the upgrade initiator and `VendingMachine` + // owner is the deployer. + const vendingMachine: VendingMachine = await helpers.contracts.getContract( + "VendingMachine" + ) + await vendingMachine + .connect(deployer) + .initiateVendingMachineUpgrade(tbtcVault.address) + await increaseTime(await vendingMachine.GOVERNANCE_DELAY()) + await vendingMachine.connect(deployer).finalizeVendingMachineUpgrade() + + const accounts = await getUnnamedAccounts() + account1 = await ethers.getSigner(accounts[0]) + account2 = await ethers.getSigner(accounts[1]) + + const initialBankBalance = to1e18(100) + await bank.setBalance(account1.address, initialBankBalance) + await bank.setBalance(account2.address, initialBankBalance) + await bank + .connect(account1) + .approveBalance(tbtcVault.address, initialBankBalance) + await bank + .connect(account2) + .approveBalance(tbtcVault.address, initialBankBalance) + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.constants.HashZero, + mainUtxoHash: ethers.constants.HashZero, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ethers.constants.HashZero, + }) + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + }) + + describe("unmintAndRedeem", () => { + const requestRedemption = async ( + redeemer: SignerWithAddress, + redeemerOutputScript: string, + amount: number + ): Promise => { + const data = defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.address, + walletPubKeyHash, + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue, + redeemerOutputScript, + ] + ) + + return tbtcVault.connect(redeemer).unmintAndRedeem(amount, data) + } + + context("when the redeemer has no TBTC", () => { + const amount = to1e18(1) + before(async () => { + await createSnapshot() + + await tbtc.connect(account1).approve(tbtcVault.address, amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + tbtcVault.connect(account1).unmintAndRedeem(to1e18(1), []) + ).to.be.revertedWith("Burn amount exceeds balance") + }) + }) + + context("when the redeemer has not enough TBTC", () => { + const mintedAmount = to1e18(1) + const redeemedAmount = mintedAmount.add(1) + + before(async () => { + await createSnapshot() + + await tbtcVault.connect(account1).mint(mintedAmount) + await tbtc.connect(account1).approve(tbtcVault.address, redeemedAmount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + tbtcVault.connect(account1).unmintAndRedeem(redeemedAmount, []) + ).to.be.revertedWith("Burn amount exceeds balance") + }) + }) + + context("when there is a single redeemer", () => { + const redeemerOutputScriptP2WPKH = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + const redeemerOutputScriptP2WSH = + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c" + const redeemerOutputScriptP2PKH = + "0x1976a914f4eedc8f40d4b8e30771f792b065ebec0abaddef88ac" + const redeemerOutputScriptP2SH = + "0x17a914f4eedc8f40d4b8e30771f792b065ebec0abaddef87" + + const mintedAmount = 10000000 + const redeemedAmount1 = 1000000 + const redeemedAmount2 = 2000000 + const redeemedAmount3 = 3000000 + const redeemedAmount4 = 1500000 + const totalRedeemedAmount = + redeemedAmount1 + redeemedAmount2 + redeemedAmount3 + redeemedAmount4 + const notRedeemedAmount = mintedAmount - totalRedeemedAmount + + const transactions: ContractTransaction[] = [] + + before(async () => { + await createSnapshot() + + await tbtcVault.connect(account1).mint(mintedAmount) + await tbtc.connect(account1).approve(tbtcVault.address, mintedAmount) + + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WPKH, + redeemedAmount1 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WSH, + redeemedAmount2 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2PKH, + redeemedAmount3 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2SH, + redeemedAmount4 + ) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer balances to Bridge", async () => { + expect(await bank.balanceOf(tbtcVault.address)).to.equal( + notRedeemedAmount + ) + expect(await bank.balanceOf(bridge.address)).to.equal( + totalRedeemedAmount + ) + }) + + it("should request redemptions in Bridge", async () => { + const redemptionRequest1 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WPKH) + ) + expect(redemptionRequest1.redeemer).to.be.equal(account1.address) + expect(redemptionRequest1.requestedAmount).to.be.equal(redeemedAmount1) + + const redemptionRequest2 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WSH) + ) + expect(redemptionRequest2.redeemer).to.be.equal(account1.address) + expect(redemptionRequest2.requestedAmount).to.be.equal(redeemedAmount2) + + const redemptionRequest3 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2PKH) + ) + expect(redemptionRequest3.redeemer).to.be.equal(account1.address) + expect(redemptionRequest3.requestedAmount).to.be.equal(redeemedAmount3) + + const redemptionRequest4 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2SH) + ) + expect(redemptionRequest4.redeemer).to.be.equal(account1.address) + expect(redemptionRequest4.requestedAmount).to.be.equal(redeemedAmount4) + }) + + it("should burn TBTC", async () => { + expect(await tbtc.balanceOf(account1.address)).to.equal( + notRedeemedAmount + ) + expect(await tbtc.totalSupply()).to.be.equal(notRedeemedAmount) + }) + + it("should emit Unminted events", async () => { + await expect(transactions[0]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount1) + await expect(transactions[1]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount2) + await expect(transactions[2]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount3) + await expect(transactions[3]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount4) + }) + }) + + context("when there are multiple redeemers", () => { + const redeemerOutputScriptP2WPKH = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + const redeemerOutputScriptP2WSH = + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c" + + const mintedAmount1 = 10000000 + const mintedAmount2 = 20000000 + const redeemedAmount1 = 1000000 + const redeemedAmount2 = 2000000 + + const totalMintedAmount = mintedAmount1 + mintedAmount2 + const totalRedeemedAmount = redeemedAmount1 + redeemedAmount2 + const totalNotRedeemedAmount = totalMintedAmount - totalRedeemedAmount + + const transactions: ContractTransaction[] = [] + + before(async () => { + await createSnapshot() + + await tbtcVault.connect(account1).mint(mintedAmount1) + await tbtc.connect(account1).approve(tbtcVault.address, mintedAmount1) + + await tbtcVault.connect(account2).mint(mintedAmount2) + await tbtc.connect(account2).approve(tbtcVault.address, mintedAmount2) + + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WPKH, + redeemedAmount1 + ) + ) + transactions.push( + await requestRedemption( + account2, + redeemerOutputScriptP2WSH, + redeemedAmount2 + ) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer balances to Bridge", async () => { + expect(await bank.balanceOf(tbtcVault.address)).to.equal( + totalNotRedeemedAmount + ) + expect(await bank.balanceOf(bridge.address)).to.equal( + totalRedeemedAmount + ) + }) + + it("should request redemptions in Bridge", async () => { + const redemptionRequest1 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WPKH) + ) + expect(redemptionRequest1.redeemer).to.be.equal(account1.address) + expect(redemptionRequest1.requestedAmount).to.be.equal(redeemedAmount1) + + const redemptionRequest2 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WSH) + ) + expect(redemptionRequest2.redeemer).to.be.equal(account2.address) + expect(redemptionRequest2.requestedAmount).to.be.equal(redeemedAmount2) + }) + + it("should burn TBTC", async () => { + expect(await tbtc.balanceOf(account1.address)).to.equal( + mintedAmount1 - redeemedAmount1 + ) + expect(await tbtc.balanceOf(account2.address)).to.equal( + mintedAmount2 - redeemedAmount2 + ) + expect(await tbtc.totalSupply()).to.be.equal(totalNotRedeemedAmount) + }) + + it("should emit Unminted events", async () => { + await expect(transactions[0]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount1) + await expect(transactions[1]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account2.address, redeemedAmount2) + }) + }) + }) + + describe("receiveApproval", () => { + const requestRedemption = async ( + redeemer: SignerWithAddress, + redeemerOutputScript: string, + amount: number + ): Promise => { + const data = defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.address, + walletPubKeyHash, + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue, + redeemerOutputScript, + ] + ) + + return tbtc + .connect(redeemer) + .approveAndCall(tbtcVault.address, amount, data) + } + + context("when called via approveAndCall", () => { + context("when called with non-empty extraData", () => { + context("when there is a single redeemer", () => { + const redeemerOutputScriptP2WPKH = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + const redeemerOutputScriptP2WSH = + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c" + const redeemerOutputScriptP2PKH = + "0x1976a914f4eedc8f40d4b8e30771f792b065ebec0abaddef88ac" + const redeemerOutputScriptP2SH = + "0x17a914f4eedc8f40d4b8e30771f792b065ebec0abaddef87" + + const mintedAmount = 10000000 + const redeemedAmount1 = 1000000 + const redeemedAmount2 = 2000000 + const redeemedAmount3 = 3000000 + const redeemedAmount4 = 1500000 + const totalRedeemedAmount = + redeemedAmount1 + + redeemedAmount2 + + redeemedAmount3 + + redeemedAmount4 + const notRedeemedAmount = mintedAmount - totalRedeemedAmount + + const transactions: ContractTransaction[] = [] + + before(async () => { + await createSnapshot() + + await tbtcVault.connect(account1).mint(mintedAmount) + + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WPKH, + redeemedAmount1 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WSH, + redeemedAmount2 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2PKH, + redeemedAmount3 + ) + ) + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2SH, + redeemedAmount4 + ) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer balances to Bridge", async () => { + expect(await bank.balanceOf(tbtcVault.address)).to.equal( + notRedeemedAmount + ) + expect(await bank.balanceOf(bridge.address)).to.equal( + totalRedeemedAmount + ) + }) + + it("should request redemptions in Bridge", async () => { + const redemptionRequest1 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WPKH) + ) + expect(redemptionRequest1.redeemer).to.be.equal(account1.address) + expect(redemptionRequest1.requestedAmount).to.be.equal( + redeemedAmount1 + ) + + const redemptionRequest2 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WSH) + ) + expect(redemptionRequest2.redeemer).to.be.equal(account1.address) + expect(redemptionRequest2.requestedAmount).to.be.equal( + redeemedAmount2 + ) + + const redemptionRequest3 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2PKH) + ) + expect(redemptionRequest3.redeemer).to.be.equal(account1.address) + expect(redemptionRequest3.requestedAmount).to.be.equal( + redeemedAmount3 + ) + + const redemptionRequest4 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2SH) + ) + expect(redemptionRequest4.redeemer).to.be.equal(account1.address) + expect(redemptionRequest4.requestedAmount).to.be.equal( + redeemedAmount4 + ) + }) + + it("should burn TBTC", async () => { + expect(await tbtc.balanceOf(account1.address)).to.equal( + notRedeemedAmount + ) + expect(await tbtc.totalSupply()).to.be.equal(notRedeemedAmount) + }) + + it("should emit Unminted events", async () => { + await expect(transactions[0]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount1) + await expect(transactions[1]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount2) + await expect(transactions[2]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount3) + await expect(transactions[3]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount4) + }) + }) + + context("when there are multiple redeemers", () => { + const redeemerOutputScriptP2WPKH = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + const redeemerOutputScriptP2WSH = + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c" + + const mintedAmount1 = 10000000 + const mintedAmount2 = 20000000 + const redeemedAmount1 = 1000000 + const redeemedAmount2 = 2000000 + + const totalMintedAmount = mintedAmount1 + mintedAmount2 + const totalRedeemedAmount = redeemedAmount1 + redeemedAmount2 + const totalNotRedeemedAmount = totalMintedAmount - totalRedeemedAmount + + const transactions: ContractTransaction[] = [] + + before(async () => { + await createSnapshot() + + await tbtcVault.connect(account1).mint(mintedAmount1) + await tbtcVault.connect(account2).mint(mintedAmount2) + + transactions.push( + await requestRedemption( + account1, + redeemerOutputScriptP2WPKH, + redeemedAmount1 + ) + ) + transactions.push( + await requestRedemption( + account2, + redeemerOutputScriptP2WSH, + redeemedAmount2 + ) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer balances to Bridge", async () => { + expect(await bank.balanceOf(tbtcVault.address)).to.equal( + totalNotRedeemedAmount + ) + expect(await bank.balanceOf(bridge.address)).to.equal( + totalRedeemedAmount + ) + }) + + it("should request redemptions in Bridge", async () => { + const redemptionRequest1 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WPKH) + ) + expect(redemptionRequest1.redeemer).to.be.equal(account1.address) + expect(redemptionRequest1.requestedAmount).to.be.equal( + redeemedAmount1 + ) + + const redemptionRequest2 = await bridge.pendingRedemptions( + buildRedemptionKey(walletPubKeyHash, redeemerOutputScriptP2WSH) + ) + expect(redemptionRequest2.redeemer).to.be.equal(account2.address) + expect(redemptionRequest2.requestedAmount).to.be.equal( + redeemedAmount2 + ) + }) + + it("should burn TBTC", async () => { + expect(await tbtc.balanceOf(account1.address)).to.equal( + mintedAmount1 - redeemedAmount1 + ) + expect(await tbtc.balanceOf(account2.address)).to.equal( + mintedAmount2 - redeemedAmount2 + ) + expect(await tbtc.totalSupply()).to.be.equal(totalNotRedeemedAmount) + }) + + it("should emit Unminted events", async () => { + await expect(transactions[0]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account1.address, redeemedAmount1) + await expect(transactions[1]) + .to.emit(tbtcVault, "Unminted") + .withArgs(account2.address, redeemedAmount2) + }) + }) + }) + }) + }) +}) + +function buildRedemptionKey( + walletPubKeyHash: BytesLike, + redeemerOutputScript: BytesLike +): string { + return ethers.utils.solidityKeccak256( + ["bytes20", "bytes"], + [walletPubKeyHash, redeemerOutputScript] + ) +} diff --git a/solidity/test/vault/TBTCVault.test.ts b/solidity/test/vault/TBTCVault.test.ts index 6fb1412e5..85b41285f 100644 --- a/solidity/test/vault/TBTCVault.test.ts +++ b/solidity/test/vault/TBTCVault.test.ts @@ -505,44 +505,47 @@ describe("TBTCVault", () => { }) context("when called via approveAndCall", () => { - const mintedAmount = to1e18(10) - const unmintedAmount = to1e18(4) - const notUnmintedAmount = mintedAmount.sub(unmintedAmount) + context("when called with an empty extraData", () => { + const mintedAmount = to1e18(10) + const unmintedAmount = to1e18(4) + const notUnmintedAmount = mintedAmount.sub(unmintedAmount) - let tx + let tx: ContractTransaction - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - await vault.connect(account1).mint(mintedAmount) - await tbtc.connect(account1).approve(vault.address, unmintedAmount) - tx = await tbtc - .connect(account1) - .approveAndCall(vault.address, unmintedAmount, []) - }) + await vault.connect(account1).mint(mintedAmount) + tx = await tbtc + .connect(account1) + .approveAndCall(vault.address, unmintedAmount, []) + }) - after(async () => { - await restoreSnapshot() - }) + after(async () => { + await restoreSnapshot() + }) - it("should transfer balance to the unminter", async () => { - expect(await bank.balanceOf(vault.address)).to.equal(notUnmintedAmount) - expect(await bank.balanceOf(account1.address)).to.equal( - initialBalance.sub(notUnmintedAmount) - ) - }) - - it("should burn TBTC", async () => { - expect(await tbtc.balanceOf(account1.address)).to.equal( - notUnmintedAmount - ) - expect(await tbtc.totalSupply()).to.be.equal(notUnmintedAmount) - }) + it("should transfer balance to the unminter", async () => { + expect(await bank.balanceOf(vault.address)).to.equal( + notUnmintedAmount + ) + expect(await bank.balanceOf(account1.address)).to.equal( + initialBalance.sub(notUnmintedAmount) + ) + }) - it("should emit Unminted event", async () => { - await expect(tx) - .to.emit(vault, "Unminted") - .withArgs(account1.address, unmintedAmount) + it("should burn TBTC", async () => { + expect(await tbtc.balanceOf(account1.address)).to.equal( + notUnmintedAmount + ) + expect(await tbtc.totalSupply()).to.be.equal(notUnmintedAmount) + }) + + it("should emit Unminted event", async () => { + await expect(tx) + .to.emit(vault, "Unminted") + .withArgs(account1.address, unmintedAmount) + }) }) }) })