Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/ten-jobs-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cartesi/devnet": patch
---

remove cannon
408 changes: 7 additions & 401 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/devnet/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
anvil_state.json
broadcast
cache
dependencies
deployments
Expand Down
88 changes: 88 additions & 0 deletions packages/devnet/anvil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import pRetry from "p-retry";

/**
* Get the installed anvil version
* @returns the installed anvil version
*/
export const version = async () => {
const proc = Bun.spawn(["anvil", "--version"]);
const output = await proc.stdout.text();

// anvil Version: 1.4.3-v1.4.3
// Commit SHA: fa9f934bdac4bcf57e694e852a61997dda90668a
// Build Timestamp: 2025-10-22T04:37:38.758664000Z (1761107858)
// Build Profile: maxperf

// parse the output to get the version
const versionMatch = output.match(
/Version: (\d+\.\d+\.\d+)-v(\d+\.\d+\.\d+)/,
);
if (!versionMatch) {
throw new Error("Failed to parse anvil version. Is anvil installed?");
}
return versionMatch[1];
};

type StartOptions = {
chainId?: number;
loadState: string;
dumpState: string;
};

/**
* Start an anvil instance
* @param options - The options for starting anvil
* @returns The child process of the anvil instance
*/
export const start = async (options: StartOptions) => {
const chainId = options.chainId ?? 31337;
// spawn anvil child process
const controller = new AbortController();
const proc = Bun.spawn(
[
"anvil",
"--load-state",
options.loadState,
"--preserve-historical-states",
"--quiet",
"--dump-state",
options.dumpState,
],
{ signal: controller.signal, stdout: "inherit", stderr: "inherit" },
);
process.on("SIGINT", () => controller.abort());
process.on("SIGTERM", () => controller.abort());

// wait for anvil to be responding
await pRetry(
async () => {
const cid = Bun.spawnSync(["cast", "chain-id"])
.stdout.toString()
.trim();
if (cid !== chainId.toString()) {
throw new Error("Anvil is not responding");
}
return cid;
},
{ retries: 10, minTimeout: 100 },
);

return proc;
};

/**
* Stop an anvil instance
* @param proc - The child process of the anvil instance
* @returns The exit code of the anvil instance
*/
export const stop = async (proc: Bun.Subprocess) => {
// send a graceful shutdown signal
proc.kill("SIGTERM");

// check exit code
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`Anvil exited with code ${exitCode}`);
}
return exitCode;
};
197 changes: 197 additions & 0 deletions packages/devnet/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { semver } from "bun";
import { existsSync, readdirSync } from "fs-extra";
import { Listr, type ListrTask } from "listr2";
import * as anvil from "./anvil";
import { downloadAndExtract } from "./download";
import path = require("node:path");

const ANVIL_VERSION = "1.4.3";
const ROLLUPS_VERSION = "2.1.1";
const PRT_VERSION = "2.0.1";

/**
* Tasks to download and extract dependencies
*/
const dependencies: ListrTask[] = [
{
url: `https://github.com/cartesi/dave/releases/download/v${PRT_VERSION}/cartesi-rollups-prt-anvil-v${ANVIL_VERSION}.tar.gz`,
destination: "build",
},
{
url: `https://github.com/cartesi/dave/releases/download/v${PRT_VERSION}/cartesi-rollups-prt-contract-artifacts.tar.gz`,
destination: "out",
stripComponents: 3,
},
{
url: `https://github.com/cartesi/rollups-contracts/releases/download/v${ROLLUPS_VERSION}/rollups-contracts-${ROLLUPS_VERSION}-artifacts.tar.gz`,
destination: "out",
},
].map((file) => ({
title: `${file.url} -> ${file.destination}`,
task: async () => await downloadAndExtract(file),
}));

/**
* Deploy contracts using forge script
* @param options - The options for deploying contracts
* @param options.privateKey - The private key to use for the deployment
* @param options.rpcUrl - The RPC URL to use for the deployment
* @returns
*/
const deploy = async (options: { privateKey: string; rpcUrl: string }) => {
// execute forge script
const proc = Bun.spawn(
[
"forge",
"script",
"Deploy",
"--broadcast",
"--non-interactive",
"--private-key",
options.privateKey,
"--rpc-url",
options.rpcUrl,
"--slow",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`Forge script exited with code ${exitCode}`, {
cause: await proc.stderr.text(),
});
}
return exitCode;
};

