diff --git a/.gitmodules b/.gitmodules index 8e9e7d7..73362ca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,3 +15,6 @@ path = lib/@uniswap/v3-core url = https://github.com/Uniswap/v3-core branch = 0.8 +[submodule "lib/pos-portal"] + path = lib/pos-portal + url = https://github.com/maticnetwork/pos-portal diff --git a/lib/pos-portal b/lib/pos-portal new file mode 160000 index 0000000..8460b1f --- /dev/null +++ b/lib/pos-portal @@ -0,0 +1 @@ +Subproject commit 8460b1ff6a654731b406716a5140337a3aebeb94 diff --git a/remappings.txt b/remappings.txt index 2120287..10bae74 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,3 @@ @openzeppelin/contracts/=lib/@openzeppelin/contracts/contracts/ @gnosis/auction/=lib/@gnosis/auction/contracts/ +@polygon/pos-portal/=lib/pos-portal/contracts/ diff --git a/src/bridge/polygon/PolygonBobToken.sol b/src/bridge/polygon/PolygonBobToken.sol new file mode 100644 index 0000000..62d9caf --- /dev/null +++ b/src/bridge/polygon/PolygonBobToken.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "../../BobToken.sol"; + +/** + * @title PolygonBobToken + */ +contract PolygonBobToken is BobToken { + event Withdrawn(address indexed account, uint256 value); + + constructor(address _self) BobToken(_self) {} + + function deposit(address _user, bytes calldata _depositData) external { + mint(_user, abi.decode(_depositData, (uint256))); + } + + function withdraw(uint256 _amount) external { + _burn(msg.sender, _amount); + + emit Withdrawn(msg.sender, _amount); + } +} diff --git a/src/bridge/polygon/PolygonERC20MintBurnPredicate.sol b/src/bridge/polygon/PolygonERC20MintBurnPredicate.sol new file mode 100644 index 0000000..1a75311 --- /dev/null +++ b/src/bridge/polygon/PolygonERC20MintBurnPredicate.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.6.6; + +import "@polygon/pos-portal/root/TokenPredicates/ITokenPredicate.sol"; + +interface IERC20MintBurn { + function mint(address user, uint256 amount) external; + function burn(uint256 amount) external; + function burnFrom(address user, uint256 amount) external; +} + +/** + * @title ERC20 Mint/Burn Predicate for Polygon PoS bridge. + * Works with a `Withdrawn(address account, uint256 value)` event. + */ +contract PolygonERC20MintBurnPredicate is ITokenPredicate { + using RLPReader for bytes; + using RLPReader for RLPReader.RLPItem; + + event LockedERC20( + address indexed depositor, address indexed depositReceiver, address indexed rootToken, uint256 amount + ); + + // keccak256("Withdrawn(address account, uint256 value)"); + bytes32 public constant WITHDRAWN_EVENT_SIG = 0x7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5; + + // see https://github.com/maticnetwork/pos-portal/blob/master/contracts/root/RootChainManager/RootChainManager.sol + address public immutable rootChainManager; + + constructor(address _rootChainManager) public { + rootChainManager = _rootChainManager; + } + + /** + * Burns ERC20 tokens for deposit. + * @dev Reverts if not called by the manager (RootChainManager). + * @param depositor Address who wants to deposit tokens. + * @param depositReceiver Address (address) who wants to receive tokens on child chain. + * @param rootToken Token which gets deposited. + * @param depositData ABI encoded amount. + */ + function lockTokens( + address depositor, + address depositReceiver, + address rootToken, + bytes calldata depositData + ) + external + override + { + require(msg.sender == rootChainManager, "Predicate: only manager"); + uint256 amount = abi.decode(depositData, (uint256)); + emit LockedERC20(depositor, depositReceiver, rootToken, amount); + IERC20MintBurn(rootToken).burnFrom(depositor, amount); + } + + /** + * Validates the {Withdrawn} log signature, then mints the correct amount to withdrawer. + * @dev Reverts if not called only by the manager (RootChainManager). + * @param rootToken Token which gets withdrawn + * @param log Valid ERC20 burn log from child chain + */ + function exitTokens(address, address rootToken, bytes memory log) public override { + require(msg.sender == rootChainManager, "Predicate: only manager"); + + RLPReader.RLPItem[] memory logRLPList = log.toRlpItem().toList(); + RLPReader.RLPItem[] memory logTopicRLPList = logRLPList[1].toList(); // topics + + require( + bytes32(logTopicRLPList[0].toUint()) == WITHDRAWN_EVENT_SIG, // topic0 is event sig + "Predicate: invalid signature" + ); + + address withdrawer = address(logTopicRLPList[1].toUint()); // topic1 is from address + + IERC20MintBurn(rootToken).mint(withdrawer, logRLPList[2].toUint()); + } +} diff --git a/src/interfaces/IBurnableERC20.sol b/src/interfaces/IBurnableERC20.sol index e3e4241..39d8fda 100644 --- a/src/interfaces/IBurnableERC20.sol +++ b/src/interfaces/IBurnableERC20.sol @@ -4,4 +4,5 @@ pragma solidity 0.8.15; interface IBurnableERC20 { function burn(uint256 amount) external; + function burnFrom(address user, uint256 amount) external; } diff --git a/src/token/ERC20MintBurn.sol b/src/token/ERC20MintBurn.sol index c7e0f43..10e241c 100644 --- a/src/token/ERC20MintBurn.sol +++ b/src/token/ERC20MintBurn.sol @@ -42,7 +42,7 @@ abstract contract ERC20MintBurn is IMintableERC20, IBurnableERC20, Ownable, Base * @param _to address of the tokens receiver. * @param _amount amount of tokens to mint. */ - function mint(address _to, uint256 _amount) external { + function mint(address _to, uint256 _amount) public { require(isMinter(msg.sender), "ERC20MintBurn: not a minter"); _mint(_to, _amount); @@ -58,4 +58,18 @@ abstract contract ERC20MintBurn is IMintableERC20, IBurnableERC20, Ownable, Base _burn(msg.sender, _value); } + + /** + * @dev Burns pre-approved tokens from the other address. + * Callable only by one of the burner addresses. + * @param _from account to burn tokens from. + * @param _value amount of tokens to burn. Should be less than or equal to caller balance. + */ + function burnFrom(address _from, uint256 _value) external virtual { + require(isBurner(msg.sender), "ERC20MintBurn: not a burner"); + + _spendAllowance(_from, msg.sender, _value); + + _burn(_from, _value); + } } diff --git a/test/bridge/polygon/PolygonPoSBridge.t.sol b/test/bridge/polygon/PolygonPoSBridge.t.sol new file mode 100644 index 0000000..5268cb4 --- /dev/null +++ b/test/bridge/polygon/PolygonPoSBridge.t.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "forge-std/Test.sol"; +import "../../shared/Env.t.sol"; +import "../../../src/bridge/polygon/PolygonBobToken.sol"; +import "../../../src/proxy/EIP1967Proxy.sol"; + +interface IRootChainManager { + function registerPredicate(bytes32 tokenType, address predicate) external; + function mapToken(address rootToken, address childToken, bytes32 tokenType) external; + function depositFor(address user, address rootToken, bytes memory depositData) external; +} + +interface IChildChainManager { + function onStateReceive(uint256, bytes calldata data) external; +} + +interface IBobPredicate { + function exitTokens(address, address rootToken, bytes memory log) external; +} + +contract PolygonPoSBridge is Test { + event LockedERC20( + address indexed depositor, address indexed depositReceiver, address indexed rootToken, uint256 amount + ); + event Withdrawn(address indexed account, uint256 value); + + BobToken bobMainnet; + PolygonBobToken bobPolygon; + + uint256 mainnetFork; + uint256 polygonFork; + + address bobPredicate; + address rootChainManager = 0xA0c68C638235ee32657e8f720a23ceC1bFc77C77; + address rootChainManagerOwner = 0xFa7D2a996aC6350f4b56C043112Da0366a59b74c; + address childChainManager = 0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa; + address stateSyncer = 0x0000000000000000000000000000000000001001; + + function setUp() public { + mainnetFork = vm.createFork(forkRpcUrlMainnet); + polygonFork = vm.createFork(forkRpcUrlPolygon); + + vm.selectFork(mainnetFork); + + EIP1967Proxy bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + BobToken bobImpl = new BobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl)); + bobMainnet = BobToken(address(bobProxy)); + + bobMainnet.updateMinter(address(this), true, false); + + vm.selectFork(polygonFork); + + bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + PolygonBobToken bobImpl2 = new PolygonBobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl2)); + bobPolygon = PolygonBobToken(address(bobProxy)); + + bobPolygon.updateMinter(address(this), true, false); + bobPolygon.updateMinter(childChainManager, true, true); + + vm.selectFork(mainnetFork); + + vm.etch(rootChainManagerOwner, ""); + vm.startPrank(rootChainManagerOwner); + bytes memory predicateCode = bytes.concat( + vm.getCode("out/PolygonERC20MintBurnPredicate.sol/PolygonERC20MintBurnPredicate.json"), + abi.encode(rootChainManager) + ); + assembly { + sstore(bobPredicate.slot, create(0, add(predicateCode, 32), mload(predicateCode))) + } + IRootChainManager(rootChainManager).registerPredicate(keccak256("BOB"), bobPredicate); + vm.recordLogs(); + IRootChainManager(rootChainManager).mapToken(address(bobMainnet), address(bobPolygon), keccak256("BOB")); + vm.stopPrank(); + _syncState(); + + bobMainnet.updateMinter(bobPredicate, true, true); + + vm.label(address(bobMainnet), "BOB"); + vm.label(address(bobPolygon), "BOB"); + } + + function _syncState() internal { + uint256 curFork = vm.activeFork(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + vm.selectFork(polygonFork); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == bytes32(0x103fed9db65eac19c4d870f49ab7520fe03b99f1838e5996caf47e9e43308392)) { + vm.prank(stateSyncer); + IChildChainManager(childChainManager).onStateReceive(0, abi.decode(logs[i].data, (bytes))); + } + } + vm.selectFork(curFork); + } + + function testBridgeToPolygon() public { + vm.selectFork(mainnetFork); + + bobMainnet.mint(user1, 100 ether); + + vm.startPrank(user1); + bobMainnet.approve(bobPredicate, 10 ether); + vm.expectEmit(true, true, true, true, bobPredicate); + emit LockedERC20(user1, user2, address(bobMainnet), 10 ether); + vm.recordLogs(); + IRootChainManager(rootChainManager).depositFor(user2, address(bobMainnet), abi.encode(10 ether)); + vm.stopPrank(); + + _syncState(); + + assertEq(bobMainnet.totalSupply(), 90 ether); + assertEq(bobMainnet.balanceOf(user1), 90 ether); + + vm.selectFork(polygonFork); + + assertEq(bobPolygon.totalSupply(), 10 ether); + assertEq(bobPolygon.balanceOf(user2), 10 ether); + } + + function testBridgeFromPolygon() public { + vm.selectFork(polygonFork); + + bobPolygon.mint(user1, 100 ether); + + vm.prank(user1); + vm.expectEmit(true, false, false, true, address(bobPolygon)); + emit Withdrawn(user1, 10 ether); + bobPolygon.withdraw(10 ether); + + vm.selectFork(mainnetFork); + + // cast --to-rlp '["", ["", ""], ""]' + bytes memory logRLP = bytes.concat( + hex"f87a94", + abi.encodePacked(address(bobPolygon)), + hex"f842a0", + keccak256("Withdrawn(address,uint256)"), + hex"a0", + abi.encode(user1), + hex"a0", + abi.encode(10 ether) + ); + + vm.etch(rootChainManager, ""); + vm.prank(rootChainManager); + IBobPredicate(bobPredicate).exitTokens(user1, address(bobMainnet), logRLP); + + assertEq(bobMainnet.totalSupply(), 10 ether); + assertEq(bobMainnet.balanceOf(user1), 10 ether); + + vm.selectFork(polygonFork); + + assertEq(bobPolygon.totalSupply(), 90 ether); + assertEq(bobPolygon.balanceOf(user1), 90 ether); + } +}