Running applications on a forked network
Introduction
When building Cartesi applications, it is often necessary to interact with contracts and services that are already deployed on a live blockchain network. While a local clean chain is useful during early development, it does not always reflect real world conditions.
To address this, the Cartesi CLI introduces support for running applications on a forked network. This feature allows you to replicate the state of an existing blockchain locally and run your application against it.
This is enabled through two flags available in the cartesi run command:
--fork-url--fork-block-number
Using these flags, the CLI forks a network from a given RPC endpoint at a specific block height, load that state into your local Anvil environment, then runs your Cartesi application on top of it.
This approach makes it possible to develop and test locally while interacting with real on chain data from networks such as testnets or mainnets.
Understanding the fork options
--fork-url
This flag specifies the RPC endpoint that will serve as the source of truth for the forked network.
Example:
--fork-url https://sample-rpc.sepolia.com
The CLI uses this endpoint to fetch blockchain state and replicate it locally.
--fork-block-number
This flag defines the exact block height used as the snapshot point for the fork.
Example:
--fork-block-number 32768
Using a fixed block number ensures deterministic behavior, which is especially useful when debugging or running repeatable tests.
Basic usage
To run your application on a forked network, execute:
cartesi run --fork-url https://sample-rpc.sepolia.com --fork-block-number 32768
When this command is executed, the Cartesi CLI performs the following steps:
- Connects to the specified RPC endpoint
- Fetches blockchain state at the given block number
- Initializes a local network using the forked state
- Deploys required Cartesi infrastructure contracts, including portals, InputBox, and test tokens
- Starts the Cartesi node stack, allowing your application to run in this environment
Why use a forked network
Running your application on a forked network provides several advantages:
- Enables interaction with contracts that already exist on the source network
- Allows reproduction of issues using a known blockchain state
- Improves development speed by enabling local iteration with realistic data
- Helps validate integration logic before deploying to public test networks
Best practices
- Prefer stable and reliable HTTPS RPC endpoints
- Use a fixed block number during debugging to ensure consistent results
- Update the block number when you need access to more recent state
- Validate that the RPC endpoint is reachable if the command fails to start
Deploying and interacting with a sample application
In this section, you will build a simple end to end example that demonstrates how a Cartesi application can interact with a contract that already exists on a forked network.
The workflow follows a practical sequence:
- set up your development environment
- create a new Cartesi application
- deploy a contract to Sepolia
- run your application on a forked network
- interact with the deployed contract through the fork
Set up your environment
Before getting started, ensure the following tools are installed:
-
Cartesi CLI: A simple tool for building applications on Cartesi. Install Cartesi CLI for your OS of choice.
-
Docker Desktop 4.x: The tool you need to run the Cartesi Machine and its dependencies. Install Docker for your OS of choice.
Create an application template using the Cartesi CLI
Use the Cartesi CLI to generate a starter application:
- JavaScript
- Python
- Rust
cartesi create fork-seploia --template javascript
cartesi create fork-seploia --template python
cartesi create fork-seploia --template rust
This command creates a new directory named fork-seploia. Depending on the selected language, the directory includes a basic project structure and an entry point for your application logic.
Create and deploy a Solidity contract to Sepolia
Next, you will create and deploy a Solidity contract named InputRelayer to Sepolia.
This contract acts as a simple relay layer. It receives a user request and forwards it to the Cartesi InputBox contract. Your Cartesi application will later receive this input, decode it, and process the message.
In a real world scenario, this contract could represent an existing protocol such as a liquidity pool manager, a swap router, or any deployed smart contract that your application integrates with.
Create and implement contract logic
Inside the fork-seploia directory, create a contracts folder and add the InputRelayer.sol file:
mkdir contracts
touch contracts/InputRelayer.sol
Then copy the Solidity implementation into the file. This contract accepts a destination, an InputBox address, and user input. It encodes the request and forwards it to the specified InputBox.
- Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IInputBox {
function addInput(address appContract, bytes calldata payload) external returns (bytes32);
}
contract InputRelayer {
struct RelayRecord {
address destination;
address inputBox;
bytes inputBody;
bytes32 inputHash;
uint256 timestamp;
}
RelayRecord[] private _records;
function relayInput(
address destination,
address input_box,
bytes calldata input_body
) external returns (bytes32) {
bytes32 inputHash = IInputBox(input_box).addInput(destination, input_body);
_records.push(RelayRecord({
destination: destination,
inputBox: input_box,
inputBody: input_body,
inputHash: inputHash,
timestamp: block.timestamp
}));
return inputHash;
}
function getRecordCount() external view returns (uint256) {
return _records.length;
}
function getRecord(uint256 index) external view returns (RelayRecord memory) {
require(index < _records.length, "InputRelayer: index out of bounds");
return _records[index];
}
function getAllRecords() external view returns (RelayRecord[] memory) {
return _records;
}
function getRecordsByDestination(address destination) external view returns (RelayRecord[] memory) {
uint256 count = 0;
for (uint256 i = 0; i < _records.length; i++) {
if (_records[i].destination == destination) count++;
}
RelayRecord[] memory result = new RelayRecord[](count);
uint256 j = 0;
for (uint256 i = 0; i < _records.length; i++) {
if (_records[i].destination == destination) {
result[j++] = _records[i];
}
}
return result;
}
}
Deploy contract to Base Sepolia
Deploy the contract to Base Sepolia using the following command:
forge create contracts/InputRelayer.sol:InputRelayer --rpc-url <RPC-Url> --private-key <Deployment Private Key with Eth token> --broadcast -v
This returns an output similar to:
[⠊] Compiling...
No files changed, compilation skipped
Deployer: 0xbD8Eba8Bf9e56ad92F4C4Fc89D6CB88902535749
Deployed to: 0x06eBAF6d44B65d76C8BDcB1701E68f44C22B1057
Transaction hash: 0xa5c073210568d1d8b11b2ff6bed0d7bd0d8058cfcf0ec561f51b5de9c8e21644
After deployment, inspect the transaction using a block explorer. Take note of the block number in which the transaction was included, as this will be used later when configuring the fork.
Implementing the Cartesi application logic
Now implement the logic for your Cartesi application.
This application will:
- receive inputs from the onchain relayer
- decode the payload
- extract the original sender and message
- store the data in memory
- log a structured summary of the received input
To set this up, replace the contents of the src/ directory inside fork-seploia with the appropriate snippet for your chosen language.
- JavaScript
- Rust
const rollup_server = process.env.ROLLUP_HTTP_SERVER_URL;
console.log("HTTP rollup_server url is " + rollup_server);
// In-memory store of all advance messages received from the InputRelayer
const messages = [];
/**
* Decodes a hex string to a UTF-8 string.
* Accepts with or without "0x" prefix.
*/
function hexToUtf8(hex) {
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
const bytes = Buffer.from(clean, "hex");
return bytes.toString("utf8");
}
/**
* Decodes a hex string to a Buffer.
*/
function hexToBuffer(hex) {
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
return Buffer.from(clean, "hex");
}
/**
* Encodes a string to a "0x"-prefixed hex string.
*/
function utf8ToHex(str) {
return "0x" + Buffer.from(str, "utf8").toString("hex");
}
async function handle_advance(data) {
console.log("Received advance request data " + JSON.stringify(data));
const relayer = data.metadata.msg_sender;
const inputIndex = data.metadata.input_index;
const payload = data.payload;
// Decode payload: first 20 bytes = original sender address (abi.encodePacked),
// remaining bytes = UTF-8 message.
const bytes = hexToBuffer(payload);
if (bytes.length < 20) {
console.error(
"Payload too short to contain a 20-byte sender address, rejecting",
);
return "reject";
}
const originalSender = "0x" + bytes.slice(0, 20).toString("hex");
const message = bytes.slice(20).toString("utf8");
console.log(
`[InputRelayer] input_index=${inputIndex} relayer=${relayer} original_sender=${originalSender} message="${message}"`,
);
messages.push({
input_index: inputIndex,
relayer,
original_sender: originalSender,
message,
});
return "accept";
}
async function handle_inspect(data) {
console.log("Received inspect request data " + JSON.stringify(data));
const route = hexToUtf8(data.payload).replace(/^\//, "");
console.log(`Inspect route: "${route}"`);
let reportBody;
if (route === "messages" || route === "") {
reportBody = JSON.stringify(messages);
} else if (route === "messages/count") {
reportBody = JSON.stringify({ count: messages.length });
} else {
reportBody = JSON.stringify({ error: "unknown route", route });
}
const resp = await fetch(rollup_server + "/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: utf8ToHex(reportBody) }),
});
console.log("Report response status: " + resp.status);
return "accept";
}
var handlers = {
advance_state: handle_advance,
inspect_state: handle_inspect,
};
var finish = { status: "accept" };
(async () => {
while (true) {
const finish_req = await fetch(rollup_server + "/finish", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status: finish["status"] }),
});
console.log("Received finish status " + finish_req.status);
if (finish_req.status == 202) {
console.log("No pending rollup request, trying again");
} else {
const rollup_req = await finish_req.json();
var handler = handlers[rollup_req["request_type"]];
finish["status"] = await handler(rollup_req["data"]);
}
}
})();
use json::{object, JsonValue};
use std::env;
use std::sync::Mutex;
#[derive(Clone)]
struct AdvanceMessage {
relayer: String,
original_sender: String,
message: String,
input_index: u64,
}
static MESSAGES: Mutex<Vec<AdvanceMessage>> = Mutex::new(Vec::new());
fn hex_decode(hex: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let hex = hex.strip_prefix("0x").unwrap_or(hex);
if hex.len() % 2 != 0 {
return Err("hex string has odd length".into());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| Box::new(e) as _))
.collect()
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub async fn handle_advance(
_client: &hyper::Client<hyper::client::HttpConnector>,
_server_addr: &str,
request: JsonValue,
) -> Result<&'static str, Box<dyn std::error::Error>> {
println!("Received advance request data {}", &request);
let payload = request["data"]["payload"]
.as_str()
.ok_or("Missing payload")?;
let relayer = request["data"]["metadata"]["msg_sender"]
.as_str()
.unwrap_or("unknown")
.to_string();
let input_index = request["data"]["metadata"]["input_index"]
.as_u64()
.unwrap_or(0);
// Decode payload: first 20 bytes = original sender address (abi.encodePacked),
// remaining bytes = UTF-8 message.
let bytes = hex_decode(payload)?;
if bytes.len() < 20 {
eprintln!("Payload too short to contain a 20-byte sender address, rejecting");
return Ok("reject");
}
let original_sender = format!("0x{}", hex_encode(&bytes[0..20]));
let message = std::str::from_utf8(&bytes[20..])
.unwrap_or("<invalid utf-8>")
.to_string();
println!(
"[InputRelayer] input_index={} relayer={} original_sender={} message=\"{}\"",
input_index, relayer, original_sender, message
);
MESSAGES.lock().unwrap().push(AdvanceMessage {
relayer,
original_sender,
message,
input_index,
});
Ok("accept")
}
pub async fn handle_inspect(
client: &hyper::Client<hyper::client::HttpConnector>,
server_addr: &str,
request: JsonValue,
) -> Result<&'static str, Box<dyn std::error::Error>> {
println!("Received inspect request data {}", &request);
let payload = request["data"]["payload"]
.as_str()
.ok_or("Missing payload")?;
let route_bytes = hex_decode(payload)?;
let route = std::str::from_utf8(&route_bytes)
.unwrap_or("")
.trim_start_matches('/');
println!("Inspect route: \"{}\"", route);
let report_body = match route {
"messages" | "" => {
let messages = MESSAGES.lock().unwrap();
let mut arr = JsonValue::new_array();
for msg in messages.iter() {
let _ = arr.push(object! {
"input_index" => msg.input_index,
"relayer" => msg.relayer.clone(),
"original_sender" => msg.original_sender.clone(),
"message" => msg.message.clone(),
});
}
arr.dump()
}
"messages/count" => {
let count = MESSAGES.lock().unwrap().len();
format!("{{\"count\":{}}}", count)
}
_ => {
format!("{{\"error\":\"unknown route\",\"route\":\"{}\"}}", route)
}
};
let hex_payload = format!("0x{}", hex_encode(report_body.as_bytes()));
let report = object! { "payload" => hex_payload };
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.header(hyper::header::CONTENT_TYPE, "application/json")
.uri(format!("{}/report", server_addr))
.body(hyper::Body::from(report.dump()))?;
let resp = client.request(req).await?;
println!("Report response status: {}", resp.status());
Ok("accept")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = hyper::Client::new();
let server_addr = env::var("ROLLUP_HTTP_SERVER_URL")?;
let mut status = "accept";
loop {
println!("Sending finish");
let response = object! {"status" => status};
let request = hyper::Request::builder()
.method(hyper::Method::POST)
.header(hyper::header::CONTENT_TYPE, "application/json")
.uri(format!("{}/finish", &server_addr))
.body(hyper::Body::from(response.dump()))?;
let response = client.request(request).await?;
println!("Received finish status {}", response.status());
if response.status() == hyper::StatusCode::ACCEPTED {
println!("No pending rollup request, trying again");
} else {
let body = hyper::body::to_bytes(response).await?;
let utf = std::str::from_utf8(&body)?;
let req = json::parse(utf)?;
let request_type = req["request_type"]
.as_str()
.ok_or("request_type is not a string")?;
status = match request_type {
"advance_state" => handle_advance(&client, &server_addr[..], req).await?,
"inspect_state" => handle_inspect(&client, &server_addr[..], req).await?,
&_ => {
eprintln!("Unknown request type");
"reject"
}
};
}
}
}
Build and run your application
Once your application logic is in place, build the project by running:
cartesi build
This command compiles your application and produces a Cartesi Machine image that includes your code.
Next, run the application using a forked network. Provide the RPC endpoint for Base Sepolia and the block number where the InputRelayer contract was deployed:
cartesi run --fork-url <Valid RPC URL> --fork-block-number 39988953
Expected logs
WARNING: default block is set to 'latest', production configuration will likely use 'finalized'
✔ fork-sepolia-r starting at http://127.0.0.1:6751
✔ anvil service ready at http://127.0.0.1:6751/anvil
✔ rpc service ready at http://127.0.0.1:6751/rpc
✔ inspect service ready at http://127.0.0.1:6751/inspect/fork-sepolia-r
✔ fork-sepolia-r machine hash is 0xa5b369b0d19766005d12369d0fa588925093ffe24c44c64c76155734270b7ac7
✔ fork-sepolia-r contract deployed at 0xa831a9883abc2ae26c643b3cab57498b8c6fcb52
(l) View logs (b) Build and redeploy (q) Quit
At this point, your application is running on a local network that mirrors the state of Base Sepolia at the specified block.
Interacting with the Cartesi application through the forked contract
Because the network was forked at a block where the InputRelayer contract already exists, you can interact with your application through this contract as if you were on the original network.
Use the following command to send an input through the relayer:
- Structure
- Sample
cast send <InputRelayer address> "relayInput(address,address,bytes)" <application address> <input-box address> --rpc-url <application local rpc_url> --private-key <private key>
cast send 0x06eBAF6d44B65d76C8BDcB1701E68f44C22B1057 "relayInput(address,address,bytes)" 0xa831a9883abc2ae26c643b3cab57498b8c6fcb52 0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac "0xd8da6bf26964af9d7eed9e03e53415d37aa9604548656c6c6f2066726f6d204361727465736921" --rpc-url http://127.0.0.1:6751/anvil --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
This command calls the relayInput(address,address,bytes) function on the relayer contract, passing:
- the Cartesi application address
- the InputBox address
- the encoded user input
Ensure that you replace the application address and RPC URL with the values returned when you executed cartesi run.
Expected logs
[INFO rollup_http_server::http_service] received new request of type ADVANCE
[INFO actix_web::middleware::logger] 127.0.0.1 "POST /finish HTTP/1.1" 200 434 "-" "-" 0.018560
Received finish status 200 OK
Received advance request data {"request_type":"advance_state","data":{"metadata":{"chain_id":31337,"app_contract":"0xa831a9883abc2ae26c643b3cab57498b8c6fcb52","msg_sender":"0x06ebaf6d44b65d76c8bdcb1701e68f44c22b1057","block_number":39989157,"block_timestamp":1775746604,"prev_randao":"0x50244e200a55ed7d32e48e6f0c32710fe89c537345036bf55a02608736d25cc2","input_index":1},"payload":"0xd8da6bf26964af9d7eed9e03e53415d37aa9604548656c6c6f2066726f6d204361727465736921"}}
[InputRelayer] input_index=1 relayer=0x06ebaf6d44b65d76c8bdcb1701e68f44c22b1057 original_sender=0xd8da6bf26964af9d7eed9e03e53415d37aa96045 message="Hello from Cartesi!"
Sending finish
Summary
In this section, you successfully:
- deployed a contract to Sepolia
- forked the network locally at a specific block
- ran a Cartesi application on top of that fork
- interacted with the application through an already deployed contract
This demonstrates how the Cartesi CLI can be used to replicate real blockchain environments locally, enabling integration and interaction with already deployed contracts during development and testing.