Skip to content

Commit

Permalink
feat(cli): deterministic deployer fallback (#2261)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Feb 27, 2024
1 parent d5c0682 commit 9c83adc
Show file tree
Hide file tree
Showing 17 changed files with 319 additions and 253 deletions.
7 changes: 7 additions & 0 deletions .changeset/tough-dolphins-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@latticexyz/cli": patch
---

Added a non-deterministic fallback for deploying to chains that have replay protection on and do not support pre-EIP-155 transactions (no chain ID).

If you're using `mud deploy` and there's already a [deterministic deployer](https://github.com/Arachnid/deterministic-deployment-proxy) on your target chain, you can provide the address with `--deployerAddress 0x...` to still get some determinism.
1 change: 1 addition & 0 deletions packages/cli/src/commands/dev-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const commandModule: CommandModule<typeof devOptions, InferredOptionTypes<typeof
printConfig: false,
profile: undefined,
saveDeployment: true,
deployerAddress: undefined,
worldAddress,
srcDir,
salt: "0x",
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/deploy/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const worldAbi = [...IBaseWorldAbi, ...IModuleAbi] as const;
export const supportedStoreVersions = ["1.0.0-unaudited"];
export const supportedWorldVersions = ["1.0.0-unaudited"];

// TODO: extend this to include factory+deployer address? so we can reuse the deployer for a world?
export type WorldDeploy = {
readonly address: Address;
readonly worldVersion: string;
Expand All @@ -47,7 +48,7 @@ export type WorldFunction = {
};

export type DeterministicContract = {
readonly address: Address;
readonly getAddress: (deployer: Address) => Address;
readonly bytecode: Hex;
readonly deployedBytecodeSize: number;
readonly abi: Abi;
Expand All @@ -59,9 +60,17 @@ export type System = DeterministicContract & {
readonly systemId: Hex;
readonly allowAll: boolean;
readonly allowedAddresses: readonly Hex[];
readonly allowedSystemIds: readonly Hex[];
readonly functions: readonly WorldFunction[];
};

export type DeployedSystem = Omit<
System,
"getAddress" | "abi" | "bytecode" | "deployedBytecodeSize" | "allowedSystemIds"
> & {
address: Address;
};

export type Module = DeterministicContract & {
readonly name: string;
readonly installAsRoot: boolean;
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/deploy/create2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ cd deterministic-deployment-proxy
git checkout b3bb19c
npm install
npm run build
cd output
jq --arg bc "$(cat bytecode.txt)" '. + {bytecode: $bc}' deployment.json > deployment-with-bytecode.json
mv deployment-with-bytecode.json deployment.json
cp deployment.json ../path/to/this/dir
```
3 changes: 2 additions & 1 deletion packages/cli/src/deploy/create2/deployment.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"gasLimit": 100000,
"signerAddress": "3fab184622dc19b6109349b94811493bf2a45362",
"transaction": "f8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222",
"address": "4e59b44847b379578588920ca78fbf26c0b4956c"
"address": "4e59b44847b379578588920ca78fbf26c0b4956c",
"bytecode": "604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3"
}
31 changes: 21 additions & 10 deletions packages/cli/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ import { debug } from "./debug";
import { resourceToLabel } from "@latticexyz/common";
import { uniqueBy } from "@latticexyz/common/utils";
import { ensureContractsDeployed } from "./ensureContractsDeployed";
import { worldFactoryContracts } from "./ensureWorldFactory";
import { randomBytes } from "crypto";
import { ensureWorldFactory } from "./ensureWorldFactory";

type DeployOptions<configInput extends ConfigInput> = {
client: Client<Transport, Chain | undefined, Account>;
config: Config<configInput>;
salt?: Hex;
worldAddress?: Address;
/**
* Address of determinstic deployment proxy: https://github.com/Arachnid/deterministic-deployment-proxy
* By default, we look for a deployment at 0x4e59b44847b379578588920ca78fbf26c0b4956c and, if not, deploy one.
* If the target chain does not support legacy transactions, we deploy the proxy bytecode anyway, but it will
* not have a deterministic address.
*/
deployerAddress?: Hex;
};

/**
Expand All @@ -35,23 +42,25 @@ export async function deploy<configInput extends ConfigInput>({
config,
salt,
worldAddress: existingWorldAddress,
deployerAddress: initialDeployerAddress,
}: DeployOptions<configInput>): Promise<WorldDeploy> {
const tables = Object.values(config.tables) as Table[];
const systems = Object.values(config.systems);

await ensureDeployer(client);
const deployerAddress = initialDeployerAddress ?? (await ensureDeployer(client));

await ensureWorldFactory(client, deployerAddress);

// deploy all dependent contracts, because system registration, module install, etc. all expect these contracts to be callable.
await ensureContractsDeployed({
client,
deployerAddress,
contracts: [
...worldFactoryContracts,
...uniqueBy(systems, (system) => getAddress(system.address)).map((system) => ({
...uniqueBy(config.systems, (system) => getAddress(system.getAddress(deployerAddress))).map((system) => ({
bytecode: system.bytecode,
deployedBytecodeSize: system.deployedBytecodeSize,
label: `${resourceToLabel(system)} system`,
})),
...uniqueBy(config.modules, (mod) => getAddress(mod.address)).map((mod) => ({
...uniqueBy(config.modules, (mod) => getAddress(mod.getAddress(deployerAddress))).map((mod) => ({
bytecode: mod.bytecode,
deployedBytecodeSize: mod.deployedBytecodeSize,
label: `${mod.name} module`,
Expand All @@ -61,7 +70,7 @@ export async function deploy<configInput extends ConfigInput>({

const worldDeploy = existingWorldAddress
? await getWorldDeploy(client, existingWorldAddress)
: await deployWorld(client, salt ? salt : `0x${randomBytes(32).toString("hex")}`);
: await deployWorld(client, deployerAddress, salt ?? `0x${randomBytes(32).toString("hex")}`);

if (!supportedStoreVersions.includes(worldDeploy.storeVersion)) {
throw new Error(`Unsupported Store version: ${worldDeploy.storeVersion}`);
Expand All @@ -73,7 +82,7 @@ export async function deploy<configInput extends ConfigInput>({
const namespaceTxs = await ensureNamespaceOwner({
client,
worldDeploy,
resourceIds: [...tables.map((table) => table.tableId), ...systems.map((system) => system.systemId)],
resourceIds: [...tables.map((table) => table.tableId), ...config.systems.map((system) => system.systemId)],
});

debug("waiting for all namespace registration transactions to confirm");
Expand All @@ -88,16 +97,18 @@ export async function deploy<configInput extends ConfigInput>({
});
const systemTxs = await ensureSystems({
client,
deployerAddress,
worldDeploy,
systems,
systems: config.systems,
});
const functionTxs = await ensureFunctions({
client,
worldDeploy,
functions: systems.flatMap((system) => system.functions),
functions: config.systems.flatMap((system) => system.functions),
});
const moduleTxs = await ensureModules({
client,
deployerAddress,
worldDeploy,
modules: config.modules,
});
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/deploy/deployWorld.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Account, Chain, Client, Hex, Log, Transport } from "viem";
import { waitForTransactionReceipt } from "viem/actions";
import { ensureWorldFactory, worldFactory } from "./ensureWorldFactory";
import { ensureWorldFactory } from "./ensureWorldFactory";
import WorldFactoryAbi from "@latticexyz/world/out/WorldFactory.sol/WorldFactory.abi.json" assert { type: "json" };
import { writeContract } from "@latticexyz/common";
import { debug } from "./debug";
Expand All @@ -9,9 +9,10 @@ import { WorldDeploy } from "./common";

export async function deployWorld(
client: Client<Transport, Chain | undefined, Account>,
deployerAddress: Hex,
salt: Hex
): Promise<WorldDeploy> {
await ensureWorldFactory(client);
const worldFactory = await ensureWorldFactory(client, deployerAddress);

debug("deploying world");
const tx = await writeContract(client, {
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/deploy/ensureContract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Client, Transport, Chain, Account, concatHex, getCreate2Address, Hex, size } from "viem";
import { Client, Transport, Chain, Account, concatHex, getCreate2Address, Hex } from "viem";
import { getBytecode } from "viem/actions";
import { deployer } from "./ensureDeployer";
import { contractSizeLimit, salt } from "./common";
import { sendTransaction } from "@latticexyz/common";
import { debug } from "./debug";
Expand All @@ -15,13 +14,15 @@ export type Contract = {

export async function ensureContract({
client,
deployerAddress,
bytecode,
deployedBytecodeSize,
label = "contract",
}: {
readonly client: Client<Transport, Chain | undefined, Account>;
readonly deployerAddress: Hex;
} & Contract): Promise<readonly Hex[]> {
const address = getCreate2Address({ from: deployer, salt, bytecode });
const address = getCreate2Address({ from: deployerAddress, salt, bytecode });

const contractCode = await getBytecode(client, { address, blockTag: "pending" });
if (contractCode) {
Expand All @@ -45,7 +46,7 @@ export async function ensureContract({
() =>
sendTransaction(client, {
chain: client.chain ?? null,
to: deployer,
to: deployerAddress,
data: concatHex([salt, bytecode]),
}),
{
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/deploy/ensureContractsDeployed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { Contract, ensureContract } from "./ensureContract";

export async function ensureContractsDeployed({
client,
deployerAddress,
contracts,
}: {
readonly client: Client<Transport, Chain | undefined, Account>;
readonly deployerAddress: Hex;
readonly contracts: readonly Contract[];
}): Promise<readonly Hex[]> {
const txs = (await Promise.all(contracts.map((contract) => ensureContract({ client, ...contract })))).flat();
const txs = (
await Promise.all(contracts.map((contract) => ensureContract({ client, deployerAddress, ...contract })))
).flat();

if (txs.length) {
debug("waiting for contracts");
Expand Down
83 changes: 61 additions & 22 deletions packages/cli/src/deploy/ensureDeployer.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,75 @@
import { Account, Chain, Client, Transport } from "viem";
import { getBytecode, sendRawTransaction, sendTransaction, waitForTransactionReceipt } from "viem/actions";
import { Account, Address, Chain, Client, Transport } from "viem";
import { getBalance, getBytecode, sendRawTransaction, sendTransaction, waitForTransactionReceipt } from "viem/actions";
import deployment from "./create2/deployment.json";
import { debug } from "./debug";

export const deployer = `0x${deployment.address}` as const;
const deployer = `0x${deployment.address}` as const;
const deployerBytecode = `0x${deployment.bytecode}` as const;

export async function ensureDeployer(client: Client<Transport, Chain | undefined, Account>): Promise<void> {
export async function ensureDeployer(client: Client<Transport, Chain | undefined, Account>): Promise<Address> {
const bytecode = await getBytecode(client, { address: deployer });
if (bytecode) {
debug("found create2 deployer at", deployer);
return;
debug("found CREATE2 deployer at", deployer);
if (bytecode !== deployerBytecode) {
console.warn(
`\n ⚠️ Bytecode for deployer at ${deployer} did not match the expected CREATE2 bytecode. You may have unexpected results.\n`
);
}
return deployer;
}

// send gas to signer
debug("sending gas for create2 deployer to signer at", deployment.signerAddress);
const gasTx = await sendTransaction(client, {
chain: client.chain ?? null,
to: `0x${deployment.signerAddress}`,
value: BigInt(deployment.gasLimit) * BigInt(deployment.gasPrice),
});
const gasReceipt = await waitForTransactionReceipt(client, { hash: gasTx });
if (gasReceipt.status !== "success") {
console.error("failed to send gas to deployer signer", gasReceipt);
throw new Error("failed to send gas to deployer signer");
// There's not really a way to simulate a pre-EIP-155 (no chain ID) transaction,
// so we have to attempt to create the deployer first and, if it fails, fall back
// to a regular deploy.

// Send gas to deployment signer
const gasRequired = BigInt(deployment.gasLimit) * BigInt(deployment.gasPrice);
const currentBalance = await getBalance(client, { address: `0x${deployment.signerAddress}` });
const gasNeeded = gasRequired - currentBalance;
if (gasNeeded > 0) {
debug("sending gas for CREATE2 deployer to signer at", deployment.signerAddress);
const gasTx = await sendTransaction(client, {
chain: client.chain ?? null,
to: `0x${deployment.signerAddress}`,
value: gasNeeded,
});
const gasReceipt = await waitForTransactionReceipt(client, { hash: gasTx });
if (gasReceipt.status !== "success") {
console.error("failed to send gas to deployer signer", gasReceipt);
throw new Error("failed to send gas to deployer signer");
}
}

// deploy the deployer
debug("deploying create2 deployer at", deployer);
const deployTx = await sendRawTransaction(client, { serializedTransaction: `0x${deployment.transaction}` });
// Deploy the deployer
debug("deploying CREATE2 deployer at", deployer);
const deployTx = await sendRawTransaction(client, { serializedTransaction: `0x${deployment.transaction}` }).catch(
(error) => {
// Do a regular contract create if the presigned transaction doesn't work due to replay protection
if (String(error).includes("only replay-protected (EIP-155) transactions allowed over RPC")) {
console.warn(
// eslint-disable-next-line max-len
`\n ⚠️ Your chain or RPC does not allow for non EIP-155 signed transactions, so your deploys will not be determinstic and contract addresses may change between deploys.\n\n We recommend running your chain's node with \`--rpc.allow-unprotected-txs\` to enable determinstic deployments.\n`
);
debug("deploying CREATE2 deployer");
return sendTransaction(client, {
chain: client.chain ?? null,
data: deployerBytecode,
});
}
throw error;
}
);

const deployReceipt = await waitForTransactionReceipt(client, { hash: deployTx });
if (!deployReceipt.contractAddress) {
throw new Error("Deploy receipt did not have contract address, was the deployer not deployed?");
}

if (deployReceipt.contractAddress !== deployer) {
console.error("unexpected contract address for deployer", deployReceipt);
throw new Error("unexpected contract address for deployer");
console.warn(
`\n ⚠️ CREATE2 deployer created at ${deployReceipt.contractAddress} does not match the CREATE2 determinstic deployer we expected (${deployer})`
);
}

return deployReceipt.contractAddress;
}
9 changes: 6 additions & 3 deletions packages/cli/src/deploy/ensureModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ import { ensureContractsDeployed } from "./ensureContractsDeployed";

