diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c182dec7..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index c87a25b7..a205f2d3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage.json typechain typechain-types dist +.DS_Store # Hardhat files cache diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 00000000..f71512d1 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,86 @@ +import * as fs from "fs"; +import * as handlebars from "handlebars"; +import * as path from "path"; + +const prepareData = (args: any) => { + const argsList = args.arguments || []; + const names = argsList.map((i: string) => i.split(":")[0]); + const types = argsList.map((i: string) => { + let parts = i.split(":"); + // If there's a type and it's not empty, use it; if not, default to "bytes32" + let t = + parts.length > 1 && parts[1].trim() !== "" ? parts[1].trim() : "bytes32"; + return t; + }); + const pairs = names.map((v: string, i: string) => [v, types[i]]); + const contractName = sanitizeSolidityFunctionName(args.name); + + return { + args, + arguments: { names, pairs, types }, + contractName, + contractNameUnderscore: camelToUnderscoreUpper(contractName), + }; +}; + +const processTemplatesRecursive = async ( + template: string, + outputDir: string, + data: Record +): Promise => { + try { + const templateDir = path.resolve( + __dirname, + path.resolve(__dirname, "..", "templates", template) + ); + + const files = fs.readdirSync(templateDir); + + for (const file of files) { + const templatePath = path.join(templateDir, file); + + // Compiling filename as a template + const filenameTemplate = handlebars.compile(file); + const filename = filenameTemplate(data); + + // Replacing .hbs extension if the file was a handlebars template + const outputPath = path.join(outputDir, filename.replace(".hbs", "")); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + if (fs.lstatSync(templatePath).isDirectory()) { + // If file is a directory, recursively process it + await processTemplatesRecursive(templatePath, outputPath, data); + } else if (path.extname(file) === ".hbs") { + const templateContent = fs.readFileSync(templatePath, "utf-8"); + const template = handlebars.compile(templateContent); + const outputContent = template(data); + fs.writeFileSync(outputPath, outputContent); + } else { + fs.copyFileSync(templatePath, outputPath); + } + } + } catch (error) { + console.error(`Error processing templates: ${error}`); + } +}; + +export const processTemplates = async (templateName: string, args: any) => { + processTemplatesRecursive( + templateName, + path.resolve(process.cwd()), + prepareData(args) + ); +}; + +const camelToUnderscoreUpper = (input: string): string => { + return input.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase(); +}; + +const sanitizeSolidityFunctionName = (str: string): string => { + // Remove any character that's not alphanumeric or underscore + const cleaned = str.replace(/[^a-zA-Z0-9_]/g, ""); + + // If the first character is a digit, prepend with an underscore + return cleaned.match(/^\d/) ? `_${cleaned}` : cleaned; +}; diff --git a/package.json b/package.json index 915b0c8b..90561cad 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ "axios": "^1.4.0", "bech32": "^2.0.0", "bip39": "^3.1.0", + "ethers": "^5.4.7", "form-data": "^4.0.0", "hardhat": "^2.15.0" } -} +} \ No newline at end of file diff --git a/tasks/message.ts b/tasks/message.ts new file mode 100644 index 00000000..9f33a296 --- /dev/null +++ b/tasks/message.ts @@ -0,0 +1,34 @@ +import * as fs from "fs"; +import { task } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import * as path from "path"; + +import { processTemplates } from "../lib"; + +const main = async (args: any, hre: HardhatRuntimeEnvironment) => { + processTemplates("messaging", args); + + const configPath = path.resolve(process.cwd(), "hardhat.config.ts"); + let hardhatConfigContents = fs.readFileSync(configPath, "utf8"); + + // Add the omnichain tasks to the hardhat.config.ts file + ["deploy", "interact"].forEach((task) => { + const content = `import "./tasks/${task}";\n`; + if (!hardhatConfigContents.includes(content)) { + hardhatConfigContents = content + hardhatConfigContents; + } + }); + + fs.writeFileSync(configPath, hardhatConfigContents); +}; + +export const messageTask = task( + "message", + "Generate code for a cross-chain messaging contract", + main +) + .addPositionalParam("name", "Name of the contract") + .addOptionalVariadicPositionalParam( + "arguments", + "Arguments for a crosschain call (e.g. dest:address to:bytes32 output:uint256)" + ); diff --git a/templates/messaging/contracts/{{contractName}}.sol.hbs b/templates/messaging/contracts/{{contractName}}.sol.hbs new file mode 100644 index 00000000..14d15c00 --- /dev/null +++ b/templates/messaging/contracts/{{contractName}}.sol.hbs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.7; + +import "@openzeppelin/contracts/interfaces/IERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@zetachain/protocol-contracts/contracts/evm/tools/ZetaInteractor.sol"; +import "@zetachain/protocol-contracts/contracts/evm/interfaces/ZetaInterfaces.sol"; + +interface {{contractName}}Errors { + error InvalidMessageType(); +} + +contract {{contractName}} is + ZetaInteractor, + ZetaReceiver, + {{contractName}}Errors +{ + bytes32 public constant {{contractNameUnderscore}}_MESSAGE_TYPE = + keccak256("CROSS_CHAIN_{{contractNameUnderscore}}"); + + event {{contractName}}Event({{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}}{{/each}}); + event {{contractName}}RevertedEvent({{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}}{{/each}}); + + ZetaTokenConsumer private immutable _zetaConsumer; + IERC20 internal immutable _zetaToken; + + constructor( + address connectorAddress, + address zetaTokenAddress, + address zetaConsumerAddress + ) ZetaInteractor(connectorAddress) { + _zetaToken = IERC20(zetaTokenAddress); + _zetaConsumer = ZetaTokenConsumer(zetaConsumerAddress); + } + + function sendMessage(uint256 destinationChainId{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}} {{this.[0]}}{{/each}}{{/if}}) external payable { + if (!_isValidChainId(destinationChainId)) + revert InvalidDestinationChainId(); + + uint256 crossChainGas = 18 * (10 ** 18); + uint256 zetaValueAndGas = _zetaConsumer.getZetaFromEth{ + value: msg.value + }(address(this), crossChainGas); + _zetaToken.approve(address(connector), zetaValueAndGas); + + connector.send( + ZetaInterfaces.SendInput({ + destinationChainId: destinationChainId, + destinationAddress: interactorsByChainId[destinationChainId], + destinationGasLimit: 300000, + message: abi.encode({{contractNameUnderscore}}_MESSAGE_TYPE{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[0]}}{{/each}}{{/if}}), + zetaValueAndGas: zetaValueAndGas, + zetaParams: abi.encode("") + }) + ); + } + + function onZetaMessage( + ZetaInterfaces.ZetaMessage calldata zetaMessage + ) external override isValidMessageCall(zetaMessage) { + /** + * @dev Decode should follow the signature of the message provided to zeta.send. + */ + (bytes32 messageType{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}} {{this.[0]}}{{/each}}{{/if}}) = abi.decode( + zetaMessage.message, (bytes32{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}}{{/each}}{{/if}}) + ); + + /** + * @dev Setting a message type is a useful pattern to distinguish between different messages. + */ + if (messageType != {{contractNameUnderscore}}_MESSAGE_TYPE) + revert InvalidMessageType(); + + emit {{contractName}}Event({{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[0]}}{{/each}}); + } + + /** + * @dev Called by the Zeta Connector contract when the message fails to be sent. + * Useful to cleanup and leave the application on its initial state. + * Note that the require statements and the functionality are similar to onZetaMessage. + */ + function onZetaRevert( + ZetaInterfaces.ZetaRevert calldata zetaRevert + ) external override isValidRevertCall(zetaRevert) { + (bytes32 messageType{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}} {{this.[0]}}{{/each}}{{/if}}) = abi.decode( + zetaRevert.message, + (bytes32{{#if arguments.pairs}}, {{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[1]}}{{/each}}{{/if}}) + ); + + if (messageType != {{contractNameUnderscore}}_MESSAGE_TYPE) + revert InvalidMessageType(); + + emit {{contractName}}RevertedEvent({{#each arguments.pairs}}{{#if @index}}, {{/if}}{{this.[0]}}{{/each}}); + } +} diff --git a/templates/messaging/tasks/deploy.ts.hbs b/templates/messaging/tasks/deploy.ts.hbs new file mode 100644 index 00000000..b120d932 --- /dev/null +++ b/templates/messaging/tasks/deploy.ts.hbs @@ -0,0 +1,118 @@ +import { getAddress, getChainId } from "@zetachain/addresses"; +import { ethers } from "ethers"; +import { task } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; + +const contractName = "{{contractName}}"; + +const main = async (args: any, hre: HardhatRuntimeEnvironment) => { + const networks = args.networks.split(","); + // A mapping between network names and deployed contract addresses. + const contracts: { [key: string]: string } = {}; + await Promise.all( + networks.map(async (networkName: string) => { + contracts[networkName] = await deployContract(hre, networkName); + }) + ); + + for (const source in contracts) { + await setInteractors(hre, source, contracts); + } +}; + +// Initialize a wallet using a network configuration and a private key from +// environment variables. +const initWallet = (hre: HardhatRuntimeEnvironment, networkName: string) => { + const { url } = hre.config.networks[networkName]; + const provider = new ethers.providers.JsonRpcProvider(url); + const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider); + + return wallet; +}; + +// Deploy the contract on the specified network. deployContract reads the +// contract artifact, creates a contract factory, and deploys the contract using +// that factory. +const deployContract = async ( + hre: HardhatRuntimeEnvironment, + networkName: string +) => { + const wallet = initWallet(hre, networkName); + const zetaNetwork = "athens"; + const connectorAddress = getAddress({ + address: "connector", + networkName, + zetaNetwork, + }); + const zetaTokenAddress = getAddress({ + address: "zetaToken", + networkName, + zetaNetwork, + }); + const zetaTokenConsumerV2 = getAddress({ + address: "zetaTokenConsumerUniV2", + networkName, + zetaNetwork, + }); + const zetaTokenConsumerV3 = getAddress({ + address: "zetaTokenConsumerUniV3", + networkName, + zetaNetwork, + }); + + const { abi, bytecode } = await hre.artifacts.readArtifact(contractName); + const factory = new ethers.ContractFactory(abi, bytecode, wallet); + const contract = await factory.deploy( + connectorAddress, + zetaTokenAddress, + zetaTokenConsumerV2 || zetaTokenConsumerV3 + ); + + await contract.deployed(); + console.log(` +🚀 Successfully deployed contract on ${networkName}. +📜 Contract address: ${contract.address} +`); + return contract.address; +}; + +// Set interactors for a contract. setInteractors attaches to the contract +// deployed at the specified address, and for every other network, sets the +// deployed contract's address as an interactor. +const setInteractors = async ( + hre: HardhatRuntimeEnvironment, + source: string, + contracts: { [key: string]: string } +) => { + console.log(` +🔗 Setting interactors for a contract on ${source}`); + const wallet = initWallet(hre, source); + + const { abi, bytecode } = await hre.artifacts.readArtifact(contractName); + const factory = new ethers.ContractFactory(abi, bytecode, wallet); + const contract = factory.attach(contracts[source]); + + for (const counterparty in contracts) { + // Skip the destination network if it's the same as the source network. + // For example, we don't need to set an interactor for a contract on + // Goerli if the destination network is also Goerli. + if (counterparty === source) continue; + + const counterpartyContract = hre.ethers.utils.solidityPack( + ["address"], + [contracts[counterparty]] + ); + const chainId = getChainId(counterparty as any); + await ( + await contract.setInteractorByChainId(chainId, counterpartyContract) + ).wait(); + console.log( + `✅ Interactor address for ${chainId} (${counterparty}) is set to ${counterpartyContract}` + ); + } +}; + +task("deploy", "Deploy the contract", main).addParam( + "networks", + "Comma separated list of networks to deploy to" +); diff --git a/templates/messaging/tasks/interact.ts.hbs b/templates/messaging/tasks/interact.ts.hbs new file mode 100644 index 00000000..3fadd3ba --- /dev/null +++ b/templates/messaging/tasks/interact.ts.hbs @@ -0,0 +1,31 @@ +import { task } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { parseEther } from "@ethersproject/units"; + +const contractName = "{{contractName}}"; + +const main = async (args: any, hre: HardhatRuntimeEnvironment) => { + const [signer] = await hre.ethers.getSigners(); + console.log(`🔑 Using account: ${signer.address}\n`); + + const factory = await hre.ethers.getContractFactory(contractName); + const contract = factory.attach(args.contract); + + const tx = await contract + .connect(signer) + .sendHelloWorld(args.destination, { value: parseEther(args.amount) }); + + const receipt = await tx.wait(); + console.log(`✅ "sendHelloWorld" transaction has been broadcasted to ${hre.network.name} +📝 Transaction hash: ${receipt.transactionHash} + +Please, refer to ZetaChain's explorer for updates on the progress of the cross-chain transaction. + +🌍 Explorer: https://explorer.zetachain.com/cc/tx/${receipt.transactionHash} +`); +}; + +task("interact", "Sends a message from one chain to another.", main) + .addParam("contract", "Contract address") + .addParam("amount", "Token amount to send") + .addParam("destination", "Destination chain ID (integer)"); diff --git a/yarn.lock b/yarn.lock index 719ab0d1..f21d2ebc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3603,7 +3603,7 @@ ethers@^4.0.40: uuid "2.0.1" xmlhttprequest "1.8.0" -ethers@^5.7.1: +ethers@^5.4.7, ethers@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==