Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: code generation for cross-chain messaging #8

Merged
merged 9 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this, I recommend using plop as we did in the past (much more maintainable and extensible): https://github.com/zeta-chain/zeta-examples/blob/498e3d51b48263184614c94704251c5839fde8a7/plopfile.js#L1.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this code could be refactored to use a library. I've looked into plop and it's a good candidate, however, right now there are issues with using it with TS plopjs/plop#297

Created an issue to track the refactor: #15

This refactor is a nice to have and shouldn't block the PR, I think.

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;
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@
"axios": "^1.4.0",
"bech32": "^2.0.0",
"bip39": "^3.1.0",
"ethers": "^6.6.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",
andresaiello marked this conversation as resolved.
Show resolved Hide resolved
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)");
Loading