diff --git a/contracts/migrator/SynapseMigrator.sol b/contracts/migrator/SynapseMigrator.sol new file mode 100644 index 000000000..9e0d1a778 --- /dev/null +++ b/contracts/migrator/SynapseMigrator.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {IBurnable} from "./interfaces/IBurnable.sol"; +import {ISynapseMigrator} from "./interfaces/ISynapseMigrator.sol"; +import {ISynapseMigratorErrors} from "./interfaces/ISynapseMigratorErrors.sol"; + +import {Ownable} from "@openzeppelin/contracts-4.5.0/access/Ownable.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts-4.5.0/token/ERC20/extensions/IERC20Metadata.sol"; +import {SafeERC20, IERC20} from "@openzeppelin/contracts-4.5.0/token/ERC20/utils/SafeERC20.sol"; + +contract SynapseMigrator is Ownable, ISynapseMigrator, ISynapseMigratorErrors { + using SafeERC20 for IERC20; + + struct TokenPair { + address newToken; + uint8 oldTokenDecimals; + uint8 newTokenDecimals; + } + + mapping(address => TokenPair) internal _tokenPairs; + + event Migrated(address indexed user, address indexed oldToken, uint256 amount); + event TokenPairAdded(address indexed oldToken, address indexed newToken); + + constructor(address owner_) { + transferOwnership(owner_); + } + + /// @inheritdoc ISynapseMigrator + function addTokenPair(address oldToken, address newToken) external onlyOwner { + if (oldToken == address(0) || newToken == address(0)) revert SM__ZeroAddress(); + if (oldToken == newToken) revert SM__SameAddress(); + // Check that the token pair has not been added yet + if (_tokenPairs[oldToken].newToken != address(0)) revert SM__TokenPairAlreadyAdded(); + // Add token pair and record the tokens decimals to avoid extra calls in the future + _tokenPairs[oldToken] = TokenPair({ + newToken: newToken, + oldTokenDecimals: IERC20Metadata(oldToken).decimals(), + newTokenDecimals: IERC20Metadata(newToken).decimals() + }); + emit TokenPairAdded(oldToken, newToken); + } + + /// @inheritdoc ISynapseMigrator + function migrate(address oldToken, uint256 amount) external { + if (oldToken == address(0)) revert SM__ZeroAddress(); + (address newToken, uint256 newAmount) = previewMigrate(oldToken, amount); + if (newToken == address(0)) revert SM__TokenPairNotAdded(); + if (newAmount == 0) revert SM__ZeroAmount(); + // Burn old tokens from the user + IBurnable(oldToken).burnFrom(msg.sender, amount); + // Send new tokens to the user + IERC20(newToken).safeTransfer(msg.sender, newAmount); + emit Migrated(msg.sender, oldToken, amount); + } + + /// @inheritdoc ISynapseMigrator + function getTokenPair(address oldToken) external view returns (address newToken) { + return _tokenPairs[oldToken].newToken; + } + + /// @inheritdoc ISynapseMigrator + function previewMigrate(address oldToken, uint256 amount) + public + view + returns (address newToken, uint256 newAmount) + { + newToken = _tokenPairs[oldToken].newToken; + uint256 oldDecimals = _tokenPairs[oldToken].oldTokenDecimals; + uint256 newDecimals = _tokenPairs[oldToken].newTokenDecimals; + if (newToken == address(0)) return (address(0), 0); + newAmount = (amount * 10**newDecimals) / 10**oldDecimals; + } +} diff --git a/contracts/migrator/interfaces/IBurnable.sol b/contracts/migrator/interfaces/IBurnable.sol new file mode 100644 index 000000000..49f3d6a9f --- /dev/null +++ b/contracts/migrator/interfaces/IBurnable.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +interface IBurnable { + function burnFrom(address from, uint256 amount) external; +} diff --git a/contracts/migrator/interfaces/ISynapseMigrator.sol b/contracts/migrator/interfaces/ISynapseMigrator.sol new file mode 100644 index 000000000..4c136a3d2 --- /dev/null +++ b/contracts/migrator/interfaces/ISynapseMigrator.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +interface ISynapseMigrator { + /// @notice Allows the contract owner to add a token pair to the migrator. + /// Users will be able to migrate from the old token to the new token using 1:1 ratio, + /// taking token decimals into account. + /// @dev Will revert in the following cases: + /// - Either of the tokens is the zero address. + /// - Token addresses are the same. + /// - The token pair is already added for the old token. + function addTokenPair(address oldToken, address newToken) external; + + /// @notice Migrates the given amount of old tokens to new tokens. + /// Old tokens will be taken from the user and burned. New tokens will be transferred to the user. + /// @dev Will revert in the following cases: + /// - Zero address or amount is supplied. + /// - The token pair is not added for the old token. + /// - Contract does not have enough balance of the new token. + function migrate(address oldToken, uint256 amount) external; + + /// @notice Returns the new token for the given old token. + /// @dev Will return the zero address if the token pair is not added for the old token. + function getTokenPair(address oldToken) external view returns (address newToken); + + /// @notice Returns the new token and amount of new tokens that will be received for the given amount of old tokens. + /// @dev Will return (address(0), 0) if the token pair is not added for the old token. + function previewMigrate(address oldToken, uint256 amount) + external + view + returns (address newToken, uint256 newAmount); +} diff --git a/contracts/migrator/interfaces/ISynapseMigratorErrors.sol b/contracts/migrator/interfaces/ISynapseMigratorErrors.sol new file mode 100644 index 000000000..b78d34345 --- /dev/null +++ b/contracts/migrator/interfaces/ISynapseMigratorErrors.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +interface ISynapseMigratorErrors { + error SM__SameAddress(); + error SM__TokenPairAlreadyAdded(); + error SM__TokenPairNotAdded(); + error SM__ZeroAddress(); + error SM__ZeroAmount(); +} diff --git a/test/migrator/SynapseMigrator.LessDecimals.t.sol b/test/migrator/SynapseMigrator.LessDecimals.t.sol new file mode 100644 index 000000000..5fcceb506 --- /dev/null +++ b/test/migrator/SynapseMigrator.LessDecimals.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {SynapseMigratorTest} from "./SynapseMigrator.t.sol"; + +// solhint-disable func-name-mixedcase +contract SynapseMigratorLessDecimalsTest is SynapseMigratorTest { + constructor() SynapseMigratorTest(18, 6) {} + + function test_migrate_precisionLoss() public { + amount = 1.23456789 * 10**18; + expectedNewAmount = 1234567; + test_migrate(); + } + + function test_migrate_revert_precisionLoss_zeroAmountOut() public { + vm.expectRevert(SM__ZeroAmount.selector); + vm.prank(user); + migrator.migrate(address(oldToken), 10**12 - 1); + } + + function test_previewMigrate_precisionLoss() public { + amount = 1.23456789 * 10**18; + expectedNewAmount = 1234567; + test_previewMigrate(); + } + + function test_previewMigrate_precisionLoss_zeroAmountOut() public { + amount = 10**12 - 1; + expectedNewAmount = 0; + test_previewMigrate(); + } +} diff --git a/test/migrator/SynapseMigrator.Management.t.sol b/test/migrator/SynapseMigrator.Management.t.sol new file mode 100644 index 000000000..9cde90341 --- /dev/null +++ b/test/migrator/SynapseMigrator.Management.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {SynapseMigrator, ISynapseMigratorErrors} from "../../contracts/migrator/SynapseMigrator.sol"; + +import {IERC20Metadata} from "@openzeppelin/contracts-4.5.0/token/ERC20/extensions/IERC20Metadata.sol"; + +import {Test} from "forge-std/Test.sol"; + +// solhint-disable func-name-mixedcase +contract SynapseMigratorManagementTest is Test, ISynapseMigratorErrors { + event TokenPairAdded(address indexed oldToken, address indexed newToken); + + SynapseMigrator internal migrator; + address internal owner = makeAddr("owner"); + + address internal oldToken = makeAddr("oldToken"); + address internal newToken = makeAddr("newToken"); + address internal anotherToken = makeAddr("anotherToken"); + + function setUp() public { + migrator = new SynapseMigrator(owner); + + vm.mockCall({ + callee: oldToken, + data: abi.encodeWithSelector(IERC20Metadata.decimals.selector), + returnData: abi.encode(18) + }); + vm.mockCall({ + callee: newToken, + data: abi.encodeWithSelector(IERC20Metadata.decimals.selector), + returnData: abi.encode(6) + }); + vm.mockCall({ + callee: anotherToken, + data: abi.encodeWithSelector(IERC20Metadata.decimals.selector), + returnData: abi.encode(6) + }); + } + + function test_constructor() public { + assertEq(migrator.owner(), owner); + assertEq(migrator.getTokenPair(oldToken), address(0)); + } + + function test_constructor_revert_zeroOwner() public { + vm.expectRevert("Ownable: new owner is the zero address"); + new SynapseMigrator(address(0)); + } + + function test_addTokenPair() public { + vm.expectEmit(address(migrator)); + emit TokenPairAdded(oldToken, newToken); + vm.prank(owner); + migrator.addTokenPair(oldToken, newToken); + assertEq(migrator.getTokenPair(oldToken), newToken); + assertEq(migrator.getTokenPair(newToken), address(0)); + } + + function test_addTokenPair_revert_oldTokenZero() public { + vm.expectRevert(SM__ZeroAddress.selector); + vm.prank(owner); + migrator.addTokenPair(address(0), newToken); + } + + function test_addTokenPair_revert_newTokenZero() public { + vm.expectRevert(SM__ZeroAddress.selector); + vm.prank(owner); + migrator.addTokenPair(oldToken, address(0)); + } + + function test_addTokenPair_revert_sameTokens() public { + vm.expectRevert(SM__SameAddress.selector); + vm.prank(owner); + migrator.addTokenPair(oldToken, oldToken); + } + + function test_addTokenPair_revert_oldTokenAlreadyAdded() public { + vm.prank(owner); + migrator.addTokenPair(oldToken, newToken); + // Same address + vm.expectRevert(SM__TokenPairAlreadyAdded.selector); + vm.prank(owner); + migrator.addTokenPair(oldToken, newToken); + // New address + vm.expectRevert(SM__TokenPairAlreadyAdded.selector); + vm.prank(owner); + migrator.addTokenPair(oldToken, anotherToken); + } + + function test_addTokenPair_revert_callerNotOwner(address caller) public { + vm.assume(caller != owner); + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(caller); + migrator.addTokenPair(oldToken, newToken); + } +} diff --git a/test/migrator/SynapseMigrator.MoreDecimals.t.sol b/test/migrator/SynapseMigrator.MoreDecimals.t.sol new file mode 100644 index 000000000..410a4bee6 --- /dev/null +++ b/test/migrator/SynapseMigrator.MoreDecimals.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {SynapseMigratorTest} from "./SynapseMigrator.t.sol"; + +contract SynapseMigratorMoreDecimalsTest is SynapseMigratorTest { + constructor() SynapseMigratorTest(6, 18) {} +} diff --git a/test/migrator/SynapseMigrator.SameDecimals.t.sol b/test/migrator/SynapseMigrator.SameDecimals.t.sol new file mode 100644 index 000000000..1f9bb34ea --- /dev/null +++ b/test/migrator/SynapseMigrator.SameDecimals.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {SynapseMigratorTest} from "./SynapseMigrator.t.sol"; + +contract SynapseMigratorSameDecimalsTest is SynapseMigratorTest { + constructor() SynapseMigratorTest(18, 18) {} +} diff --git a/test/migrator/SynapseMigrator.t.sol b/test/migrator/SynapseMigrator.t.sol new file mode 100644 index 000000000..d21db133f --- /dev/null +++ b/test/migrator/SynapseMigrator.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {SynapseMigrator, ISynapseMigratorErrors} from "../../contracts/migrator/SynapseMigrator.sol"; + +import {MockBurnableToken} from "../mocks/MockBurnableToken.sol"; + +import {Test} from "forge-std/Test.sol"; + +// solhint-disable func-name-mixedcase +abstract contract SynapseMigratorTest is Test, ISynapseMigratorErrors { + event Migrated(address indexed user, address indexed oldToken, uint256 amount); + + SynapseMigrator internal migrator; + MockBurnableToken internal oldToken; + MockBurnableToken internal newToken; + + uint8 internal oldTokenDecimals; + uint8 internal newTokenDecimals; + + address internal user = makeAddr("user"); + uint256 internal oldTokenBalance; + uint256 internal newTokenSupply; + uint256 internal amount; + uint256 internal expectedNewAmount; + + constructor(uint8 oldTokenDecimals_, uint8 newTokenDecimals_) { + oldTokenDecimals = oldTokenDecimals_; + newTokenDecimals = newTokenDecimals_; + } + + function setUp() public { + migrator = new SynapseMigrator(address(this)); + oldToken = new MockBurnableToken("OldToken", oldTokenDecimals); + newToken = new MockBurnableToken("NewToken", newTokenDecimals); + migrator.addTokenPair(address(oldToken), address(newToken)); + + // 10 tokens + oldTokenBalance = 10 * 10**oldTokenDecimals; + newTokenSupply = 10 * 10**newTokenDecimals; + // Migrate 1 token + amount = 10**oldTokenDecimals; + expectedNewAmount = 10**newTokenDecimals; + + oldToken.mintTestTokens(user, oldTokenBalance); + newToken.mintTestTokens(address(migrator), newTokenSupply); + + vm.prank(user); + oldToken.approve(address(migrator), type(uint256).max); + } + + function test_migrate() public { + vm.expectEmit(address(migrator)); + emit Migrated(user, address(oldToken), amount); + vm.prank(user); + migrator.migrate(address(oldToken), amount); + // Old token balances + assertEq(oldToken.balanceOf(user), oldTokenBalance - amount); + assertEq(oldToken.balanceOf(address(migrator)), 0); + assertEq(oldToken.totalSupply(), oldTokenBalance - amount); + // New token balances + assertEq(newToken.balanceOf(user), expectedNewAmount); + assertEq(newToken.balanceOf(address(migrator)), newTokenSupply - expectedNewAmount); + assertEq(newToken.totalSupply(), newTokenSupply); + } + + function test_migrate_revert_oldTokenNotAdded() public { + // Redeploy migrator to effectively remove the token pair + migrator = new SynapseMigrator(address(this)); + vm.expectRevert(SM__TokenPairNotAdded.selector); + vm.prank(user); + migrator.migrate(address(oldToken), amount); + } + + function test_migrate_revert_oldTokenZero() public { + vm.expectRevert(SM__ZeroAddress.selector); + vm.prank(user); + migrator.migrate(address(0), amount); + } + + function test_migrate_revert_amountZero() public { + vm.expectRevert(SM__ZeroAmount.selector); + vm.prank(user); + migrator.migrate(address(oldToken), 0); + } + + function test_migrate_revert_notEnoughAllowance() public { + vm.prank(user); + oldToken.approve(address(migrator), amount - 1); + vm.expectRevert(); + vm.prank(user); + migrator.migrate(address(oldToken), amount); + } + + function test_previewMigrate() public { + (address previewedNewToken, uint256 previewedAmount) = migrator.previewMigrate(address(oldToken), amount); + assertEq(previewedNewToken, address(newToken)); + assertEq(previewedAmount, expectedNewAmount); + } + + function test_previewMigrate_returnsZero_tokenNotAdded() public { + migrator = new SynapseMigrator(address(this)); + (address previewedNewToken, uint256 previewedAmount) = migrator.previewMigrate(address(oldToken), amount); + assertEq(previewedNewToken, address(0)); + assertEq(previewedAmount, 0); + } +} diff --git a/test/mocks/MockBurnableToken.sol b/test/mocks/MockBurnableToken.sol new file mode 100644 index 000000000..1985c83a4 --- /dev/null +++ b/test/mocks/MockBurnableToken.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ERC20, ERC20Burnable} from "@openzeppelin/contracts-4.5.0/token/ERC20/extensions/ERC20Burnable.sol"; + +// solhint-disable no-empty-blocks +/// @notice Obviously, do NOT use this token in production. It's only for testing purposes. +contract MockBurnableToken is ERC20Burnable { + uint8 private _decimals; + + constructor(string memory name_, uint8 decimals_) ERC20(name_, name_) { + _decimals = decimals_; + } + + /// @notice We include an empty "test" function so that this contract does not appear in the coverage report. + function testMockBurnableToken() external {} + + function decimals() public view virtual override returns (uint8) { + return _decimals; + } + + function mintTestTokens(address to, uint256 amount) external { + _mint(to, amount); + } +}