diff --git a/contracts/examples/AnyCallProxy.sol b/contracts/examples/AnyCallProxy.sol new file mode 100644 index 00000000..69e60072 --- /dev/null +++ b/contracts/examples/AnyCallProxy.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../lzApp/NonblockingLzApp.sol"; + +/// ┌─────────────┐ ┌──────────────────┐ +/// │ │ │ │ +/// L1 │ Contract └───────────────► AnyCallProxy │ +/// │ ◄───────────────┐ │ +/// └─────────────┘ └─────────┬───▲────┘ +/// │ │ +/// │ │ +/// ───────────────────────────────────────────────────────────────┼───┼───────────────────── +/// │ │ +/// │ │ +/// ┌─────────────┐ ┌────────▼───┴─────┐ +/// │ │ │ │ +/// L2 │ Contract ◄────────────────┘ AnyCallProxy │ +/// │ ┌────────────────► │ +/// └─────────────┘ └──────────────────┘ + +/// @title AnyCallProxy +/// @notice A contract for sending and receiving message across chains using LayerZero's NonblockingLzApp. +contract AnyCallProxy is NonblockingLzApp { + using BytesLib for bytes; + + event LogAnyCall(address indexed from, address indexed to, bytes data, uint16 indexed toChainID); + event ReceiveFromChain(uint16 indexed _srcChainId, bytes _srcAddress, uint64 _nonce, address _contract, bytes _data); + event Deposit(address indexed account, uint256 amount); + event Withdrawl(address indexed account, uint256 amount); + event SetBlacklist(address indexed account, bool flag); + event SetWhitelist(address indexed from, address indexed to, uint16 indexed toChainID, bool flag); + + mapping(address => uint256) public executionBudget; + mapping(address => bool) public blacklist; + mapping(address => mapping(address => mapping(uint256 => bool))) public whitelist; + + constructor(address _endpoint) NonblockingLzApp(_endpoint) { + } + + receive() external payable {} + + /** + * @notice Submit a request for a cross chain interaction + * @param _paymentAddress Payment address(if _paymentAddress ZERO address, payment current contract) + * @param _to The target to interact with on `_toChainID` + * @param _data The calldata supplied for the interaction with `_to` + * @param _toChainID The target chain id to interact with + */ + function anyCall(address _paymentAddress, address _to, bytes calldata _data, uint16 _toChainID, bytes calldata _adapterParams) external payable { + require(!blacklist[msg.sender], "caller is blacklisted"); + require(whitelist[msg.sender][_to][_toChainID], "request denied"); + + bytes memory payload = _encodePayload(_to, _data); + uint _nativeFee = msg.value; + address _refundAddress = _paymentAddress; + if (_paymentAddress == address(0)) { + (_nativeFee,) = lzEndpoint.estimateFees(_toChainID, address(this), payload, false, _adapterParams); + require(address(this).balance >= _nativeFee, "Insufficient fee balance"); + _refundAddress = address(this); + } + + _lzSend( // {value: messageFee} will be paid out of this contract! + _toChainID, // destination chainId + payload, // abi.encode()'ed bytes + payable(_refundAddress), // (msg.sender will be this contract) refund address (LayerZero will refund any extra gas back to caller of send() + address(0x0), // future param, unused for this example + _adapterParams, // v1 adapterParams, specify custom destination gas qty + _nativeFee + ); + emit LogAnyCall(msg.sender, _to, _data, _toChainID); + } + + /*** + * @notice gets a quote in source native gas, for the amount that send() requires to pay for message delivery + * @param _to The target to interact with on `_toChainID` + * @param _data The calldata supplied for the interaction with `_to` + * @param _toChainID The target chain id to interact with + * @param _adapterParam - parameters for the adapter service, e.g. send some dust native token to dstChain + */ + function estimateFees(address _to, bytes calldata _data, uint16 _toChainID, bytes calldata _adapterParam) external view returns (uint nativeFee){ + bytes memory payload = _encodePayload(_to, _data); + (nativeFee,) = lzEndpoint.estimateFees(_toChainID, address(this), payload, false, _adapterParam); + } + + + function _nonblockingLzReceive(uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload) internal override { + (address _contract, bytes memory _data) = _decodePayload(_payload); + (bool ok,) = _contract.call(_data); + require(ok, "call failed"); + emit ReceiveFromChain(_srcChainId, _srcAddress, _nonce, _contract, _data); + } + + function _encodePayload(address _toAddress, bytes calldata _data) internal pure returns (bytes memory) { + return abi.encodePacked(_toAddress, _data); + } + + function _decodePayload(bytes memory _payload) internal pure returns (address to, bytes memory data) { + to = _payload.toAddress(0); + data = _payload.slice(20, _payload.length - 20); + } + + /*** + * @notice Set the whitelist premitting an account to issue a cross chain request + * @param _from The account which will submit cross chain interaction requests + * @param _to The target of the cross chain interaction + * @param _toChainID The target chain id + */ + function setWhitelist(address _from, address _to, uint16 _toChainID, bool _flag) external onlyOwner { + whitelist[_from][_to][_toChainID] = _flag; + emit SetWhitelist(_from, _to, _toChainID, _flag); + } + + /*** + * @notice Set an account's blacklist status + * @dev A simpler way to deactive an account's permission to issue + * cross chain requests without updating the whitelist + * @param _account The account to update blacklist status of + * @param _flag The blacklist state to put `_account` in + */ + function setBlacklist(address _account, bool _flag) external onlyOwner { + blacklist[_account] = _flag; + emit SetBlacklist(_account, _flag); + } + + function withdraw(address toAddr) external onlyOwner { + payable(toAddr).transfer(address(this).balance); + } + + +} \ No newline at end of file diff --git a/contracts/mocks/ReceiveAnyCallMock.sol b/contracts/mocks/ReceiveAnyCallMock.sol new file mode 100644 index 00000000..16ddf7ec --- /dev/null +++ b/contracts/mocks/ReceiveAnyCallMock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract ReceiveAnyCallMock { + event ReceiveMsg(uint256 _idx, string _data); + + uint256 public idx; + string public data = "Nothing received yet"; + + + address public callProxy; + + + constructor(address _callProxy){ + callProxy = _callProxy; + } + + + function receiveMsg(uint256 _idx, string memory _data) external { + require(msg.sender == callProxy, "No permission"); + idx = _idx; + data = _data; + emit ReceiveMsg(_idx, _data); + } + + +} diff --git a/contracts/mocks/SendAnyCallMock.sol b/contracts/mocks/SendAnyCallMock.sol new file mode 100644 index 00000000..a948983c --- /dev/null +++ b/contracts/mocks/SendAnyCallMock.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + + +interface IAnyCallProxy { + + + function estimateFees(address _to, bytes calldata _data, uint16 _toChainID, bytes calldata _adapterParam) external view returns (uint nativeFee); + + function anyCall(address _paymentAddress, address _to, bytes calldata _data, uint16 _toChainID, bytes calldata _adapterParams) external payable; + +} + + +contract SendAnyCallMock { + + + address public callProxy; + + uint16 version = 1; + uint256 gasForDestinationLzReceive = 3500000; + + receive() external payable {} + + constructor(address _callProxy){ + callProxy = _callProxy; + } + + function setGas(uint256 gas) external { + gasForDestinationLzReceive = gas; + } + + /*** + * @notice Test method call + */ + function testTransfer(address _contract, address to, uint256 amount, uint16 _toChainID) external payable { + bytes memory _data = abi.encodeWithSignature("transfer(address,uint256)", to, amount); + IAnyCallProxy(callProxy).anyCall{value: msg.value}(msg.sender, _contract, _data, _toChainID, abi.encodePacked(version, gasForDestinationLzReceive)); + } + + /*** + * @notice Test message transfer + */ + function testMsg(address _contract, uint256 _idx, string memory _msg, uint16 _toChainID) external payable { + bytes memory _data = abi.encodeWithSignature("receiveMsg(uint256,string)", _idx, _msg); + IAnyCallProxy(callProxy).anyCall{value: msg.value}(msg.sender, _contract, _data, _toChainID, abi.encodePacked(version, gasForDestinationLzReceive)); + } + + /*** + * @notice Test message transfer(No fee) + */ + function testMsgNoFee(address _contract, uint256 _idx, string memory _msg, uint16 _toChainID) external { + bytes memory _data = abi.encodeWithSignature("receiveMsg(uint256,string)", _idx, _msg); + IAnyCallProxy(callProxy).anyCall(address(0), _contract, _data, _toChainID, abi.encodePacked(version, gasForDestinationLzReceive)); + } + + /*** + * @notice GasAmount 20000 + */ + function testMsgDefautGasLimit(address _contract, uint256 _idx, string memory _msg, uint16 _toChainID) external payable { + bytes memory _data = abi.encodeWithSignature("receiveMsg(uint256,string)", _idx, _msg); + IAnyCallProxy(callProxy).anyCall{value: msg.value}(msg.sender, _contract, _data, _toChainID, abi.encodePacked(version, gasForDestinationLzReceive)); + } + + /*** + * @notice GasAmount 200000000 + */ + function testMsgMoreGasLimit(address _contract, uint256 _idx, string memory _msg, uint16 _toChainID) external payable { + bytes memory _data = abi.encodeWithSignature("receiveMsg(uint256,string)", _idx, _msg); + uint16 version = 1; + uint256 gasAmount = 20000000; + bytes memory _adapterParams = abi.encodePacked(version, gasAmount); + IAnyCallProxy(callProxy).anyCall{value: msg.value}(msg.sender, _contract, _data, _toChainID, _adapterParams); + } + + +} diff --git a/test/examples/AnyCallProxy.test.js b/test/examples/AnyCallProxy.test.js new file mode 100644 index 00000000..458350f8 --- /dev/null +++ b/test/examples/AnyCallProxy.test.js @@ -0,0 +1,234 @@ +const {expect} = require("chai") +const {ethers} = require("hardhat") + +describe("AnyCallProxy Test ", function () { + + const LOCAL_CHAIN_ID = 1; + const REMOTE_CHAIN_ID = 2; + const _idx = 1; + const _msg = "Hello world"; + + let owner, alice, bob; + let LZEndpointMock, AnyCallProxy, ERC20Mock, SendAnyCallMock, ReceiveAnyCallMock + let l1_CallProxy, l2_CallProxy, l2_token, sendAnyCallMock, receiveAnyCallMock, localEndpoint, remoteEndpoint + + before(async function () { + [owner, alice, bob] = await ethers.getSigners(); + LZEndpointMock = await ethers.getContractFactory("LZEndpointMock"); + AnyCallProxy = await ethers.getContractFactory("AnyCallProxy"); + ERC20Mock = await ethers.getContractFactory("ERC20Mock"); + SendAnyCallMock = await ethers.getContractFactory("SendAnyCallMock"); + ReceiveAnyCallMock = await ethers.getContractFactory("ReceiveAnyCallMock"); + }) + + beforeEach(async function () { + localEndpoint = await LZEndpointMock.deploy(LOCAL_CHAIN_ID); + remoteEndpoint = await LZEndpointMock.deploy(REMOTE_CHAIN_ID); + l2_token = await ERC20Mock.deploy("Test", "Test"); + l1_CallProxy = await AnyCallProxy.deploy(localEndpoint.address); + l2_CallProxy = await AnyCallProxy.deploy(remoteEndpoint.address); + + sendAnyCallMock = await SendAnyCallMock.deploy(l1_CallProxy.address) + receiveAnyCallMock = await ReceiveAnyCallMock.deploy(l2_CallProxy.address); + + await localEndpoint.setDestLzEndpoint(l2_CallProxy.address, remoteEndpoint.address); + await remoteEndpoint.setDestLzEndpoint(l1_CallProxy.address, localEndpoint.address); + + const localPath = ethers.utils.solidityPack(["address", "address"], [l1_CallProxy.address, l2_CallProxy.address]); + const remotePath = ethers.utils.solidityPack(["address", "address"], [l2_CallProxy.address, l1_CallProxy.address]); + + await l1_CallProxy.setTrustedRemote(REMOTE_CHAIN_ID, remotePath); // for A, set B + await l2_CallProxy.setTrustedRemote(LOCAL_CHAIN_ID, localPath); // for B, set A + + + }) + + + describe("Test Fee", async function () { + it("should send msg no fee", async function () { + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + // 2. send ETH to cover tx fee + await owner.sendTransaction({to: l1_CallProxy.address, value: ethers.utils.parseEther("2")}); + // 3. cross-chain + await sendAnyCallMock.testMsgNoFee(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID); + + expect(await receiveAnyCallMock.idx()).to.equal(_idx); + expect(await receiveAnyCallMock.data()).to.equal(_msg); + + }) + + it("revert send msg no fee", async function () { + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + // 2. cross-chain + await expect(sendAnyCallMock.testMsgNoFee(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID)).to.revertedWith("Insufficient fee balance"); + + }) + + it('test GasAmount', async function () { + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + + // 2. cross-chain + await sendAnyCallMock.connect(bob).testMsgDefautGasLimit(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: ethers.utils.parseEther("10")}); + await sendAnyCallMock.connect(alice).testMsgMoreGasLimit(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: ethers.utils.parseEther("10")}); + }); + }) + + + describe("Test Fee", async function () { + it("should send msg no fee", async function () { + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + + // 2. send ETH to cover tx fee + await owner.sendTransaction({to: l1_CallProxy.address, value: ethers.utils.parseEther("2")}); + + // 3. cross-chain + await sendAnyCallMock.testMsgNoFee(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID); + + expect(await receiveAnyCallMock.idx()).to.equal(_idx); + expect(await receiveAnyCallMock.data()).to.equal(_msg); + + }) + + it("revert send msg no fee", async function () { + + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + // 2. cross-chain + await expect(sendAnyCallMock.testMsgNoFee(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID)).to.revertedWith("Insufficient fee balance"); + + }) + + it('test GasAmount', async function () { + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + + // 2. cross-chain + await sendAnyCallMock.connect(bob).testMsgDefautGasLimit(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: ethers.utils.parseEther("10")}); + await sendAnyCallMock.connect(alice).testMsgMoreGasLimit(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: ethers.utils.parseEther("10")}); + + expect(await receiveAnyCallMock.idx()).to.equal(_idx); + expect(await receiveAnyCallMock.data()).to.equal(_msg); + }); + }) + + + describe("Test Any Call", async function () { + it('should cross-chain calling function from L1 to L2 ', async function () { + // send ETH to cover tx fee + // await owner.sendTransaction({to: sendAnyCallMock.address, value: ethers.utils.parseEther("2")}); + const amount = 10; + + l2_token.mint(l2_CallProxy.address, amount); + + expect(await l2_token.balanceOf(bob.address)).to.equal(0); + + // 1. set white list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, l2_token.address, REMOTE_CHAIN_ID, true); + + // 2. get native Fee + let iface = new ethers.utils.Interface(["function transfer(address to, uint amount)"]); + let _data = iface.encodeFunctionData("transfer", [bob.address, amount]); + const _payload = ethers.utils.defaultAbiCoder.encode(["address", "bytes"], [l2_token.address, _data]) + const {nativeFee} = await localEndpoint.estimateFees(REMOTE_CHAIN_ID, owner.address, _payload, false, ethers.utils.solidityPack( + ['uint16', 'uint256'], + [1, 3500000] + )); + + // 3. cross-chain + await sendAnyCallMock.testTransfer(l2_token.address, bob.address, amount, REMOTE_CHAIN_ID, {value: nativeFee}); + + expect(await l2_token.balanceOf(bob.address)).to.equal(amount); + }); + + it('should cross-chain msg from L1 to L2', async function () { + + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + + // 2. get native Fee + let iface = new ethers.utils.Interface(["function receiveMsg(uint256,string)"]); + let _data = iface.encodeFunctionData("receiveMsg", [_idx, _msg]); + const _payload = ethers.utils.solidityPack(["address", "bytes"], [receiveAnyCallMock.address, _data]); + // const _payload = ethers.AbiCoder.defaultAbiCoder().encode(["address", "bytes"], [receiveAnyCallMock.address, _data]) + + + const {nativeFee} = await localEndpoint.estimateFees(REMOTE_CHAIN_ID, owner.address, _payload, false, ethers.utils.solidityPack( + ['uint16', 'uint256'], + [1, 3500000] + )); + // address _to, bytes calldata _data, uint16 _toChainID, bytes calldata _adapterParam + const nativeFee2 = await l1_CallProxy.estimateFees(receiveAnyCallMock.address, _data, REMOTE_CHAIN_ID, ethers.utils.solidityPack( + ['uint16', 'uint256'], + [1, 3500000]) + ); + expect(nativeFee).to.equal(nativeFee2); + + // 3. cross-chain + await sendAnyCallMock.testMsg(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: nativeFee}); + + + expect(await receiveAnyCallMock.idx()).to.equal(_idx); + expect(await receiveAnyCallMock.data()).to.equal(_msg); + }); + + it('should get refund fee', async function () { + const _idx = 1; + const _msg = "Hello world"; + + expect(await receiveAnyCallMock.idx()).to.equal(0); + expect(await receiveAnyCallMock.data()).to.equal("Nothing received yet"); + + // 1. set White list + await l1_CallProxy.setWhitelist(sendAnyCallMock.address, receiveAnyCallMock.address, REMOTE_CHAIN_ID, true); + + // 2. get native Fee + let iface = new ethers.utils.Interface(["function receiveMsg(uint256,string)"]); + let _data = iface.encodeFunctionData("receiveMsg", [_idx, _msg]); + const nativeFee = await l1_CallProxy.estimateFees(receiveAnyCallMock.address, _data, REMOTE_CHAIN_ID, "0x"); + + // 3. cross-chain + const beforeBalance = await ethers.provider.getBalance(owner.address); + const moreFee = ethers.utils.parseEther("10"); + await sendAnyCallMock.testMsg(receiveAnyCallMock.address, _idx, _msg, REMOTE_CHAIN_ID, {value: moreFee}); + + const afterBalance = await ethers.provider.getBalance(owner.address); + expect(beforeBalance.sub(afterBalance)).to.lt(moreFee) + + expect(await receiveAnyCallMock.idx()).to.equal(_idx); + expect(await receiveAnyCallMock.data()).to.equal(_msg); + }); + + }) + +}) \ No newline at end of file