diff --git a/packages/evm/contracts/Bouncer.sol b/packages/evm/contracts/Bouncer.sol new file mode 100644 index 00000000..71120bd8 --- /dev/null +++ b/packages/evm/contracts/Bouncer.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.20; + +import { IAdapter } from "./interfaces/IAdapter.sol"; +import { IReporter } from "./interfaces/IReporter.sol"; +import { IYaho } from "./interfaces/IYaho.sol"; +import { IBouncer } from "./interfaces/IBouncer.sol"; +import { HopDecoder } from "./libraries/HopDecoder.sol"; + +contract Bouncer is IBouncer { + address public immutable YAHO; + address public immutable YARU; + + constructor(address yaho, address yaru) { + YAHO = yaho; + YARU = yaru; + } + + function onMessage( + uint256, + uint256 sourceChainId, + address sender, + uint256 threshold, + IAdapter[] calldata adapters, + bytes calldata data + ) external returns (bytes memory) { + if (msg.sender != YARU) revert NotYaru(); + + ( + , + uint8 hopsNonce, + , + , + uint256 nextChainId, + address receiver, + uint256 expectedSourceChainId, + address expectedSender, + uint256 expectedThreshold, + bytes32 expectedAdaptersHash, + uint256 nextThreshold, + IReporter[] memory nextReporters, + IAdapter[] memory nextAdapters, + bytes memory message + ) = HopDecoder.decodeCurrentHop(data); + + if (sourceChainId != expectedSourceChainId) revert InvalidSourceChainId(); + if (sender != expectedSender) revert InvalidSender(); + if (threshold < expectedThreshold) revert InvalidThreshold(); + if (keccak256(abi.encodePacked(adapters)) != expectedAdaptersHash) revert InvalidAdapters(); + + bytes memory dataWithUpdatedNonce = abi.encodePacked( + data[:7 + message.length], + bytes1(hopsNonce + 1), + data[7 + 1 + message.length:] + ); + IYaho(YAHO).dispatchMessage( + nextChainId, + nextThreshold, + receiver, + dataWithUpdatedNonce, + nextReporters, + nextAdapters + ); + + return abi.encodePacked(true); + } +} diff --git a/packages/evm/contracts/interfaces/IBouncer.sol b/packages/evm/contracts/interfaces/IBouncer.sol new file mode 100644 index 00000000..75607c7b --- /dev/null +++ b/packages/evm/contracts/interfaces/IBouncer.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +import { IJushin } from "./IJushin.sol"; + +/** + * @title IBouncer + */ +interface IBouncer is IJushin { + error InvalidAdapters(); + error InvalidSender(); + error InvalidSourceChainId(); + error InvalidThreshold(); + error NotYaru(); +} diff --git a/packages/evm/contracts/libraries/HopDecoder.sol b/packages/evm/contracts/libraries/HopDecoder.sol new file mode 100644 index 00000000..8be659e6 --- /dev/null +++ b/packages/evm/contracts/libraries/HopDecoder.sol @@ -0,0 +1,97 @@ +pragma solidity ^0.8.20; + +import { IAdapter } from "../interfaces/IAdapter.sol"; +import { IReporter } from "../interfaces/IReporter.sol"; + +library HopDecoder { + function decodeCurrentHop( + bytes calldata data + ) + internal + pure + returns ( + bytes4 magic, + uint8 hopsNonce, + uint8 hopsCount, + bytes8 chainProtocol, + uint256 chainId, + address receiver, + uint256 expectedSourceChainId, + address expectedSender, + uint32 expectedThreshold, + bytes32 expectedAdaptersHash, + uint32 nextThreshold, + IReporter[] memory nextReporters, + IAdapter[] memory nextAdapters, + bytes memory message + ) + { + magic = bytes4(data[:4]); + + uint24 messageLength = uint24(bytes3(data[4:7])); + message = data[7:7 + messageLength]; + + uint256 a = 7 + messageLength; + uint256 b = a + 1; + uint256 c = b + 1; + + hopsNonce = uint8(bytes1(data[a:b])); + hopsCount = uint8(bytes1(data[b:c])); + + uint32 hopBytesToSkip = 0; + for (uint256 k = 0; k < hopsNonce; ) { + hopBytesToSkip += uint32(bytes4(data[c + k * 4:c + (k * 4) + 4])); + unchecked { + ++k; + } + } + + uint256 hopStart = c + hopBytesToSkip + ((hopsNonce + 1) * 4); + uint256 d = hopStart + 8; + uint256 e = hopStart + 24; + uint256 f = hopStart + 56; + uint256 g = hopStart + 72; + uint256 h = hopStart + 104; + uint256 i = hopStart + 108; + uint256 l = hopStart + 140; + uint256 m = hopStart + 144; + uint256 n = hopStart + 148; + + chainProtocol = bytes8(data[hopStart:d]); + chainId = uint128(bytes16(data[d:e])); + receiver = address(uint160(uint256(bytes32(data[e:f])))); + expectedSourceChainId = uint128(bytes16(data[f:g])); + expectedSender = address(uint160(uint256(bytes32(data[g:h])))); + expectedThreshold = uint32(bytes4(data[h:i])); + expectedAdaptersHash = bytes32(data[i:l]); + nextThreshold = uint32(bytes4(data[l:m])); + + uint32 nextReportersLength = uint32(bytes4(data[m:n])); + nextReporters = new IReporter[](nextReportersLength); + for (uint256 k = 0; k < nextReportersLength; ) { + nextReporters[k] = IReporter(address(uint160(uint256(bytes32(data[n + (k * 32):n + (k * 32) + 32]))))); + unchecked { + ++k; + } + } + + uint256 o = n + (nextReportersLength * 32); + uint256 p = o + 4; + uint32 nextAdaptersLength = uint32(bytes4(data[o:p])); + nextAdapters = new IAdapter[](nextAdaptersLength); + for (uint256 k = 0; k < nextAdaptersLength; ) { + nextAdapters[k] = IAdapter(address(uint160(uint256(bytes32(data[p + (k * 32):p + (k * 32) + 32]))))); + unchecked { + ++k; + } + } + } + + function decodeHeaderAndMessage( + bytes calldata data + ) internal pure returns (bytes memory header, bytes memory message) { + uint24 messageLength = uint24(bytes3(data[4:7])); + message = data[7:7 + messageLength]; + header = data[7 + messageLength:]; + } +} diff --git a/packages/evm/contracts/test/HopReceiver.sol b/packages/evm/contracts/test/HopReceiver.sol new file mode 100644 index 00000000..e61546b9 --- /dev/null +++ b/packages/evm/contracts/test/HopReceiver.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.20; + +import { IJushin } from "../interfaces/IJushin.sol"; +import { IAdapter } from "../interfaces/IAdapter.sol"; +import { HopDecoder } from "../libraries/HopDecoder.sol"; + +contract HopReceiver is IJushin { + address public yaru; + uint256 public expectedSourceChainId; + address public expectedSender; + uint256 public expectedThreshold; + bytes32 public expectedAdaptersHash; + bytes32 public expectedHeaderHash; + + event MessageReceived(bytes message); + + function setConfigs( + address yaru_, + uint256 expectedSourceChainId_, + address expectedSender_, + uint256 expectedThreshold_, + bytes32 expectedAdaptersHash_, + bytes32 expectedHeaderHash_ + ) external { + yaru = yaru_; + expectedSourceChainId = expectedSourceChainId_; + expectedSender = expectedSender_; + expectedThreshold = expectedThreshold_; + expectedAdaptersHash = expectedAdaptersHash_; + expectedHeaderHash = expectedHeaderHash_; + } + + function onMessage( + uint256, + uint256 sourceChainId, + address sender, + uint256 threshold, + IAdapter[] calldata adapters, + bytes calldata data + ) external returns (bytes memory) { + require(msg.sender == yaru, "!yaru"); + require(sourceChainId == expectedSourceChainId, "!expectedSourceChainId"); + require(sender == expectedSender, "!expectedSender"); + require(threshold == expectedThreshold, "!expectedThreshold"); + require(keccak256(abi.encodePacked(adapters)) == expectedAdaptersHash, "!expectedAdaptersHash"); + (bytes memory header, bytes memory message) = HopDecoder.decodeHeaderAndMessage(data); + require(keccak256(header) == expectedHeaderHash, "!expectedHeaderHash"); + emit MessageReceived(message); + return abi.encodePacked(true); + } +} diff --git a/packages/evm/test/06_Bouncer.spec.ts b/packages/evm/test/06_Bouncer.spec.ts new file mode 100644 index 00000000..5d70b382 --- /dev/null +++ b/packages/evm/test/06_Bouncer.spec.ts @@ -0,0 +1,211 @@ +import { expect } from "chai" +import { Contract } from "ethers" +import { ethers } from "hardhat" + +import Message from "./utils/Message" +import { Chains } from "./utils/constants" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" + +let reporter1: Contract, + reporter2: Contract, + reporter3: Contract, + reporter4: Contract, + headerStorage: Contract, + yaho: Contract, + yaru: Contract, + hashi: Contract, + adapter1: Contract, + adapter2: Contract, + adapter3: Contract, + adapter4: Contract, + bouncer1: Contract, + bouncer2: Contract, + hopReceiver: Contract, + owner: SignerWithAddress + +describe("Bouncer", () => { + beforeEach(async () => { + const signers = await ethers.getSigners() + owner = signers[0] + + const Yaru = await ethers.getContractFactory("Yaru") + const Yaho = await ethers.getContractFactory("Yaho") + const Hashi = await ethers.getContractFactory("Hashi") + const Reporter = await ethers.getContractFactory("MockReporter") + const Adapter = await ethers.getContractFactory("MockAdapter") + const HopReceiver = await ethers.getContractFactory("HopReceiver") + const HeaderStorage = await ethers.getContractFactory("HeaderStorage") + const Bouncer = await ethers.getContractFactory("Bouncer") + + hashi = await Hashi.deploy() + yaho = await Yaho.deploy() + headerStorage = await HeaderStorage.deploy() + yaru = await Yaru.deploy(hashi.address, yaho.address, Chains.Hardhat) + reporter1 = await Reporter.deploy(headerStorage.address, yaho.address) + reporter2 = await Reporter.deploy(headerStorage.address, yaho.address) + reporter3 = await Reporter.deploy(headerStorage.address, yaho.address) + reporter4 = await Reporter.deploy(headerStorage.address, yaho.address) + adapter1 = await Adapter.deploy() + adapter2 = await Adapter.deploy() + adapter3 = await Adapter.deploy() + adapter4 = await Adapter.deploy() + hopReceiver = await HopReceiver.deploy() + + bouncer1 = await Bouncer.deploy(yaho.address, yaru.address) + bouncer2 = await Bouncer.deploy(yaho.address, yaru.address) + }) + + it(`should be able to execute a message after ONE hop`, async () => { + const threshold = 1 + const expectedAdaptersHash1 = ethers.utils.solidityKeccak256(["address[]"], [[adapter1.address]]) + const expectedAdaptersHash2 = ethers.utils.solidityKeccak256(["address[]"], [[adapter2.address]]) + + const header = + "00" + // hops nonce + "01" + // hops count + "000000D8" + // 184 bytes = 1th hop size + "0000000000000001" + // chain protocol + Chains.Hardhat.toString(16).padStart(32, "0") + // chain protocol identifier + hopReceiver.address.slice(2).padStart(64, "0") + // receiver + Chains.Hardhat.toString(16).padStart(32, "0") + // expected source chain id + owner.address.slice(2).padStart(64, "0") + // expected sender + "00000001" + // expected threshold + expectedAdaptersHash1.slice(2) + // expected adapters hash + "00000001" + // threshold + "00000001" + // reporters length + reporter2.address.slice(2).padStart(64, "0") + + "00000001" + // adapters length + adapter2.address.slice(2).padStart(64, "0") + + const messageWithHops = + "0x04510001" + + "000001" + // message length + "01" + // raw message + header + + await hopReceiver.setConfigs( + yaru.address, + Chains.Hardhat, + bouncer1.address, + threshold, + expectedAdaptersHash2, + ethers.utils.keccak256("0x" + "01" + header.slice(2)), // NOTE: nonce must equal to count in the last step + ) + + let tx = await yaho.dispatchMessagesToAdapters( + Chains.Hardhat, + [threshold], + [bouncer1.address], + [messageWithHops], + [reporter1.address], + [adapter1.address], + ) + const [message1] = Message.fromReceipt(await tx.wait(1)) + const hash1 = await yaho.calculateMessageHash(message1.serialize()) + let adapters = [adapter1] + for (let i = 0; i < threshold; i++) { + await adapters[i].setHashes(Chains.Hardhat, [message1.id], [hash1]) + } + + tx = await yaru.executeMessages([message1]) + const [message2] = Message.fromReceipt(await tx.wait(1)) + const hash2 = await yaho.calculateMessageHash(message2.serialize()) + adapters = [adapter2] + for (let i = 0; i < threshold; i++) { + await adapters[i].setHashes(Chains.Hardhat, [message2.id], [hash2]) + } + + await expect(yaru.executeMessages([message2])) + .to.emit(hopReceiver, "MessageReceived") + .withArgs("0x01") + }) + + it(`should be able to execute a message after TWO hops`, async () => { + const threshold = 1 + const expectedAdaptersHash1 = ethers.utils.solidityKeccak256(["address[]"], [[adapter1.address]]) + const expectedAdaptersHash2 = ethers.utils.solidityKeccak256(["address[]"], [[adapter2.address]]) + const expectedAdaptersHash34 = ethers.utils.solidityKeccak256(["address[]"], [[adapter3.address, adapter4.address]]) + + const header = + "00" + // hops nonce + "02" + // hops count + "000000D8" + // 184 bytes = 1th hop size + "0000000000000001" + // chain protocol + Chains.Hardhat.toString(16).padStart(32, "0") + // chain protocol identifier + bouncer2.address.slice(2).padStart(64, "0") + // receiver + Chains.Hardhat.toString(16).padStart(32, "0") + // expected source chain id + owner.address.slice(2).padStart(64, "0") + // expected sender + "00000001" + // expected threshold + expectedAdaptersHash1.slice(2) + // expected adapters hash + "00000001" + // threshold + "00000001" + // reporters length + reporter2.address.slice(2).padStart(64, "0") + + "00000001" + // adapters length + adapter2.address.slice(2).padStart(64, "0") + + "000000D8" + // 184 bytes = 2th hop size + "0000000000000001" + // chain protocol + Chains.Hardhat.toString(16).padStart(32, "0") + // chain protocol identifier + hopReceiver.address.slice(2).padStart(64, "0") + // receiver + Chains.Hardhat.toString(16).padStart(32, "0") + // expected source chain id + bouncer1.address.slice(2).padStart(64, "0") + // expected sender + "00000001" + // expected threshold + expectedAdaptersHash2.slice(2) + // expected adapters hash + "00000001" + // threshold + "00000002" + // reporters length + reporter3.address.slice(2).padStart(64, "0") + + reporter4.address.slice(2).padStart(64, "0") + + "00000002" + // adapters length + adapter3.address.slice(2).padStart(64, "0") + + adapter4.address.slice(2).padStart(64, "0") + + const messageWithHops = + "0x04510001" + + "000001" + // message length + "01" + // raw message + header + + await hopReceiver.setConfigs( + yaru.address, + Chains.Hardhat, + bouncer2.address, + threshold, + expectedAdaptersHash34, + ethers.utils.keccak256("0x" + "02" + header.slice(2)), // NOTE: nonce must equal to count in the last step + ) + + let tx = await yaho.dispatchMessagesToAdapters( + Chains.Hardhat, + [threshold], + [bouncer1.address], + [messageWithHops], + [reporter1.address], + [adapter1.address], + ) + const [message1] = Message.fromReceipt(await tx.wait(1)) + const hash1 = await yaho.calculateMessageHash(message1.serialize()) + let adapters = [adapter1, adapter2] + for (let i = 0; i < threshold; i++) { + await adapters[i].setHashes(Chains.Hardhat, [message1.id], [hash1]) + } + + tx = await yaru.executeMessages([message1]) + const [message2] = Message.fromReceipt(await tx.wait(1)) + const hash2 = await yaho.calculateMessageHash(message2.serialize()) + adapters = [adapter2] + for (let i = 0; i < threshold; i++) { + await adapters[i].setHashes(Chains.Hardhat, [message2.id], [hash2]) + } + + tx = await yaru.executeMessages([message2]) + const [message3] = Message.fromReceipt(await tx.wait(1)) + const hash3 = await yaho.calculateMessageHash(message3.serialize()) + adapters = [adapter3, adapter4] + for (let i = 0; i < threshold; i++) { + await adapters[i].setHashes(Chains.Hardhat, [message3.id], [hash3]) + } + + await expect(yaru.executeMessages([message3])) + .to.emit(hopReceiver, "MessageReceived") + .withArgs("0x01") + }) +}) diff --git a/packages/evm/test/utils/Message.ts b/packages/evm/test/utils/Message.ts index 8a16c75e..9fc785dd 100644 --- a/packages/evm/test/utils/Message.ts +++ b/packages/evm/test/utils/Message.ts @@ -1,4 +1,4 @@ -import { ContractReceipt } from "ethers" +import { ContractReceipt, utils } from "ethers" type Configs = { data: string @@ -36,8 +36,61 @@ class Message { } static fromReceipt(_receipt: ContractReceipt) { - const events = _receipt.events.filter(({ event }) => event === "MessageDispatched") - return events.map(({ args: { messageId, message } }) => new Message({ id: messageId, ...message })) + let events = _receipt.events.filter(({ event }) => event === "MessageDispatched") + if (events.length) { + return events.map(({ args: { messageId, message } }) => new Message({ id: messageId, ...message })) + } + + const abiCoder = new utils.AbiCoder() + events = _receipt.events.filter( + ({ topics }) => topics[0] === "0x218247aabc759e65b5bb92ccc074f9d62cd187259f2a0984c3c9cf91f67ff7cf", + ) + return events.map(({ topics, data }) => { + const message = abiCoder.decode( + [ + { + components: [ + { + name: "nonce", + type: "uint256", + }, + { + name: "targetChainId", + type: "uint256", + }, + { + name: "threshold", + type: "uint256", + }, + { + name: "sender", + type: "address", + }, + { + name: "receiver", + type: "address", + }, + { + name: "data", + type: "bytes", + }, + { + name: "reporters", + type: "address[]", + }, + { + name: "adapters", + type: "address[]", + }, + ], + name: "message", + type: "tuple", + }, + ], + data, + ) + return new Message({ id: topics[1], ...message[0] }) + }) } serialize() {