Skip to content

Commit e580d99

Browse files
tulerclaude
andcommitted
feat(cli): add anvil fork mode to cartesi run
Allow forking from a live chain instead of using pre-baked devnet state. Adds --fork, --fork-url, and --fork-block-number options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent feb831d commit e580d99

11 files changed

Lines changed: 174 additions & 76 deletions

File tree

.changeset/shaggy-months-lose.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cartesi/cli": patch
3+
---
4+
5+
add anvil fork mode to `cartesi run`

apps/cli/biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
33
"root": false,
44
"extends": "//",
55
"linter": {

apps/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@
4141
"yaml": "^2.8.2"
4242
},
4343
"devDependencies": {
44-
"@cartesi/devnet": "2.0.0-alpha.9",
44+
"@cartesi/devnet": "2.0.0-alpha.10",
4545
"@cartesi/rollups": "2.1.1",
46+
"@sunodo/wagmi-plugin-hardhat-deploy": "^0.4.0",
4647
"@types/bun": "^1.3.6",
4748
"@types/bytes": "^3.1.5",
4849
"@types/fs-extra": "^11.0.4",

apps/cli/src/base.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
testNftAddress,
2727
testTokenAddress,
2828
} from "./contracts.js";
29-
import { getApplicationAddress } from "./exec/rollups.js";
29+
import { getApplicationAddress, getForkChainId } from "./exec/rollups.js";
3030
import type { PsResponse } from "./types/docker.js";
3131

