Skip to content

Commit

Permalink
feat: code generation for cross-chain messaging (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
fadeev authored Jul 3, 2023
1 parent bc3c19a commit be05390
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 2 deletions.
Binary file removed .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ coverage.json
typechain
typechain-types
dist
.DS_Store

# Hardhat files
cache
Expand Down
86 changes: 86 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
): Promise<void> => {
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;
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
34 changes: 34 additions & 0 deletions tasks/message.ts
Original file line number Diff line number Diff line change
@@ -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)"
);
95 changes: 95 additions & 0 deletions templates/messaging/contracts/{{contractName}}.sol.hbs
Original file line number Diff line number Diff line change
@@ -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}});
}
}
118 changes: 118 additions & 0 deletions templates/messaging/tasks/deploy.ts.hbs
Original file line number Diff line number Diff line change
@@ -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"
);
31 changes: 31 additions & 0 deletions templates/messaging/tasks/interact.ts.hbs
Original file line number Diff line number Diff line change
@@ -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)");
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down

0 comments on commit be05390

Please sign in to comment.