diff --git a/.gitignore b/.gitignore index e108b40c..c6fe596f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ yarn.lock !broadcast broadcast/* broadcast/*/31337/ + +# ide files +.idea/ +*.iml diff --git a/README.md b/README.md index 9daba5b2..299ee0c7 100644 --- a/README.md +++ b/README.md @@ -27,36 +27,36 @@ Note that OpenZeppelin Contracts is pre-installed, so you can follow that as an This is a list of the most frequently needed commands. -### Build +### Install Dependencies -Build the contracts: +Install the dependencies: ```sh -$ forge build +$ bun install ``` -### Clean +### Build -Delete the build artifacts and cache directories: +Build/compile the contracts: ```sh -$ forge clean +$ forge build ``` -### Compile +### Test -Compile the contracts: +Run the tests: ```sh -$ forge build +$ forge test ``` -### Coverage +### Clean -Get a test coverage report: +Delete the build artifacts and cache directories: ```sh -$ forge coverage +$ forge clean ``` ### Deploy @@ -97,12 +97,12 @@ Lint the contracts: $ bun run lint ``` -### Test +### Coverage -Run the tests: +Get a test coverage report: ```sh -$ forge test +$ forge coverage ``` Generate test coverage and output result to the terminal: diff --git a/src/ICS02Client.sol b/src/ICS02Client.sol index 2ab42942..2d85c40d 100644 --- a/src/ICS02Client.sol +++ b/src/ICS02Client.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS02Client } from "./interfaces/IICS02Client.sol"; @@ -35,7 +35,7 @@ contract ICS02Client is IICS02Client, IICS02ClientErrors, Ownable { function getCounterparty(string calldata clientId) public view returns (CounterpartyInfo memory) { CounterpartyInfo memory counterpartyInfo = counterpartyInfos[clientId]; if (bytes(counterpartyInfo.clientId).length == 0) { - revert IBCClientNotFound(clientId); + revert IBCCounterpartyClientNotFound(clientId); } return counterpartyInfo; diff --git a/src/ICS20Transfer.sol b/src/ICS20Transfer.sol new file mode 100644 index 000000000..9a25f737 --- /dev/null +++ b/src/ICS20Transfer.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.25; + +import { IIBCApp } from "./interfaces/IIBCApp.sol"; +import { IICS20Errors } from "./errors/IICS20Errors.sol"; +import { ICS20Lib } from "./utils/ICS20Lib.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { IICS20Transfer } from "./interfaces/IICS20Transfer.sol"; +import { IICS26Router } from "./interfaces/IICS26Router.sol"; +import { IICS26RouterMsgs } from "./msgs/IICS26RouterMsgs.sol"; + +using SafeERC20 for IERC20; + +/* + * Things not handled yet: + * - Prefixed denoms (source chain is not the source) and the burning of tokens related to that + * - Separate escrow balance tracking + * - Related to escrow ^: invariant checking (where to implement that?) + * - Receiving packets + */ +contract ICS20Transfer is IIBCApp, IICS20Transfer, IICS20Errors, Ownable, ReentrancyGuard { + /// @param owner_ The owner of the contract + constructor(address owner_) Ownable(owner_) { } + + function sendTransfer(SendTransferMsg calldata msg_) external override returns (uint32) { + IICS26Router ibcRouter = IICS26Router(owner()); + + string memory sender = ICS20Lib.addressToHexString(msg.sender); + string memory sourcePort = ICS20Lib.addressToHexString(address(this)); + bytes memory packetData; + if (bytes(msg_.memo).length == 0) { + packetData = ICS20Lib.marshalJSON(msg_.denom, msg_.amount, sender, msg_.receiver); + } else { + packetData = ICS20Lib.marshalJSON(msg_.denom, msg_.amount, sender, msg_.receiver, msg_.memo); + } + + IICS26RouterMsgs.MsgSendPacket memory msgSendPacket = IICS26RouterMsgs.MsgSendPacket({ + sourcePort: sourcePort, + sourceChannel: msg_.sourceChannel, + destPort: msg_.destPort, + data: packetData, + timeoutTimestamp: msg_.timeoutTimestamp, // TODO: Default timestamp? + version: ICS20Lib.ICS20_VERSION + }); + + return ibcRouter.sendPacket(msgSendPacket); + } + + function onSendPacket(OnSendPacketCallback calldata msg_) external override onlyOwner nonReentrant { + if (keccak256(abi.encodePacked(msg_.packet.version)) != keccak256(abi.encodePacked(ICS20Lib.ICS20_VERSION))) { + revert ICS20UnexpectedVersion(msg_.packet.version); + } + + ICS20Lib.UnwrappedFungibleTokenPacketData memory packetData = ICS20Lib.unwrapPacketData(msg_.packet.data); + + // TODO: Maybe have a "ValidateBasic" type of function that checks the packet data, could be done in unwrapping? + + if (packetData.amount == 0) { + revert ICS20InvalidAmount(packetData.amount); + } + + // TODO: Handle prefixed denoms (source chain is not the source) and burn + + // The packet sender has to be either the packet data sender or the contract itself + // The scenarios are either the sender sent the packet directly to the router (msg_.sender == packetData.sender) + // or sender used the sendTransfer function, which makes this contract the sender (msg_.sender == address(this)) + if (msg_.sender != packetData.sender && msg_.sender != address(this)) { + revert ICS20MsgSenderIsNotPacketSender(msg_.sender, packetData.sender); + } + + _transferFrom(packetData.sender, address(this), packetData.erc20ContractAddress, packetData.amount); + + emit ICS20Transfer(packetData); + } + + function onRecvPacket(OnRecvPacketCallback calldata) + external + override + onlyOwner + nonReentrant + returns (bytes memory) + { + // TODO: Implement + return ""; + } + + function onAcknowledgementPacket(OnAcknowledgementPacketCallback calldata msg_) + external + override + onlyOwner + nonReentrant + { + ICS20Lib.UnwrappedFungibleTokenPacketData memory packetData = ICS20Lib.unwrapPacketData(msg_.packet.data); + bool isSuccessAck = true; + + if (keccak256(msg_.acknowledgement) != ICS20Lib.KECCAK256_SUCCESSFUL_ACKNOWLEDGEMENT_JSON) { + isSuccessAck = false; + _refundTokens(packetData); + } + + // Nothing needed to be done if the acknowledgement was successful, tokens are already in escrow or burnt + + emit ICS20Acknowledgement(packetData, msg_.acknowledgement, isSuccessAck); + } + + function onTimeoutPacket(OnTimeoutPacketCallback calldata msg_) external override onlyOwner nonReentrant { + ICS20Lib.UnwrappedFungibleTokenPacketData memory packetData = ICS20Lib.unwrapPacketData(msg_.packet.data); + _refundTokens(packetData); + + emit ICS20Timeout(packetData); + } + + // TODO: Implement escrow balance tracking + function _refundTokens(ICS20Lib.UnwrappedFungibleTokenPacketData memory data) private { + address refundee = data.sender; + IERC20(data.erc20ContractAddress).safeTransfer(refundee, data.amount); + } + + // TODO: Implement escrow balance tracking + function _transferFrom(address sender, address receiver, address tokenContract, uint256 amount) private { + // we snapshot our current balance of this token + uint256 ourStartingBalance = IERC20(tokenContract).balanceOf(receiver); + + IERC20(tokenContract).safeTransferFrom(sender, receiver, amount); + + // check what this particular ERC20 implementation actually gave us, since it doesn't + // have to be at all related to the _amount + uint256 actualEndingBalance = IERC20(tokenContract).balanceOf(receiver); + + uint256 expectedEndingBalance = ourStartingBalance + amount; + // a very strange ERC20 may trigger this condition, if we didn't have this we would + // underflow, so it's mostly just an error message printer + if (actualEndingBalance <= ourStartingBalance || actualEndingBalance != expectedEndingBalance) { + revert ICS20UnexpectedERC20Balance(expectedEndingBalance, actualEndingBalance); + } + } +} diff --git a/src/ICS26Router.sol b/src/ICS26Router.sol index d789e660..9d6caac1 100644 --- a/src/ICS26Router.sol +++ b/src/ICS26Router.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IIBCApp } from "./interfaces/IIBCApp.sol"; @@ -60,7 +60,7 @@ contract ICS26Router is IICS26Router, IBCStore, Ownable, IICS26RouterErrors, Ree /// @param msg_ The message for sending packets /// @return The sequence number of the packet function sendPacket(MsgSendPacket calldata msg_) external nonReentrant returns (uint32) { - string memory counterpartyId = ics02Client.getCounterparty(msg_.sourcePort).clientId; + string memory counterpartyId = ics02Client.getCounterparty(msg_.sourceChannel).clientId; // TODO: validate all identifiers if (msg_.timeoutTimestamp <= block.timestamp) { @@ -83,7 +83,11 @@ contract ICS26Router is IICS26Router, IBCStore, Ownable, IICS26RouterErrors, Ree IIBCAppCallbacks.OnSendPacketCallback memory sendPacketCallback = IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: msg.sender }); - apps[msg_.sourcePort].onSendPacket(sendPacketCallback); + IIBCApp app = apps[msg_.sourcePort]; + if (app == IIBCApp(address(0))) { + revert IBCAppNotFound(msg_.sourcePort); + } + app.onSendPacket(sendPacketCallback); IBCStore.commitPacket(packet); diff --git a/src/errors/IICS02ClientErrors.sol b/src/errors/IICS02ClientErrors.sol index 27652027..24463b61 100644 --- a/src/errors/IICS02ClientErrors.sol +++ b/src/errors/IICS02ClientErrors.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; interface IICS02ClientErrors { @@ -7,4 +7,7 @@ interface IICS02ClientErrors { /// @param clientId client identifier error IBCClientNotFound(string clientId); + + /// @param counterpartyClientId counterparty client identifier + error IBCCounterpartyClientNotFound(string counterpartyClientId); } diff --git a/src/errors/IICS20Errors.sol b/src/errors/IICS20Errors.sol new file mode 100644 index 000000000..24b4ba94 --- /dev/null +++ b/src/errors/IICS20Errors.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.25; + +interface IICS20Errors { + /// @param msgSender Address of the message sender + /// @param packetSender Address of the packet sender + error ICS20MsgSenderIsNotPacketSender(address msgSender, address packetSender); + + /// @param sender Address whose tokens are being transferred + error ICS20InvalidSender(string sender); + + /// @param amount Amount of tokens being transferred + error ICS20InvalidAmount(uint256 amount); + + /// @param tokenContract Address of the token contract + error ICS20InvalidTokenContract(string tokenContract); + + /// @param version Version string + error ICS20UnexpectedVersion(string version); + + /// @param expected Expected balance of the ERC20 token for ICS20Transfer + /// @param actual Actual balance of the ERC20 token for ICS20Transfer + error ICS20UnexpectedERC20Balance(uint256 expected, uint256 actual); + + // ICS20Lib Errors: + + /// @param position position in packet data bytes + /// @param expected expected bytes + /// @param actual actual bytes + error ICS20JSONUnexpectedBytes(uint256 position, bytes32 expected, bytes32 actual); + + /// @param position position in packet data bytes + /// @param actual actual value + error ICS20JSONClosingBraceNotFound(uint256 position, bytes1 actual); + + /// @param position position in packet data bytes + /// @param actual actual value + error ICS20JSONStringClosingDoubleQuoteNotFound(uint256 position, bytes1 actual); + + /// @param bz json string value + /// @param position position in packet data bytes + error ICS20JSONStringUnclosed(bytes bz, uint256 position); + + /// @param position position in packet data bytes + /// @param actual actual value + error ICS20JSONInvalidEscape(uint256 position, bytes1 actual); + + /// @param length length of the slice + error ICS20BytesSliceOverflow(uint256 length); + + /// @param length length of the bytes + /// @param start start index + /// @param end end index + error ICS20BytesSliceOutOfBounds(uint256 length, uint256 start, uint256 end); +} diff --git a/src/errors/IICS24HostErrors.sol b/src/errors/IICS24HostErrors.sol index 71f89fe6..a48087a2 100644 --- a/src/errors/IICS24HostErrors.sol +++ b/src/errors/IICS24HostErrors.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; interface IICS24HostErrors { diff --git a/src/errors/IICS26RouterErrors.sol b/src/errors/IICS26RouterErrors.sol index 9cc92668..69f1d7a6 100644 --- a/src/errors/IICS26RouterErrors.sol +++ b/src/errors/IICS26RouterErrors.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; interface IICS26RouterErrors { @@ -18,4 +18,6 @@ interface IICS26RouterErrors { error IBCAsyncAcknowledgementNotSupported(); error IBCPacketCommitmentMismatch(bytes32 expected, bytes32 actual); + + error IBCAppNotFound(string portId); } diff --git a/src/interfaces/IIBCApp.sol b/src/interfaces/IIBCApp.sol index 5bf753b7..8c399efc 100644 --- a/src/interfaces/IIBCApp.sol +++ b/src/interfaces/IIBCApp.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IIBCAppCallbacks } from "../msgs/IIBCAppCallbacks.sol"; diff --git a/src/interfaces/IICS02Client.sol b/src/interfaces/IICS02Client.sol index b4d34bd2..6f54deea 100644 --- a/src/interfaces/IICS02Client.sol +++ b/src/interfaces/IICS02Client.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS02ClientMsgs } from "../msgs/IICS02ClientMsgs.sol"; diff --git a/src/interfaces/IICS20Transfer.sol b/src/interfaces/IICS20Transfer.sol new file mode 100644 index 000000000..f8279794 --- /dev/null +++ b/src/interfaces/IICS20Transfer.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.25; + +import { ICS20Lib } from "../utils/ICS20Lib.sol"; +import { IICS20TransferMsgs } from "../msgs/IICS20TransferMsgs.sol"; + +interface IICS20Transfer is IICS20TransferMsgs { + /// @notice Called when a packet is handled in onSendPacket and a transfer has been initiated + /// @param packetData The transfer packet data + event ICS20Transfer(ICS20Lib.UnwrappedFungibleTokenPacketData packetData); + + // TODO: If we want error and/or success result in the event (resp.Result), parsing the acknowledgement is needed + /// @notice Called after handling acknowledgement in onAcknowledgementPacket + /// @param packetData The transfer packet data + /// @param acknowledgement The acknowledgement data + /// @param success Whether the acknowledgement received was a success or error + event ICS20Acknowledgement( + ICS20Lib.UnwrappedFungibleTokenPacketData packetData, bytes acknowledgement, bool success + ); + + /// @notice Called after handling a timeout in onTimeoutPacket + /// @param packetData The transfer packet data + event ICS20Timeout(ICS20Lib.UnwrappedFungibleTokenPacketData packetData); + + /// @notice Send a transfer + /// @param msg The message for sending a transfer + /// @return sequence The sequence number of the packet created + function sendTransfer(SendTransferMsg calldata msg) external returns (uint32 sequence); +} diff --git a/src/interfaces/IICS26Router.sol b/src/interfaces/IICS26Router.sol index f42e69b2..5cc1f462 100644 --- a/src/interfaces/IICS26Router.sol +++ b/src/interfaces/IICS26Router.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS26RouterMsgs } from "../msgs/IICS26RouterMsgs.sol"; diff --git a/src/interfaces/ILightClient.sol b/src/interfaces/ILightClient.sol index 9bd2ddf0..e570b780 100644 --- a/src/interfaces/ILightClient.sol +++ b/src/interfaces/ILightClient.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { ILightClientMsgs } from "../msgs/ILightClientMsgs.sol"; diff --git a/src/msgs/IIBCAppCallbacks.sol b/src/msgs/IIBCAppCallbacks.sol index 715f363b..157e3336 100644 --- a/src/msgs/IIBCAppCallbacks.sol +++ b/src/msgs/IIBCAppCallbacks.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS26RouterMsgs } from "./IICS26RouterMsgs.sol"; diff --git a/src/msgs/IICS02ClientMsgs.sol b/src/msgs/IICS02ClientMsgs.sol index 81cb821a..4dc86b60 100644 --- a/src/msgs/IICS02ClientMsgs.sol +++ b/src/msgs/IICS02ClientMsgs.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; interface IICS02ClientMsgs { diff --git a/src/msgs/IICS20TransferMsgs.sol b/src/msgs/IICS20TransferMsgs.sol new file mode 100644 index 000000000..5a251691 --- /dev/null +++ b/src/msgs/IICS20TransferMsgs.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.25; + +interface IICS20TransferMsgs { + /// @notice Message for sending a transfer + struct SendTransferMsg { + /// This is expected to be contract address of the token contract + string denom; + /// The amount of tokens to transfer + uint256 amount; + /// The receiver of the transfer on the counterparty chain + string receiver; + /// The source channel (client identifier) + string sourceChannel; + /// The destination port on the counterparty chain + string destPort; + /// The absolute timeout timestamp + uint32 timeoutTimestamp; + /// Optional memo + string memo; + } +} diff --git a/src/msgs/IICS26RouterMsgs.sol b/src/msgs/IICS26RouterMsgs.sol index 2e9bc9e2..b913f6ba 100644 --- a/src/msgs/IICS26RouterMsgs.sol +++ b/src/msgs/IICS26RouterMsgs.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS02ClientMsgs } from "./IICS02ClientMsgs.sol"; diff --git a/src/msgs/ILightClientMsgs.sol b/src/msgs/ILightClientMsgs.sol index 5b425b3f..67d37af6 100644 --- a/src/msgs/ILightClientMsgs.sol +++ b/src/msgs/ILightClientMsgs.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IICS02ClientMsgs } from "./IICS02ClientMsgs.sol"; diff --git a/src/utils/IBCIdentifiers.sol b/src/utils/IBCIdentifiers.sol index 0d0ffad1..5108d39d 100644 --- a/src/utils/IBCIdentifiers.sol +++ b/src/utils/IBCIdentifiers.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; /// @title IBC Identifiers diff --git a/src/utils/IBCStore.sol b/src/utils/IBCStore.sol index d0341262..441f61f9 100644 --- a/src/utils/IBCStore.sol +++ b/src/utils/IBCStore.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { IIBCStore } from "./IIBCStore.sol"; diff --git a/src/utils/ICS20Lib.sol b/src/utils/ICS20Lib.sol new file mode 100644 index 000000000..1888be23 --- /dev/null +++ b/src/utils/ICS20Lib.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.25; + +// solhint-disable no-inline-assembly + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { IICS20Errors } from "../errors/IICS20Errors.sol"; + +// This library is mostly copied, with minor adjustments, from https://github.com/hyperledger-labs/yui-ibc-solidity +library ICS20Lib { + /** + * @dev PacketData is defined in + * [ICS-20](https://github.com/cosmos/ibc/tree/main/spec/app/ics-020-fungible-token-transfer). + */ + struct PacketDataJSON { + string denom; + string sender; + string receiver; + uint256 amount; + string memo; + } + + /// @notice Convenience type used after unmarshalling the packet data and converting addresses + struct UnwrappedFungibleTokenPacketData { + address erc20ContractAddress; + uint256 amount; + address sender; + string receiver; + string memo; + } + + string public constant ICS20_VERSION = "ics20-1"; + + bytes public constant SUCCESSFUL_ACKNOWLEDGEMENT_JSON = bytes("{\"result\":\"AQ==\"}"); + bytes public constant FAILED_ACKNOWLEDGEMENT_JSON = bytes("{\"error\":\"failed\"}"); + bytes32 internal constant KECCAK256_SUCCESSFUL_ACKNOWLEDGEMENT_JSON = keccak256(SUCCESSFUL_ACKNOWLEDGEMENT_JSON); + + uint256 private constant CHAR_DOUBLE_QUOTE = 0x22; + uint256 private constant CHAR_SLASH = 0x2f; + uint256 private constant CHAR_BACKSLASH = 0x5c; + uint256 private constant CHAR_F = 0x66; + uint256 private constant CHAR_R = 0x72; + uint256 private constant CHAR_N = 0x6e; + uint256 private constant CHAR_B = 0x62; + uint256 private constant CHAR_T = 0x74; + uint256 private constant CHAR_CLOSING_BRACE = 0x7d; + uint256 private constant CHAR_M = 0x6d; + + bytes16 private constant HEX_DIGITS = "0123456789abcdef"; + + /** + * @dev marshalUnsafeJSON marshals PacketData into JSON bytes without escaping. + * `memo` field is omitted if it is empty. + */ + function marshalUnsafeJSON(PacketDataJSON memory data) internal pure returns (bytes memory) { + if (bytes(data.memo).length == 0) { + return marshalJSON(data.denom, data.amount, data.sender, data.receiver); + } else { + return marshalJSON(data.denom, data.amount, data.sender, data.receiver, data.memo); + } + } + + /** + * @dev marshalJSON marshals PacketData into JSON bytes with escaping. + */ + function marshalJSON( + string memory escapedDenom, + uint256 amount, + string memory escapedSender, + string memory escapedReceiver, + string memory escapedMemo + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + "{\"amount\":\"", + Strings.toString(amount), + "\",\"denom\":\"", + escapedDenom, + "\",\"memo\":\"", + escapedMemo, + "\",\"receiver\":\"", + escapedReceiver, + "\",\"sender\":\"", + escapedSender, + "\"}" + ); + } + + /** + * @dev marshalJSON marshals PacketData into JSON bytes with escaping. + */ + function marshalJSON( + string memory escapedDenom, + uint256 amount, + string memory escapedSender, + string memory escapedReceiver + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + "{\"amount\":\"", + Strings.toString(amount), + "\",\"denom\":\"", + escapedDenom, + "\",\"receiver\":\"", + escapedReceiver, + "\",\"sender\":\"", + escapedSender, + "\"}" + ); + } + + /** + * @dev unmarshalJSON unmarshals JSON bytes into PacketData. + */ + function unmarshalJSON(bytes calldata bz) internal pure returns (PacketDataJSON memory) { + PacketDataJSON memory pd; + uint256 pos = 0; + + unchecked { + if (bytes32(bz[pos:pos + 11]) != bytes32("{\"amount\":\"")) { + revert IICS20Errors.ICS20JSONUnexpectedBytes(pos, bytes32("{\"amount\":\""), bytes32(bz[pos:pos + 11])); + } + (pd.amount, pos) = parseUint256String(bz, pos + 11); + if (bytes32(bz[pos:pos + 10]) != bytes32(",\"denom\":\"")) { + revert IICS20Errors.ICS20JSONUnexpectedBytes(pos, bytes32(",\"denom\":\""), bytes32(bz[pos:pos + 10])); + } + (pd.denom, pos) = parseString(bz, pos + 10); + + if (uint256(uint8(bz[pos + 2])) == CHAR_M) { + if (bytes32(bz[pos:pos + 9]) != bytes32(",\"memo\":\"")) { + // solhint-disable-next-line max-line-length + revert IICS20Errors.ICS20JSONUnexpectedBytes(pos, bytes32(",\"memo\":\""), bytes32(bz[pos:pos + 9])); + } + (pd.memo, pos) = parseString(bz, pos + 9); + } + + if (bytes32(bz[pos:pos + 13]) != bytes32(",\"receiver\":\"")) { + revert IICS20Errors.ICS20JSONUnexpectedBytes( + pos, bytes32(",\"receiver\":\""), bytes32(bz[pos:pos + 13]) + ); + } + (pd.receiver, pos) = parseString(bz, pos + 13); + + if (bytes32(bz[pos:pos + 11]) != bytes32(",\"sender\":\"")) { + revert IICS20Errors.ICS20JSONUnexpectedBytes(pos, bytes32(",\"sender\":\""), bytes32(bz[pos:pos + 11])); + } + (pd.sender, pos) = parseString(bz, pos + 11); + + if (pos != bz.length - 1 || uint256(uint8(bz[pos])) != CHAR_CLOSING_BRACE) { + revert IICS20Errors.ICS20JSONClosingBraceNotFound(pos, bz[pos]); + } + } + + return pd; + } + + /** + * @dev parseUint256String parses `bz` from a position `pos` to produce a uint256. + */ + function parseUint256String(bytes calldata bz, uint256 pos) internal pure returns (uint256, uint256) { + uint256 ret = 0; + unchecked { + for (; pos < bz.length; pos++) { + uint256 c = uint256(uint8(bz[pos])); + if (c < 48 || c > 57) { + break; + } + ret = ret * 10 + (c - 48); + } + if (pos >= bz.length || uint256(uint8(bz[pos])) != CHAR_DOUBLE_QUOTE) { + revert IICS20Errors.ICS20JSONStringClosingDoubleQuoteNotFound(pos, bz[pos]); + } + return (ret, pos + 1); + } + } + + /** + * @dev parseString parses `bz` from a position `pos` to produce a string. + */ + function parseString(bytes calldata bz, uint256 pos) internal pure returns (string memory, uint256) { + unchecked { + for (uint256 i = pos; i < bz.length; i++) { + uint256 c = uint256(uint8(bz[i])); + if (c == CHAR_DOUBLE_QUOTE) { + return (string(bz[pos:i]), i + 1); + } else if (c == CHAR_BACKSLASH && i + 1 < bz.length) { + i++; + c = uint256(uint8(bz[i])); + if ( + c != CHAR_DOUBLE_QUOTE && c != CHAR_SLASH && c != CHAR_BACKSLASH && c != CHAR_F && c != CHAR_R + && c != CHAR_N && c != CHAR_B && c != CHAR_T + ) { + revert IICS20Errors.ICS20JSONInvalidEscape(i, bz[i]); + } + } + } + } + revert IICS20Errors.ICS20JSONStringUnclosed(bz, pos); + } + + function isEscapedJSONString(string calldata s) internal pure returns (bool) { + bytes memory bz = bytes(s); + unchecked { + for (uint256 i = 0; i < bz.length; i++) { + uint256 c = uint256(uint8(bz[i])); + if (c == CHAR_DOUBLE_QUOTE) { + return false; + } else if (c == CHAR_BACKSLASH && i + 1 < bz.length) { + i++; + c = uint256(uint8(bz[i])); + if ( + c != CHAR_DOUBLE_QUOTE && c != CHAR_SLASH && c != CHAR_BACKSLASH && c != CHAR_F && c != CHAR_R + && c != CHAR_N && c != CHAR_B && c != CHAR_T + ) { + return false; + } + } + } + } + return true; + } + + function isEscapeNeededString(bytes memory bz) internal pure returns (bool) { + unchecked { + for (uint256 i = 0; i < bz.length; i++) { + uint256 c = uint256(uint8(bz[i])); + if (c == CHAR_DOUBLE_QUOTE) { + return true; + } + } + } + return false; + } + + /** + * @dev addressToHexString converts an address to a hex string. + */ + function addressToHexString(address addr) internal pure returns (string memory) { + uint256 localValue = uint256(uint160(addr)); + bytes memory buffer = new bytes(42); + buffer[0] = "0"; + buffer[1] = "x"; + unchecked { + for (int256 i = 41; i >= 2; --i) { + buffer[uint256(i)] = HEX_DIGITS[localValue & 0xf]; + localValue >>= 4; + } + } + return string(buffer); + } + + /** + * @dev hexStringToAddress converts a hex string to an address. + */ + function hexStringToAddress(string memory addrHexString) internal pure returns (address, bool) { + bytes memory addrBytes = bytes(addrHexString); + if (addrBytes.length != 42) { + return (address(0), false); + } else if (addrBytes[0] != "0" || addrBytes[1] != "x") { + return (address(0), false); + } + uint256 addr = 0; + unchecked { + for (uint256 i = 2; i < 42; i++) { + uint256 c = uint256(uint8(addrBytes[i])); + if (c >= 48 && c <= 57) { + addr = addr * 16 + (c - 48); + } else if (c >= 97 && c <= 102) { + addr = addr * 16 + (c - 87); + } else if (c >= 65 && c <= 70) { + addr = addr * 16 + (c - 55); + } else { + return (address(0), false); + } + } + } + return (address(uint160(addr)), true); + } + + /** + * @dev slice returns a slice of the original bytes from `start` to `start + length`. + * This is a copy from https://github.com/GNSPS/solidity-bytes-utils/blob/v0.8.0/contracts/BytesLib.sol + */ + function slice(bytes memory _bytes, uint256 _start, uint256 _length) internal pure returns (bytes memory) { + if (_length + 31 < _length) { + revert IICS20Errors.ICS20BytesSliceOverflow(_length); + } else if (_start + _length > _bytes.length) { + revert IICS20Errors.ICS20BytesSliceOutOfBounds(_bytes.length, _start, _start + _length); + } + + bytes memory tempBytes; + + assembly { + switch iszero(_length) + case 0 { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // The first word of the slice result is potentially a partial + // word read from the original array. To read it, we calculate + // the length of that partial word and start copying that many + // bytes into the array. The first word we copy will start with + // data we don't care about, but the last `lengthmod` bytes will + // land at the beginning of the contents of the new array. When + // we're done copying, we overwrite the full first word with + // the actual length of the slice. + let lengthmod := and(_length, 31) + + // The multiplication in the next line is necessary + // because when slicing multiples of 32 bytes (lengthmod == 0) + // the following copy loop was copying the origin's length + // and then ending prematurely not copying everything it should. + let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) + let end := add(mc, _length) + + for { + // The multiplication in the next line has the same exact purpose + // as the one above. + let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) + } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { mstore(mc, mload(cc)) } + + mstore(tempBytes, _length) + + //update free-memory pointer + //allocating the array padded to 32 bytes like the compiler does now + mstore(0x40, and(add(mc, 31), not(31))) + } + //if we want a zero-length slice let's just return a zero-length array + default { + tempBytes := mload(0x40) + //zero out the 32 bytes slice we are about to return + //we need to do it because Solidity does not garbage collect + mstore(tempBytes, 0) + + mstore(0x40, add(tempBytes, 0x20)) + } + } + + return tempBytes; + } + + /** + * @dev equal returns true if two byte arrays are equal. + */ + function equal(bytes memory a, bytes memory b) internal pure returns (bool) { + return keccak256(a) == keccak256(b); + } + + function unwrapPacketData(bytes calldata data) internal pure returns (UnwrappedFungibleTokenPacketData memory) { + ICS20Lib.PacketDataJSON memory packetData = ICS20Lib.unmarshalJSON(data); + + (address tokenContract, bool tokenContractConvertSuccess) = ICS20Lib.hexStringToAddress(packetData.denom); + if (!tokenContractConvertSuccess) { + revert IICS20Errors.ICS20InvalidTokenContract(packetData.denom); + } + + (address sender, bool senderConvertSuccess) = ICS20Lib.hexStringToAddress(packetData.sender); + if (!senderConvertSuccess) { + revert IICS20Errors.ICS20InvalidSender(packetData.sender); + } + + return UnwrappedFungibleTokenPacketData({ + erc20ContractAddress: tokenContract, + amount: packetData.amount, + sender: sender, + receiver: packetData.receiver, + memo: packetData.memo + }); + } +} diff --git a/src/utils/ICS24Host.sol b/src/utils/ICS24Host.sol index 6bd8d69d..bd9617f0 100644 --- a/src/utils/ICS24Host.sol +++ b/src/utils/ICS24Host.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; diff --git a/src/utils/IIBCStore.sol b/src/utils/IIBCStore.sol index 2a53b4b5..78fadfb4 100644 --- a/src/utils/IIBCStore.sol +++ b/src/utils/IIBCStore.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity >=0.8.25; /// @title IBC Store Interface diff --git a/test/DummyLightClient.sol b/test/DummyLightClient.sol new file mode 100644 index 000000000..21ca8bf0 --- /dev/null +++ b/test/DummyLightClient.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +// solhint-disable no-empty-blocks + +import { ILightClient } from "../src/interfaces/ILightClient.sol"; + +contract DummyLightClient is ILightClient { + UpdateResult public updateResult; + uint32 public membershipResult; + bytes public latestUpdateMsg; + + constructor(UpdateResult updateResult_, uint32 membershipResult_) { + updateResult = updateResult_; + membershipResult = membershipResult_; + } + + function updateClient(bytes calldata updateMsg) external returns (UpdateResult) { + latestUpdateMsg = updateMsg; + return updateResult; + } + + function membership(MsgMembership calldata) external view returns (uint256) { + return membershipResult; + } + + function misbehaviour(bytes calldata misbehaviourMsg) external { } + + function upgradeClient(bytes calldata upgradeMsg) external { } + + // custom functions to return values we want + function setUpdateResult(UpdateResult updateResult_) external { + updateResult = updateResult_; + } + + function setMembershipResult(uint32 membershipResult_) external { + membershipResult = membershipResult_; + } +} diff --git a/test/ICS02ClientTest.t.sol b/test/ICS02ClientTest.t.sol new file mode 100644 index 000000000..1a697fd9 --- /dev/null +++ b/test/ICS02ClientTest.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +// solhint-disable custom-errors,max-line-length + +import { Test } from "forge-std/src/Test.sol"; +import { IICS02Client } from "../src/interfaces/IICS02Client.sol"; +import { ICS02Client } from "../src/ICS02Client.sol"; +import { IICS02ClientMsgs } from "../src/msgs/IICS02ClientMsgs.sol"; +import { ILightClient } from "../src/interfaces/ILightClient.sol"; +import { ILightClientMsgs } from "../src/msgs/ILightClientMsgs.sol"; +import { DummyLightClient } from "./DummyLightClient.sol"; + +contract ICS02ClientTest is Test { + IICS02Client public ics02Client; + DummyLightClient public lightClient; + + function setUp() public { + lightClient = new DummyLightClient(ILightClientMsgs.UpdateResult.Update, 0); + ics02Client = new ICS02Client(address(this)); + } + + function test_ICS02Client() public { + string memory counterpartyClient = "42-dummy-01"; + string memory clientIdentifier = ics02Client.addClient( + "07-tendermint", IICS02ClientMsgs.CounterpartyInfo(counterpartyClient), address(lightClient) + ); + + ILightClient fetchedLightClient = ics02Client.getClient(clientIdentifier); + assertNotEq(address(fetchedLightClient), address(0), "client not found"); + + IICS02Client.CounterpartyInfo memory counterpartyInfo = ics02Client.getCounterparty(clientIdentifier); + assertEq(counterpartyInfo.clientId, counterpartyClient, "counterpartyInfo not found"); + + bytes memory updateMsg = "testUpdateMsg"; + ILightClient.UpdateResult updateResult = ics02Client.updateClient(clientIdentifier, updateMsg); + assertEq(uint256(updateResult), uint256(ILightClientMsgs.UpdateResult.Update), "updateClient failed"); + assertEq(updateMsg, lightClient.latestUpdateMsg(), "updateClient failed"); + } +} diff --git a/test/ICS20TransferTest.t.sol b/test/ICS20TransferTest.t.sol new file mode 100644 index 000000000..7c7f3d8b --- /dev/null +++ b/test/ICS20TransferTest.t.sol @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +// solhint-disable custom-errors,max-line-length + +import { Test } from "forge-std/src/Test.sol"; +import { IICS26RouterMsgs } from "../src/msgs/IICS26RouterMsgs.sol"; +import { IIBCAppCallbacks } from "../src/msgs/IIBCAppCallbacks.sol"; +import { IICS20Transfer } from "../src/interfaces/IICS20Transfer.sol"; +import { ICS20Transfer } from "../src/ICS20Transfer.sol"; +import { TestERC20, MalfunctioningERC20 } from "./TestERC20.sol"; +import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import { ICS20Lib } from "../src/utils/ICS20Lib.sol"; +import { IICS20Errors } from "../src/errors/IICS20Errors.sol"; + +contract ICS20TransferTest is Test { + ICS20Transfer public ics20Transfer; + TestERC20 public erc20; + string public erc20AddressStr; + + address public sender; + string public senderStr; + string public receiver; + uint256 public defaultAmount = 100; + bytes public data; + IICS26RouterMsgs.Packet public packet; + + function setUp() public { + ics20Transfer = new ICS20Transfer(address(this)); + erc20 = new TestERC20(); + + sender = makeAddr("sender"); + + erc20AddressStr = ICS20Lib.addressToHexString(address(erc20)); + senderStr = ICS20Lib.addressToHexString(sender); + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, senderStr, receiver, "memo"); + + packet = IICS26RouterMsgs.Packet({ + sequence: 0, + timeoutTimestamp: 0, + sourcePort: "sourcePort", + sourceChannel: "sourceChannel", + destPort: "destinationPort", + destChannel: "destinationChannel", + version: ICS20Lib.ICS20_VERSION, + data: data + }); + } + + function test_success_onSendPacket() public { + erc20.mint(sender, defaultAmount); + + vm.prank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + uint256 senderBalanceAfter = erc20.balanceOf(sender); + uint256 contractBalanceAfter = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfter, 0); + assertEq(contractBalanceAfter, defaultAmount); + } + + function test_failure_onSendPacket() public { + // test missing approval + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, address(ics20Transfer), 0, defaultAmount + ) + ); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test insufficient balance + vm.prank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, sender, 0, defaultAmount) + ); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test invalid amount + data = ICS20Lib.marshalJSON(erc20AddressStr, 0, senderStr, receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidAmount.selector, 0)); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test invalid data + data = bytes("invalid"); + packet.data = data; + vm.expectRevert(bytes("")); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test invalid sender + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, "invalid", receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidSender.selector, "invalid")); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test msg sender is not packet sender + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, senderStr, receiver, "memo"); + packet.data = data; + address someoneElse = makeAddr("someoneElse"); + vm.expectRevert( + abi.encodeWithSelector(IICS20Errors.ICS20MsgSenderIsNotPacketSender.selector, someoneElse, sender) + ); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: someoneElse })); + + // test invalid token contract + data = ICS20Lib.marshalJSON("invalid", defaultAmount, senderStr, receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidTokenContract.selector, "invalid")); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + // test invalid version + packet.version = "invalid"; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20UnexpectedVersion.selector, "invalid")); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + // Reset version + packet.version = ICS20Lib.ICS20_VERSION; + + // test malfunctioning transfer + MalfunctioningERC20 malfunctioningERC20 = new MalfunctioningERC20(); + malfunctioningERC20.mint(sender, defaultAmount); + vm.prank(sender); + malfunctioningERC20.approve(address(ics20Transfer), defaultAmount); + string memory malfuncERC20AddressStr = ICS20Lib.addressToHexString(address(malfunctioningERC20)); + data = ICS20Lib.marshalJSON(malfuncERC20AddressStr, defaultAmount, senderStr, receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20UnexpectedERC20Balance.selector, defaultAmount, 0)); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + } + + function test_success_onAcknowledgementPacketWithSuccessAck() public { + erc20.mint(sender, defaultAmount); + + vm.prank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + uint256 senderBalanceAfterSend = erc20.balanceOf(sender); + uint256 contractBalanceAfterSend = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterSend, 0); + assertEq(contractBalanceAfterSend, defaultAmount); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Acknowledgement(_getPacketData(), ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, true); + ics20Transfer.onAcknowledgementPacket( + IIBCAppCallbacks.OnAcknowledgementPacketCallback({ + packet: packet, + acknowledgement: ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, + relayer: makeAddr("relayer") + }) + ); + + // Nothing should change + uint256 senderBalanceAfterAck = erc20.balanceOf(sender); + uint256 contractBalanceAfterAck = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterAck, 0); + assertEq(contractBalanceAfterAck, defaultAmount); + } + + function test_success_onAcknowledgementPacketWithFailedAck() public { + erc20.mint(sender, defaultAmount); + + vm.prank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + uint256 senderBalanceAfterSend = erc20.balanceOf(sender); + uint256 contractBalanceAfterSend = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterSend, 0); + assertEq(contractBalanceAfterSend, defaultAmount); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Acknowledgement(_getPacketData(), ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, false); + ics20Transfer.onAcknowledgementPacket( + IIBCAppCallbacks.OnAcknowledgementPacketCallback({ + packet: packet, + acknowledgement: ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, + relayer: makeAddr("relayer") + }) + ); + + // transfer should be reverted + uint256 senderBalanceAfterAck = erc20.balanceOf(sender); + uint256 contractBalanceAfterAck = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterAck, defaultAmount); + assertEq(contractBalanceAfterAck, 0); + } + + function test_failure_onAcknowledgementPacket() public { + // test invalid data + data = bytes("invalid"); + packet.data = data; + vm.expectRevert(bytes("")); + ics20Transfer.onAcknowledgementPacket( + IIBCAppCallbacks.OnAcknowledgementPacketCallback({ + packet: packet, + acknowledgement: ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, + relayer: makeAddr("relayer") + }) + ); + + // test invalid contract + data = ICS20Lib.marshalJSON("invalid", defaultAmount, senderStr, receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidTokenContract.selector, "invalid")); + ics20Transfer.onAcknowledgementPacket( + IIBCAppCallbacks.OnAcknowledgementPacketCallback({ + packet: packet, + acknowledgement: ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, + relayer: makeAddr("relayer") + }) + ); + + // test invalid sender + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, "invalid", receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidSender.selector, "invalid")); + ics20Transfer.onAcknowledgementPacket( + IIBCAppCallbacks.OnAcknowledgementPacketCallback({ + packet: packet, + acknowledgement: ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, + relayer: makeAddr("relayer") + }) + ); + } + + function test_success_onTimeoutPacket() public { + erc20.mint(sender, defaultAmount); + + vm.prank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + ics20Transfer.onSendPacket(IIBCAppCallbacks.OnSendPacketCallback({ packet: packet, sender: sender })); + + uint256 senderBalanceAfterSend = erc20.balanceOf(sender); + uint256 contractBalanceAfterSend = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterSend, 0); + assertEq(contractBalanceAfterSend, defaultAmount); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Timeout(_getPacketData()); + ics20Transfer.onTimeoutPacket( + IIBCAppCallbacks.OnTimeoutPacketCallback({ packet: packet, relayer: makeAddr("relayer") }) + ); + + // transfer should be reverted + uint256 senderBalanceAfterAck = erc20.balanceOf(sender); + uint256 contractBalanceAfterAck = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterAck, defaultAmount); + assertEq(contractBalanceAfterAck, 0); + } + + function test_failure_onTimeoutPacket() public { + // test invalid data + data = bytes("invalid"); + packet.data = data; + vm.expectRevert(bytes("")); + ics20Transfer.onTimeoutPacket( + IIBCAppCallbacks.OnTimeoutPacketCallback({ packet: packet, relayer: makeAddr("relayer") }) + ); + + // test invalid contract + data = ICS20Lib.marshalJSON("invalid", defaultAmount, senderStr, receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidTokenContract.selector, "invalid")); + ics20Transfer.onTimeoutPacket( + IIBCAppCallbacks.OnTimeoutPacketCallback({ packet: packet, relayer: makeAddr("relayer") }) + ); + + // test invalid sender + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, "invalid", receiver, "memo"); + packet.data = data; + vm.expectRevert(abi.encodeWithSelector(IICS20Errors.ICS20InvalidSender.selector, "invalid")); + ics20Transfer.onTimeoutPacket( + IIBCAppCallbacks.OnTimeoutPacketCallback({ packet: packet, relayer: makeAddr("relayer") }) + ); + } + + function _getPacketData() internal view returns (ICS20Lib.UnwrappedFungibleTokenPacketData memory) { + return ICS20Lib.UnwrappedFungibleTokenPacketData({ + sender: sender, + receiver: receiver, + erc20ContractAddress: address(erc20), + amount: defaultAmount, + memo: "memo" + }); + } +} diff --git a/test/IntegrationTest.t.sol b/test/IntegrationTest.t.sol new file mode 100644 index 000000000..6186c285 --- /dev/null +++ b/test/IntegrationTest.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +// solhint-disable custom-errors,max-line-length + +import { Test } from "forge-std/src/Test.sol"; +import { IICS02Client } from "../src/interfaces/IICS02Client.sol"; +import { IICS02ClientMsgs } from "../src/msgs/IICS02ClientMsgs.sol"; +import { ICS20Transfer } from "../src/ICS20Transfer.sol"; +import { IICS20Transfer } from "../src/interfaces/IICS20Transfer.sol"; +import { IICS20TransferMsgs } from "../src/msgs/IICS20TransferMsgs.sol"; +import { TestERC20 } from "./TestERC20.sol"; +import { ICS02Client } from "../src/ICS02Client.sol"; +import { ICS26Router } from "../src/ICS26Router.sol"; +import { IICS26RouterMsgs } from "../src/msgs/IICS26RouterMsgs.sol"; +import { DummyLightClient } from "./DummyLightClient.sol"; +import { ILightClientMsgs } from "../src/msgs/ILightClientMsgs.sol"; +import { ICS20Lib } from "../src/utils/ICS20Lib.sol"; +import { ICS24Host } from "../src/utils/ICS24Host.sol"; + +contract IntegrationTest is Test { + IICS02Client public ics02Client; + ICS26Router public ics26Router; + DummyLightClient public lightClient; + string public clientIdentifier; + ICS20Transfer public ics20Transfer; + string public ics20AddressStr; + TestERC20 public erc20; + string public erc20AddressStr; + string public counterpartyClient = "42-dummy-01"; + + uint256 public defaultAmount = 1000; + address public sender; + string public senderStr; + string public receiver = "someReceiver"; + bytes public data; + IICS26RouterMsgs.MsgSendPacket public msgSendPacket; + + function setUp() public { + ics02Client = new ICS02Client(address(this)); + ics26Router = new ICS26Router(address(ics02Client), address(this)); + lightClient = new DummyLightClient(ILightClientMsgs.UpdateResult.Update, 0); + ics20Transfer = new ICS20Transfer(address(ics26Router)); + erc20 = new TestERC20(); + erc20AddressStr = ICS20Lib.addressToHexString(address(erc20)); + + clientIdentifier = ics02Client.addClient( + "07-tendermint", IICS02ClientMsgs.CounterpartyInfo(counterpartyClient), address(lightClient) + ); + ics20AddressStr = ICS20Lib.addressToHexString(address(ics20Transfer)); + ics26Router.addIBCApp("", address(ics20Transfer)); + + sender = makeAddr("sender"); + senderStr = ICS20Lib.addressToHexString(sender); + data = ICS20Lib.marshalJSON(erc20AddressStr, defaultAmount, senderStr, receiver, "memo"); + msgSendPacket = IICS26RouterMsgs.MsgSendPacket({ + sourcePort: ics20AddressStr, + sourceChannel: clientIdentifier, + destPort: "transfer", + data: data, + timeoutTimestamp: uint32(block.timestamp) + 1000, + version: ICS20Lib.ICS20_VERSION + }); + } + + function test_success_sendICS20PacketDirectlyFromRouter() public { + erc20.mint(sender, defaultAmount); + vm.startPrank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + uint32 sequence = ics26Router.sendPacket(msgSendPacket); + assertEq(sequence, 1); + + bytes32 path = + ICS24Host.packetCommitmentKeyCalldata(msgSendPacket.sourcePort, msgSendPacket.sourceChannel, sequence); + bytes32 storedCommitment = ics26Router.getCommitment(path); + IICS26RouterMsgs.Packet memory packet = _getPacket(msgSendPacket, sequence); + assertEq(storedCommitment, ICS24Host.packetCommitmentBytes32(packet)); + + IICS26RouterMsgs.MsgAckPacket memory ackMsg = IICS26RouterMsgs.MsgAckPacket({ + packet: packet, + acknowledgement: ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, + proofAcked: bytes("doesntmatter"), // dummy client will accept + proofHeight: IICS02ClientMsgs.Height({ revisionNumber: 1, revisionHeight: 42 }) // dummy client will accept + }); + vm.expectEmit(); + emit IICS20Transfer.ICS20Acknowledgement(_getPacketData(), ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, true); + ics26Router.ackPacket(ackMsg); + // commitment should be deleted + storedCommitment = ics26Router.getCommitment(path); + assertEq(storedCommitment, 0); + + uint256 senderBalanceAfter = erc20.balanceOf(sender); + uint256 contractBalanceAfter = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfter, 0); + assertEq(contractBalanceAfter, defaultAmount); + } + + function test_success_sendICS20PacketFromICSContract() public { + IICS26RouterMsgs.Packet memory packet = _sendICS20Transfer(); + + IICS26RouterMsgs.MsgAckPacket memory ackMsg = IICS26RouterMsgs.MsgAckPacket({ + packet: packet, + acknowledgement: ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, + proofAcked: bytes("doesntmatter"), // dummy client will accept + proofHeight: IICS02ClientMsgs.Height({ revisionNumber: 1, revisionHeight: 42 }) // dummy client will accept + }); + vm.expectEmit(); + emit IICS20Transfer.ICS20Acknowledgement(_getPacketData(), ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, true); + ics26Router.ackPacket(ackMsg); + // commitment should be deleted + bytes32 path = ICS24Host.packetCommitmentKeyCalldata( + msgSendPacket.sourcePort, msgSendPacket.sourceChannel, packet.sequence + ); + bytes32 storedCommitment = ics26Router.getCommitment(path); + assertEq(storedCommitment, 0); + + uint256 senderBalanceAfter = erc20.balanceOf(sender); + uint256 contractBalanceAfter = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfter, 0); + assertEq(contractBalanceAfter, defaultAmount); + } + + function test_success_failedCounterpartyAckForICS20Packet() public { + IICS26RouterMsgs.Packet memory packet = _sendICS20Transfer(); + + IICS26RouterMsgs.MsgAckPacket memory ackMsg = IICS26RouterMsgs.MsgAckPacket({ + packet: packet, + acknowledgement: ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, + proofAcked: bytes("doesntmatter"), // dummy client will accept + proofHeight: IICS02ClientMsgs.Height({ revisionNumber: 1, revisionHeight: 42 }) // dummy client will accept + }); + vm.expectEmit(); + emit IICS20Transfer.ICS20Acknowledgement(_getPacketData(), ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, false); + ics26Router.ackPacket(ackMsg); + // commitment should be deleted + bytes32 path = ICS24Host.packetCommitmentKeyCalldata( + msgSendPacket.sourcePort, msgSendPacket.sourceChannel, packet.sequence + ); + bytes32 storedCommitment = ics26Router.getCommitment(path); + assertEq(storedCommitment, 0); + + // transfer should be reverted + uint256 senderBalanceAfterAck = erc20.balanceOf(sender); + uint256 contractBalanceAfterAck = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterAck, defaultAmount); + assertEq(contractBalanceAfterAck, 0); + } + + function test_success_timeoutICS20Packet() public { + IICS26RouterMsgs.Packet memory packet = _sendICS20Transfer(); + + // make light client return timestamp that is after our timeout + lightClient.setMembershipResult(msgSendPacket.timeoutTimestamp + 1); + + IICS26RouterMsgs.MsgTimeoutPacket memory timeoutMsg = IICS26RouterMsgs.MsgTimeoutPacket({ + packet: packet, + proofTimeout: bytes("doesntmatter"), // dummy client will accept + proofHeight: IICS02ClientMsgs.Height({ revisionNumber: 1, revisionHeight: 42 }) // dummy client will accept + }); + vm.expectEmit(); + emit IICS20Transfer.ICS20Timeout(_getPacketData()); + ics26Router.timeoutPacket(timeoutMsg); + // commitment should be deleted + bytes32 path = ICS24Host.packetCommitmentKeyCalldata( + msgSendPacket.sourcePort, msgSendPacket.sourceChannel, packet.sequence + ); + bytes32 storedCommitment = ics26Router.getCommitment(path); + assertEq(storedCommitment, 0); + + // transfer should be reverted + uint256 senderBalanceAfterTimeout = erc20.balanceOf(sender); + uint256 contractBalanceAfterTimeout = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceAfterTimeout, defaultAmount); + assertEq(contractBalanceAfterTimeout, 0); + } + + function _sendICS20Transfer() internal returns (IICS26RouterMsgs.Packet memory) { + erc20.mint(sender, defaultAmount); + vm.startPrank(sender); + erc20.approve(address(ics20Transfer), defaultAmount); + + uint256 senderBalanceBefore = erc20.balanceOf(sender); + uint256 contractBalanceBefore = erc20.balanceOf(address(ics20Transfer)); + assertEq(senderBalanceBefore, defaultAmount); + assertEq(contractBalanceBefore, 0); + + IICS20TransferMsgs.SendTransferMsg memory msgSendTransfer = IICS20TransferMsgs.SendTransferMsg({ + denom: erc20AddressStr, + amount: defaultAmount, + receiver: receiver, + sourceChannel: clientIdentifier, + destPort: "transfer", + timeoutTimestamp: uint32(block.timestamp) + 1000, + memo: "memo" + }); + + vm.startPrank(sender); + vm.expectEmit(); + emit IICS20Transfer.ICS20Transfer(_getPacketData()); + uint32 sequence = ics20Transfer.sendTransfer(msgSendTransfer); + assertEq(sequence, 1); + + bytes32 path = + ICS24Host.packetCommitmentKeyCalldata(msgSendPacket.sourcePort, msgSendPacket.sourceChannel, sequence); + bytes32 storedCommitment = ics26Router.getCommitment(path); + IICS26RouterMsgs.Packet memory packet = _getPacket(msgSendPacket, sequence); + assertEq(storedCommitment, ICS24Host.packetCommitmentBytes32(packet)); + + return packet; + } + + function _getPacket( + IICS26RouterMsgs.MsgSendPacket memory _msgSendPacket, + uint32 sequence + ) + internal + view + returns (IICS26RouterMsgs.Packet memory) + { + return IICS26RouterMsgs.Packet({ + sequence: sequence, + timeoutTimestamp: _msgSendPacket.timeoutTimestamp, + sourcePort: _msgSendPacket.sourcePort, + sourceChannel: _msgSendPacket.sourceChannel, + destPort: _msgSendPacket.destPort, + destChannel: counterpartyClient, // If we test with something else, we need to add this to the args + version: _msgSendPacket.version, + data: _msgSendPacket.data + }); + } + + function _getPacketData() internal view returns (ICS20Lib.UnwrappedFungibleTokenPacketData memory) { + return ICS20Lib.UnwrappedFungibleTokenPacketData({ + sender: sender, + receiver: receiver, + erc20ContractAddress: address(erc20), + amount: defaultAmount, + memo: "memo" + }); + } +} diff --git a/test/TestERC20.sol b/test/TestERC20.sol new file mode 100644 index 000000000..51f4a503 --- /dev/null +++ b/test/TestERC20.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +// solhint-disable no-empty-blocks + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TestERC20 is ERC20 { + constructor() ERC20("Test ERC20", "TERC") { } + + function mint(address _to, uint256 _amount) external { + _mint(_to, _amount); + } +} + +contract MalfunctioningERC20 is TestERC20 { + // _update is doing nothing so that a transfer seems to have gone through, + // but the internal state of the ERC20 contract is not updated - i.e. no transfer really happened + function _update(address from, address to, uint256 value) internal virtual override { + // Do nothing 😱 + } +}