3232
export const getContextPath = (...paths: string[]): string => {
@@ -67,13 +67,22 @@ export type AddressBook = Record<string, Address>;
6767
export const getAddressBook = async (options: {
6868
projectName?: string;
6969
}): Promise<AddressBook> => {
70+
const forkChainId = (await getForkChainId(options)) ?? 31337;
7071
const applicationAddress = await getApplicationAddress(options);
7172

73+
const chainDaveAppFactoryAddress =
74+
daveAppFactoryAddress[
75+
forkChainId as keyof typeof daveAppFactoryAddress
76+
];
77+
if (!chainDaveAppFactoryAddress) {
78+
throw new Error(`Unsupported fork chain ${forkChainId}`);
79+
}
80+
7281
// build rollups contracts address book
7382
const contracts: AddressBook = {
7483
ApplicationFactory: applicationFactoryAddress,
7584
AuthorityFactory: authorityFactoryAddress,
76-
DaveAppFactory: daveAppFactoryAddress,
85+
DaveAppFactory: chainDaveAppFactoryAddress,
7786
EntryPointV06: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
7887
EntryPointV07: "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
7988
ERC1155BatchPortal: erc1155BatchPortalAddress,

apps/cli/src/commands/run.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,34 @@ import chalk from "chalk";
88
import { ExecaError } from "execa";
99
import getPort, { portNumbers } from "get-port";
1010
import ora from "ora";
11-
import { type Address, type Hex, numberToHex } from "viem";
11+
import {
12+
type Address,
13+
createPublicClient,
14+
type Hex,
15+
http,
16+
numberToHex,
17+
} from "viem";
1218
import { getMachineHash, getProjectName } from "../base.js";
1319
import { DEFAULT_SDK_VERSION, PREFERRED_PORT } from "../config.js";
1420
import {
1521
AVAILABLE_SERVICES,
16-
type RollupsDeployment,
1722
deployApplication,
1823
removeApplication,
24+
type RollupsDeployment,
1925
startEnvironment,
2026
stopEnvironment,
2127
waitHealthyEnvironment,
2228
} from "../exec/rollups.js";
2329
import { keySelect } from "../prompts.js";
2430

31+
const DEFAULT_FORK_URL = "https://ethereum.reth.rs/rpc";
32+
33+
export type ForkConfig = {
34+
blockNumber?: bigint;
35+
chainId: number;
36+
url: string;
37+
};
38+
2539
const commaSeparatedList = (value: string) => value.split(",");
2640

2741
const shell = async (options: {
@@ -165,6 +179,37 @@ const deploy = async (options: {
165179
return application;
166180
};
167181

182+
const configureFork = async (options: {
183+
fork?: true | undefined;
184+
forkUrl?: string;
185+
forkBlockNumber?: number;
186+
}): Promise<ForkConfig | undefined> => {
187+
// determine fork mode: explicit --fork flag or --fork-url provided
188+
const isFork = options.fork || options.forkUrl !== undefined;
189+
190+
if (!isFork) {
191+
return undefined;
192+
}
193+
194+
// assign default fork url
195+
const url = options.forkUrl ?? DEFAULT_FORK_URL;
196+
197+
// create a client to upstream so we can query it
198+
const client = createPublicClient({
199+
transport: http(url),
200+
});
201+
202+
// use explicit fork-block-number or query from upstream
203+
const blockNumber = options.forkBlockNumber
204+
? BigInt(options.forkBlockNumber)
205+
: await client.getBlockNumber();
206+
207+
// need to query fork chainId if forkUrl is specified
208+
const chainId = await client.getChainId();
209+
210+
return { blockNumber, chainId, url };
211+
};
212+
168213
export const createRunCommand = () => {
169214
return new Command("run")
170215
.description("Run a local cartesi node for the application.")
@@ -197,6 +242,17 @@ export const createRunCommand = () => {
197242
.default("latest"),
198243
)
199244
.option("--dry-run", "show the docker compose configuration", false)
245+
.option(
246+
"--fork",
247+
"fork from a live chain instead of using devnet state",
248+
)
249+
.option("--fork-url <url>", "RPC URL to fork from (implies --fork)")
250+
.addOption(
251+
new Option(
252+
"--fork-block-number <number>",
253+
"block number to fork from",
254+
).argParser(Number),
255+
)
200256
.addOption(
201257
new Option(
202258
"--memory <number>",
@@ -265,12 +321,16 @@ export const createRunCommand = () => {
265321
port: portNumbers(PREFERRED_PORT, PREFERRED_PORT + 10),
266322
}));
267323

324+
// configure optional anvil fork
325+
const forkConfig = await configureFork(options);
326+
268327
// run compose environment (detached)
269328
const { address, config } = await startEnvironment({
270329
blockTime,
271330
cpus,
272331
defaultBlock,
273332
dryRun,
333+
forkConfig,
274334
memory,
275335
port,
276336
projectName,

apps/cli/src/compose/anvil.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,46 @@
1+
import type { ForkConfig } from "../commands/run.js";
12
import type { ComposeFile, Config, Service } from "../types/compose.js";
23
import { DEFAULT_HEALTHCHECK } from "./common.js";
34

45
type ServiceOptions = {
56
imageTag?: string;
67
blockTime?: number;
8+
forkConfig?: ForkConfig;
79
};
810

911
// Anvil service
1012
const service = (options?: ServiceOptions): Service => {
1113
const blockTime = options?.blockTime ?? 2;
1214
const imageTag = options?.imageTag ?? "latest";
15+
const forkConfig = options?.forkConfig;
16+
17+
// command for fork and command for load-state local (non-fork)
18+
const command = forkConfig
19+
? [
20+
"anvil",
21+
"--chain-id",
22+
"31337",
23+
"--block-time",
24+
blockTime.toString(),
25+
"--fork-url",
26+
forkConfig.url,
27+
...(forkConfig.blockNumber !== undefined
28+
? ["--fork-block-number", forkConfig.blockNumber.toString()]
29+
: []),
30+
]
31+
: ["devnet", "--block-time", blockTime.toString()];
32+
33+
// in case of forked network service is ready only when it responds with target block number
34+
const test = forkConfig?.blockNumber
35+
? ["CMD", "eth_isready", forkConfig.blockNumber?.toString()]
36+
: ["CMD", "eth_isready"];
1337

1438
return {
1539
image: `cartesi/sdk:${imageTag}`,
16-
command: ["devnet", "--block-time", blockTime.toString()],
40+
command,
1741
healthcheck: {
1842
...DEFAULT_HEALTHCHECK,
19-
test: ["CMD", "eth_isready"],
43+
test,
2044
},
2145
environment: {
2246
ANVIL_IP_ADDR: "0.0.0.0",

apps/cli/src/compose/node.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import type { ComposeFile, Config, Service } from "../types/compose.js";
88
import { DEFAULT_HEALTHCHECK } from "./common.js";
99

1010
type ServiceOptions = {
11+
cpus?: number;
1112
databaseHost?: string;
1213
databasePort?: number;
1314
databasePassword: string;
1415
defaultBlock?: "latest" | "safe" | "pending" | "finalized";
15-
cpus?: number;
16+
forkChainId?: number;
1617
logLevel?: "info" | "debug" | "warn" | "error" | "fatal";
1718
memory?: number;
1819
mnemonic?: string;
@@ -30,6 +31,10 @@ const service = (options: ServiceOptions): Service => {
3031
const mnemonic =
3132
options.mnemonic ??
3233
"test test test test test test test test test test test junk";
34+
const chainId = (options.forkChainId ??
35+
31337) as keyof typeof daveAppFactoryAddress;
36+
37+
const chainDaveAppFactoryAddress = daveAppFactoryAddress[chainId];
3338

3439
return {
3540
image: `cartesi/rollups-runtime:${imageTag}`,
@@ -63,7 +68,8 @@ const service = (options: ServiceOptions): Service => {
6368
CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545",
6469
CARTESI_BLOCKCHAIN_ID: anvil.id.toString(),
6570
CARTESI_BLOCKCHAIN_WS_ENDPOINT: "ws://anvil:8545",
66-
CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: daveAppFactoryAddress,
71+
CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS:
72+
chainDaveAppFactoryAddress,
6773
CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: inputBoxAddress,
6874
CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS:
6975
selfHostedApplicationFactoryAddress,

apps/cli/src/exec/rollups.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
type Address,
77
type Hash,
88
type Hex,
9+
createPublicClient,
910
getAddress,
1011
hexToNumber,
12+
http,
1113
} from "viem";
1214
import { stringify } from "yaml";
1315
import {
@@ -16,6 +18,7 @@ import {
1618
getProjectName,
1719
getServiceHealth,
1820
} from "../base.js";
21+
import type { ForkConfig } from "../commands/run.js";
1922
import anvil from "../compose/anvil.js";
2023
import { concat } from "../compose/builder.js";
2124
import bundler from "../compose/bundler.js";
@@ -100,6 +103,25 @@ export const getApplicationAddress = async (options: {
100103
return deployment?.address;
101104
};
102105

106+
/**
107+
* Get anvil node configuration and query the chainId of its fork
108+
* @param options projectName
109+
* @returns chainId of anvil fork
110+
*/
111+
export const getForkChainId = async (options: {
112+
projectName?: string;
113+
}): Promise<number | undefined> => {
114+
const projectName = getProjectName(options ?? {});
115+
const nodeInfo = await getAnvilNodeInfo({ projectName });
116+
const forkUrl = nodeInfo?.forkConfig?.forkUrl;
117+
if (forkUrl) {
118+
// if anvil is configured with a forkUrl, connect to it and query the chainId
119+
const client = createPublicClient({ transport: http(forkUrl) });
120+
return client.getChainId();
121+
}
122+
return undefined;
123+
};
124+
103125
type Service = {
104126
name: string; // name of the service
105127
healthySemaphore?: string; // service to check if the service is healthy
@@ -216,6 +238,7 @@ export const startEnvironment = async (options: {
216238
cpus?: number;
217239
defaultBlock: "latest" | "safe" | "pending" | "finalized";
218240
dryRun: boolean;
241+
forkConfig?: ForkConfig;
219242
memory?: number;
220243
port: number;
221244
projectName: string;
@@ -228,6 +251,7 @@ export const startEnvironment = async (options: {
228251
cpus,
229252
defaultBlock,
230253
dryRun,
254+
forkConfig,
231255
memory,
232256
port,
233257
projectName,
@@ -246,12 +270,17 @@ export const startEnvironment = async (options: {
246270
};
247271

248272
const files = [
249-
anvil({ blockTime, imageTag: runtimeVersion }),
273+
anvil({
274+
blockTime,
275+
forkConfig,
276+
imageTag: runtimeVersion,
277+
}),
250278
database({ imageTag: runtimeVersion, password: "password" }),
251279
node({
252280
cpus,
253281
databasePassword: "password",
254282
defaultBlock,
283+
forkChainId: forkConfig?.chainId,
255284
imageTag: runtimeVersion,
256285
logLevel: verbose ? "debug" : "info",
257286
memory,
@@ -546,3 +575,23 @@ export const getProjectPort = async (options: { projectName: string }) => {
546575
]);
547576
return stdout;
548577
};
578+
579+
/**
580+
* Get anvil node info returned by RPC method anvil_nodeInfo
581+
* @param options
582+
* @returns anvil node info
583+
*/
584+
export const getAnvilNodeInfo = async (options: { projectName: string }) => {
585+
const { projectName } = options;
586+
const { stdout } = await execa("docker", [
587+
"compose",
588+
"--project-name",
589+
projectName,
590+
"exec",
591+
"anvil",
592+
"cast",
593+
"rpc",
594+
"anvil_nodeInfo",
595+
]);
596+
return JSON.parse(stdout);
597+
};

0 commit comments

Comments
 (0)