export async function ensureModules({
client,
deployerAddress,
worldDeploy,
modules,
}: {
readonly client: Client<Transport, Chain | undefined, Account>;
readonly deployerAddress: Hex; // TODO: move this into WorldDeploy to reuse a world's deployer?
readonly worldDeploy: WorldDeploy;
readonly modules: readonly Module[];
}): Promise<readonly Hex[]> {
if (!modules.length) return [];

await ensureContractsDeployed({
client,
contracts: uniqueBy(modules, (mod) => getAddress(mod.address)).map((mod) => ({
deployerAddress,
contracts: uniqueBy(modules, (mod) => getAddress(mod.getAddress(deployerAddress))).map((mod) => ({
bytecode: mod.bytecode,
deployedBytecodeSize: mod.deployedBytecodeSize,
label: `${mod.name} module`,
Expand All @@ -40,15 +43,15 @@ export async function ensureModules({
abi: worldAbi,
// TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645)
functionName: "installRootModule",
args: [mod.address, mod.installData],
args: [mod.getAddress(deployerAddress), mod.installData],
})
: await writeContract(client, {
chain: client.chain ?? null,
address: worldDeploy.address,
abi: worldAbi,
// TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645)
functionName: "installModule",
args: [mod.address, mod.installData],
args: [mod.getAddress(deployerAddress), mod.installData],
});
} catch (error) {
if (error instanceof BaseError && error.message.includes("Module_AlreadyInstalled")) {
Expand Down
Loading

0 comments on commit 9c83adc

Please sign in to comment.