/**
* Generate artifacts from deployed contracts (with { abi, address, contractName })
* @returns
*/
const artifacts = (dir: string) => {
return readdirSync(dir).map((file) => ({
title: file,
task: async () => {
const deployment = await Bun.file(path.join(dir, file)).json();
const { address, contractName } = deployment;

const filename = path.join(
"out",
`${contractName}.sol`,
`${contractName}.json`,
);

// read abi from forge artifact (if it exists, error if it doesn't)
const { abi } = existsSync(filename)
? await Bun.file(filename).json()
: undefined;
if (!abi) {
throw new Error(`ABI file not found for ${contractName}`);
}

// write deployments (with { abi, address, contractName })
const artifact = path.join("deployments", `${contractName}.json`);
return Bun.write(
artifact,
JSON.stringify({ abi, address, contractName }, null, 2),
);
},
}));
};

const build = async () => {
type Ctx = {
anvilProc?: Bun.Subprocess;
dumpState: string;
privateKey: string;
rpcUrl: string;
};

const tasks = new Listr<Ctx>(
[
{
title: "Checking anvil version",
task: async (_, task) => {
// check is required anvil version is installed
const anvilVersion = await anvil.version();
if (!semver.satisfies(anvilVersion, ANVIL_VERSION)) {
throw new Error(
`Anvil version ${anvilVersion} is not the expected version ${ANVIL_VERSION}`,
);
}
task.title = `Anvil version ${anvilVersion} is installed`;
},
},
{
title: "Download dependencies",
task: async (_, task) =>
task.newListr(dependencies, { concurrent: true }),
},
{
title: "Starting anvil...",
task: async (ctx, task) => {
const { dumpState } = ctx;

// start anvil
ctx.anvilProc = await anvil.start({
loadState: `build/state.json`,
dumpState,
});

// setup graceful anvil shutdown, just in case process is terminated prematurely
const shutdown = async () => {
await anvil.stop(ctx.anvilProc);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

task.title = `Anvil started (PID: ${ctx.anvilProc.pid})`;
},
},
{
title: "Deploying contracts...",
task: async (ctx, task) => {
const { privateKey, rpcUrl } = ctx;
await deploy({ privateKey, rpcUrl });
task.title = "Contracts deployed";
},
},
{
title: "Generating artifacts",
task: async (_, task) =>
task.newListr(
artifacts(path.join("build", "deployments", "31337")),
{ concurrent: true },
),
},
{
title: "Stopping anvil...",
task: async (ctx, task) => {
if (ctx.anvilProc) {
// kill anvil gracefully
await anvil.stop(ctx.anvilProc);
}
task.title = `Anvil stopped -> ${ctx.dumpState}`;
},
},
],
{ exitOnError: false },
);

await tasks.run({
privateKey:
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
rpcUrl: "http://127.0.0.1:8545",
dumpState: "anvil_state.json",
});

// exit with error code if any task failed
const failed = tasks.tasks.some((task) => task.hasFailed());
if (failed) {
process.exit(1);
}
};

build().catch(() => {
process.exit(1);
});
21 changes: 0 additions & 21 deletions packages/devnet/cannonfile.toml

This file was deleted.

47 changes: 47 additions & 0 deletions packages/devnet/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ensureDir } from "fs-extra";
import { unpackTar } from "modern-tar/fs";
import { pipeline } from "node:stream/promises";
import { createGunzip } from "node:zlib";

/**
* Download and extract a tarball to a destination directory
* @param url - the url of the tarball to download
* @param destination - the destination directory to extract the tarball to
*/
type DownloadAndExtractOptions = {
url: string;
destination: string;
stripComponents?: number;
};
export const downloadAndExtract = async (
options: DownloadAndExtractOptions,
) => {
const { url, destination, stripComponents = 0 } = options;

const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`);
}

// Create destination directory if it doesn't exist
await ensureDir(destination);

// Stream download → gunzip → extract
const extractStream = unpackTar(destination, {
map: (header) => {
if (stripComponents > 0) {
header.name = header.name
.split("/")
.splice(stripComponents)
.join("/");
}
return header;
},
});

await pipeline(
response.body as ReadableStream,
createGunzip(),
extractStream,
);
};
3 changes: 3 additions & 0 deletions packages/devnet/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ libs = ["dependencies"]
solc_version = "0.8.30"
optimizer = true

fs_permissions = [{ access = "read-write", path = "build" }]

[dependencies]
"@openzeppelin-contracts" = "5.5.0"
forge-std = "1.9.6"
Loading