diff --git a/contracts/TokenVault.sol b/contracts/TokenVault.sol new file mode 100644 index 0000000..817b611 --- /dev/null +++ b/contracts/TokenVault.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.6.12; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title TokenVault + * @dev This is a holder contract for Tokens which will be deployed on the 'master' chain (Ethereum). + * + * When a user transfers Tokens from the master chain to another chain + * through a bridge, Tokens are 'locked' in this vault contract + * and 'bridge-secured' Tokens (xc-tokens) are 'mint' on the other chain. + * This vault contract transfers Tokens from the depositor's wallet to itself. + * + * When a user transfers xc-tokens from another chain back to the master chain + * through a chain-bridge instance, xc-tokens are 'burnt' on the other chain + * and locked Tokens are 'unlocked' from this vault contract on the master chain. + * The vault contract transfers Tokens from itself to the recipient's wallet. + * + * The vault owner curates a list of bridge-gateways which are allowed to + * lock and unlock tokens. + * + */ +contract TokenVault is Ownable { + using SafeMath for uint256; + + event Locked( + address indexed bridgeGateway, + address indexed token, + address indexed depositor, + uint256 amount + ); + + event Unlocked( + address indexed bridgeGateway, + address indexed token, + address indexed recipient, + uint256 amount + ); + + event GatewayWhitelistUpdated(address indexed bridgeGateway, bool active); + + // White-list of trusted bridge gateway contracts + mapping(address => bool) public whitelistedBridgeGateways; + + modifier onlyBridgeGateway() { + require( + whitelistedBridgeGateways[msg.sender], + "TokenVault: Bridge gateway not whitelisted" + ); + _; + } + + /** + * @notice Adds bridge gateway contract address to whitelist. + * @param bridgeGateway The address of the bridge gateway contract. + */ + function addBridgeGateway(address bridgeGateway) external onlyOwner { + whitelistedBridgeGateways[bridgeGateway] = true; + emit GatewayWhitelistUpdated(bridgeGateway, true); + } + + /** + * @notice Removes bridge gateway contract address from whitelist. + * @param bridgeGateway The address of the bridge gateway contract. + */ + function removeBridgeGateway(address bridgeGateway) external onlyOwner { + delete whitelistedBridgeGateways[bridgeGateway]; + emit GatewayWhitelistUpdated(bridgeGateway, false); + } + + /** + * @notice Transfers specified amount from the depositor's wallet and locks it in the gateway contract. + */ + function lock( + address token, + address depositor, + uint256 amount + ) external onlyBridgeGateway { + require(IERC20(token).transferFrom(depositor, address(this), amount)); + emit Locked(msg.sender, token, depositor, amount); + } + + /** + * @notice Unlocks the specified amount from the gateway contract and transfers it to the recipient. + */ + function unlock( + address token, + address recipient, + uint256 amount + ) external onlyBridgeGateway { + require(IERC20(token).transfer(recipient, amount)); + emit Unlocked(msg.sender, token, recipient, amount); + } + + /** + * @notice Total token balance secured by the gateway contract. + */ + function totalLocked(address token) public view returns (uint256) { + return IERC20(token).balanceOf(address(this)); + } +} diff --git a/test/token_vault.js b/test/token_vault.js new file mode 100644 index 0000000..cec5461 --- /dev/null +++ b/test/token_vault.js @@ -0,0 +1,227 @@ +const { ethers } = require('@nomiclabs/buidler'); +const { expect } = require('chai'); + +let accounts, + deployer, + deployerAddress, + bridge, + bridgeAddress, + otherBridge, + otherBridgeAddress, + vault, + mockToken; +async function setupContracts () { + accounts = await ethers.getSigners(); + deployer = accounts[0]; + bridge = accounts[1]; + otherBridge = accounts[3]; + deployerAddress = await deployer.getAddress(); + bridgeAddress = await bridge.getAddress(); + otherBridgeAddress = await otherBridge.getAddress(); + + mockToken = await (await ethers.getContractFactory('MockERC20')) + .connect(deployer) + .deploy('MockToken', 'MOCK'); + + vault = await (await ethers.getContractFactory('TokenVault')) + .connect(deployer) + .deploy(); +} + +describe('TokenVault:Initialization', () => { + before('setup TokenVault contract', setupContracts); + + it('should set the owner', async function () { + expect(await vault.owner()).to.eq(await deployer.getAddress()); + }); +}); + +describe('TokenVault:addBridgeGateway', async () => { + beforeEach('setup TokenVault contract', setupContracts); + + it('should NOT be callable by non-owner', async function () { + await expect( + vault.connect(accounts[5]).addBridgeGateway(bridgeAddress), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should be callable by owner', async function () { + await expect(vault.connect(deployer).addBridgeGateway(bridgeAddress)).to.not + .be.reverted; + }); + + it('should add to the whitelist', async function () { + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.false; + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.true; + }); + + it('should NOT affect others', async function () { + expect(await vault.whitelistedBridgeGateways(otherBridgeAddress)).to.be + .false; + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.false; + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.true; + expect(await vault.whitelistedBridgeGateways(otherBridgeAddress)).to.be + .false; + }); +}); + +describe('TokenVault:removeBridgeGateway', async () => { + beforeEach('setup TokenVault contract', async () => { + await setupContracts(); + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + await vault.connect(deployer).addBridgeGateway(otherBridgeAddress); + }); + + it('should NOT be callable by non-owner', async function () { + await expect( + vault.connect(accounts[5]).removeBridgeGateway(bridgeAddress), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should be callable by owner', async function () { + await expect(vault.connect(deployer).removeBridgeGateway(bridgeAddress)).to + .not.be.reverted; + }); + + it('should remove from the whitelist', async function () { + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.true; + await vault.connect(deployer).removeBridgeGateway(bridgeAddress); + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.false; + }); + + it('should NOT affect others', async function () { + expect(await vault.whitelistedBridgeGateways(otherBridgeAddress)).to.be + .true; + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.true; + await vault.connect(deployer).removeBridgeGateway(bridgeAddress); + expect(await vault.whitelistedBridgeGateways(bridgeAddress)).to.be.false; + expect(await vault.whitelistedBridgeGateways(otherBridgeAddress)).to.be + .true; + }); +}); + +describe('TokenVault:lock:accessControl', async () => { + beforeEach('setup TokenVault contract', async function () { + await setupContracts(); + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + await vault.connect(deployer).addBridgeGateway(otherBridgeAddress); + await mockToken.connect(deployer).approve(vault.address, 10000); + }); + + it('should NOT be callable by non-bridge', async function () { + await expect( + vault.connect(deployer).lock(mockToken.address, deployerAddress, 123), + ).to.be.revertedWith('TokenVault: Bridge gateway not whitelisted'); + }); + + it('should be callable by bridge', async function () { + await expect( + vault.connect(bridge).lock(mockToken.address, deployerAddress, 123), + ).to.not.be.reverted; + await expect( + vault.connect(otherBridge).lock(mockToken.address, deployerAddress, 123), + ).to.not.be.reverted; + }); +}); + +describe('TokenVault:lock', async () => { + beforeEach('setup TokenVault contract', async function () { + await setupContracts(); + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + await mockToken.connect(deployer).approve(vault.address, 999); + }); + + it('should transfer tokens from the depositor to the contract', async function () { + const _b = await mockToken.balanceOf(deployerAddress); + await expect( + vault.connect(bridge).lock(mockToken.address, deployerAddress, 999), + ) + .to.emit(mockToken, 'Transfer') + .withArgs(deployerAddress, vault.address, 999); + const b = await mockToken.balanceOf(deployerAddress); + expect(_b.sub(b)).to.eq(999); + }); + + it('should update total locked', async function () { + await vault.connect(bridge).lock(mockToken.address, deployerAddress, 999); + expect(await vault.totalLocked(mockToken.address)).to.eq(999); + }); + + it('should log Locked event', async function () { + await expect( + vault.connect(bridge).lock(mockToken.address, deployerAddress, 999), + ) + .to.emit(vault, 'Locked') + .withArgs( + await bridge.getAddress(), + mockToken.address, + deployerAddress, + 999, + ); + }); +}); + +describe('TokenVault:unlock:accessControl', async () => { + beforeEach('setup TokenVault contract', async function () { + await setupContracts(); + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + await vault.connect(deployer).addBridgeGateway(otherBridgeAddress); + await mockToken.connect(deployer).approve(vault.address, 10000); + await vault.connect(bridge).lock(mockToken.address, deployerAddress, 9999); + }); + + it('should NOT be callable by non-bridge', async function () { + await expect( + vault.connect(deployer).unlock(mockToken.address, deployerAddress, 123), + ).to.be.revertedWith('TokenVault: Bridge gateway not whitelisted'); + }); + + it('should be callable by bridge', async function () { + await expect( + vault.connect(bridge).unlock(mockToken.address, deployerAddress, 9990), + ).to.not.be.reverted; + await expect( + vault.connect(otherBridge).unlock(mockToken.address, deployerAddress, 9), + ).to.not.be.reverted; + }); +}); + +describe('TokenVault:unlock', async () => { + beforeEach('setup TokenVault contract', async function () { + await setupContracts(); + await vault.connect(deployer).addBridgeGateway(bridgeAddress); + await mockToken.connect(deployer).approve(vault.address, 10000); + await vault.connect(bridge).lock(mockToken.address, deployerAddress, 9999); + }); + + it('should transfer tokens from the contract to recipient', async function () { + const _b = await mockToken.balanceOf(deployerAddress); + await expect( + vault.connect(bridge).unlock(mockToken.address, deployerAddress, 999), + ) + .to.emit(mockToken, 'Transfer') + .withArgs(vault.address, deployerAddress, 999); + const b = await mockToken.balanceOf(deployerAddress); + expect(b.sub(_b)).to.eq(999); + }); + + it('should update total unlocked', async function () { + await vault.connect(bridge).unlock(mockToken.address, deployerAddress, 999); + expect(await vault.totalLocked(mockToken.address)).to.eq(9000); + }); + + it('should log Unlocked event', async function () { + await expect( + vault.connect(bridge).unlock(mockToken.address, deployerAddress, 999), + ) + .to.emit(vault, 'Unlocked') + .withArgs( + await bridge.getAddress(), + mockToken.address, + deployerAddress, + 999, + ); + }); +});