Migrating from Geth to Reth
This guide helps you migrate the execution layer of your 0G Aristotle node from geth to reth, adapted to the directory structure and configuration from our Validator Node Setup guide.
This migration is optional
You are not required to move to reth to stay on the network — geth is still supported and remains a fully valid node. reth is the recommended client going forward for better performance. Do not rush it; plan it as its own maintenance window.
Why Reth?
geth (0g-geth) | reth (0g-reth) | |
|---|---|---|
| Language | Go | Rust |
| Database | LevelDB | MDBX |
| Status on 0G | Supported | Recommended going forward |
| Sync & storage | Baseline | Faster sync, more efficient storage |
| Switching cost | — | EL data must be rebuilt from scratch |
What does not change during migration:
- The consensus layer (
0gchaind) is untouched — your validator keys, consensus state, and node identity are all preserved. - Only the execution layer database is rebuilt.
Validator Key & Double-Sign
During migration you will stop and restart 0gchaind. Never run the same priv_validator_key.json on two machines at once — fully stop the old process before starting the new one, or you risk a double-sign slashing penalty.
Prerequisites
Before you start, make sure you have:
- The reth binary from the latest release package (v1.0.6+)
- Enough disk space for backup + RLP export (estimate ~2x the size of
geth-home) - Sufficient downtime window (export + import can take several hours depending on chain height and disk speed)
Migration Steps
Step 1: Download the Reth Binary
Download the latest release which includes the reth binary:
cd $HOME
wget https://github.com/0gfoundation/0gchain-Aristotle/releases/download/v1.0.6/aristotle-v1.0.6.tar.gz
tar -xvzf aristotle-v1.0.6.tar.gzConfirm the reth binary is present in the package:
ls $HOME/aristotle-v1.0.6/bin/
# Expected: 0gchaind geth rethInstall reth to /usr/local/bin:
chmod +x $HOME/aristotle-v1.0.6/bin/reth
sudo cp $HOME/aristotle-v1.0.6/bin/reth /usr/local/bin/reth
reth --versionStep 2: Note the Current Chain Head
Do this while geth is still running, before stopping it:
journalctl -u geth --no-pager | grep "Chain head was updated" | tail -1You will see a line like:
Chain head was updated number=36,687,726 hash=eeef21..5f4517 ...To extract just the block number:
journalctl -u geth --no-pager | grep "Chain head was updated" | tail -1 | grep -oP 'number=\K[\d,]+' | tr -d ','Note that number down — you will need it in Step 5.
Step 3: Stop Services
Stop the CL first, then the EL. Both must be fully stopped before backing up — copying LevelDB files while geth is running risks a corrupt backup.
sudo systemctl stop 0gchaind
sudo systemctl stop gethStep 4: Back Up Existing Data
BACKUP_DIR="$HOME/.0gchaind/backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p $BACKUP_DIR
# CL data (small, required for rollback)
cp -r $HOME/.0gchaind/0g-home/0gchaind-home $BACKUP_DIR/0gchaind-home
# geth EL data (large — skip if disk is tight,
# but without this backup you cannot roll back to geth)
cp -r $HOME/.0gchaind/0g-home/geth-home $BACKUP_DIR/geth-homeINFO
Backing up geth-home is strongly recommended — this is the data that will be deleted in Step 6. Without it, rolling back to geth will not be possible. Backing up 0gchaind-home is optional since the CL is untouched during migration, but it serves as a safety net in case of unexpected issues.
Step 5: Export Chain Data from Geth
INFO
This process can take a long time depending on chain height and disk speed. Run it inside a tmux or screen session to avoid interruption.
Export the full chain from block 1 up to the chain head noted in Step 2. This takes a long time — run it inside a tmux or screen session.
geth export \
--datadir $HOME/.0gchaind/0g-home/geth-home \
$HOME/.0gchaind/chain-export.rlp \
1 <chain_head>Replace
<chain_head>with the block number from Step 2, e.g.1 39048765
Monitor the export file size:
ls -lh $HOME/.0gchaind/chain-export.rlpStep 6: Remove the Geth Data Directory
Destructive Step
Only run this after confirming the backup in Step 3 is complete and the RLP export file exists.
rm -rf $HOME/.0gchaind/0g-home/geth-homeStep 7: Initialize Reth
reth init --chain $HOME/.0gchaind/geth-genesis.json \
--datadir $HOME/.0gchaind/0g-home/reth-homeVerify the reth data directory was created:
ls $HOME/.0gchaind/0g-home/reth-home/
# Expected: db/ and other reth state directoriesStep 8: Trim the RLP Export (Skip the Genesis Block)
reth cannot import block 0 (genesis) from the RLP file — it conflicts with the genesis already written during reth init. Use the script below to strip block 0 from the export.
Save as $HOME/trim_export.py:
cat > $HOME/trim_export.py << 'EOF'
import sys
import os
base_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.expanduser("~"), ".0gchaind")
input_file = base_dir + "/chain-export.rlp"
output_file = base_dir + "/chain-export-from-{start}.rlp"
start_block = int(sys.argv[1]) if len(sys.argv) > 1 else 1
output_file = output_file.format(start=start_block)
print(f"Trimming blocks before {start_block}, output: {output_file}")
def read_rlp_length(f):
first = f.read(1)
if not first:
return None, 0
b = first[0]
if b < 0xc0:
return None, 0
elif b <= 0xf7:
return first, b - 0xc0
else:
len_bytes_count = b - 0xf7
len_bytes = f.read(len_bytes_count)
return first + len_bytes, int.from_bytes(len_bytes, 'big')
def get_block_number(block_data):
offset = 0
b = block_data[offset]
offset += 1 if b <= 0xf7 else 1 + (b - 0xf7)
b = block_data[offset]
offset += 1 if b <= 0xf7 else 1 + (b - 0xf7)
for _ in range(8):
b = block_data[offset]
if b <= 0x80:
offset += 1
elif b <= 0xb7:
offset += 1 + (b - 0x80)
elif b <= 0xbf:
n = b - 0xb7
offset += 1 + n + int.from_bytes(block_data[offset+1:offset+1+n], 'big')
elif b <= 0xf7:
offset += 1 + (b - 0xc0)
else:
n = b - 0xf7
offset += 1 + n + int.from_bytes(block_data[offset+1:offset+1+n], 'big')
b = block_data[offset]
if b == 0x80: return 0
if b < 0x80: return b
length = b - 0x80
return int.from_bytes(block_data[offset+1:offset+1+length], 'big')
block_count = 0
skipped = 0
with open(input_file, "rb") as fin, open(output_file, "wb") as fout:
while True:
header_bytes, length = read_rlp_length(fin)
if header_bytes is None:
break
block_body = fin.read(length)
if len(block_body) < length:
break
full_block = header_bytes + block_body
try:
block_number = get_block_number(full_block)
except Exception as e:
print(f"Warning: could not parse block at index {block_count + skipped}, writing anyway: {e}")
fout.write(full_block)
block_count += 1
continue
if block_number < start_block:
skipped += 1
if skipped % 100000 == 0:
print(f"Skipped {skipped} blocks (current: {block_number})...")
else:
fout.write(full_block)
block_count += 1
if block_count % 100000 == 0:
print(f"Written {block_count} blocks (current: {block_number})...")
print(f"Done. Skipped {skipped}, wrote {block_count} blocks to {output_file}")
EOFRun (skip genesis only):
python3 $HOME/trim_export.py 1 $HOME/.0gchaindThe output file will be named: $HOME/.0gchaind/chain-export-from-1.rlp
Step 9: Import into Reth
Run in the background — this can take several hours:
mkdir -p $HOME/.0gchaind/0g-home/log
nohup reth import \
--chain $HOME/.0gchaind/geth-genesis.json \
--datadir $HOME/.0gchaind/0g-home/reth-home \
$HOME/.0gchaind/chain-export-from-1.rlp \
>> $HOME/.0gchaind/0g-home/log/reth-import.log 2>&1 &Monitor progress:
tail -f $HOME/.0gchaind/0g-home/log/reth-import.logIf the import fails mid-way with block number X does not match parent block number Y:
# Check the last successfully written height
grep "latest_block" $HOME/.0gchaind/0g-home/log/reth-import.log | tail -3
# Re-trim from that height (replace <Y+1> with the correct number)
python3 $HOME/trim_export.py <Y+1> $HOME/.0gchaind
# Re-run import with the new trimmed file
nohup reth import \
--chain $HOME/.0gchaind/geth-genesis.json \
--datadir $HOME/.0gchaind/0g-home/reth-home \
$HOME/.0gchaind/chain-export-from-<Y+1>.rlp \
>> $HOME/.0gchaind/0g-home/log/reth-import.log 2>&1 &If the import fails with mismatched block state root, this is a compatibility issue between 0G's geth fork and reth fork. Record the failing block number and state root values from the log and report to the 0G development team. Do not attempt to skip the block manually.
Step 10: Verify the Import is Complete
Once the import finishes, confirm reth's block height matches the chain head before starting 0gchaind:
# Check reth block height (adjust port to match your OG_PORT, e.g. 55)
curl -s -X POST http://localhost:${OG_PORT}545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
| jq -r '.result' | xargs printf "%d\n"
# Compare against the public chain head
curl -s -X POST https://evmrpc.0g.ai \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
| jq -r '.result' | xargs printf "%d\n"The two values should be equal or within a few blocks. If reth is still behind, it will catch up via P2P after 0gchaind is started.
Wait for reth to sync
Do not proceed until reth height is at or near chain head. Starting 0gchaind while reth is still syncing will cause a -38002 Invalid forkchoice state panic.
Step 11: Create the Reth Systemd Service
Remove the old geth service and create a new reth service:
sudo systemctl disable geth
sudo rm /etc/systemd/system/geth.serviceCreate reth.service:
sudo tee /etc/systemd/system/reth.service > /dev/null <<EOF
[Unit]
Description=0g Reth Node Service
After=network-online.target
[Service]
User=$USER
WorkingDirectory=$HOME/.0gchaind
ExecStart=/usr/local/bin/reth node \
--chain $HOME/.0gchaind/geth-genesis.json \
--http \
--http.addr 127.0.0.1 \
--http.port ${OG_PORT}545 \
--http.api eth,net,admin \
--ws \
--ws.addr 127.0.0.1 \
--ws.port ${OG_PORT}546 \
--authrpc.addr 127.0.0.1 \
--authrpc.port ${OG_PORT}551 \
--authrpc.jwtsecret $HOME/.0gchaind/jwt.hex \
--datadir $HOME/.0gchaind/0g-home/reth-home \
--ipcpath $HOME/.0gchaind/0g-home/reth-home/eth-engine.ipc \
--engine.persistence-threshold 0 \
--engine.memory-block-buffer-target 0 \
--port ${OG_PORT}303 \
--bootnodes "enode://2bf74c837a98c94ad0fa8f5c58a428237d2040f9269fe622c3dbe4fef68141c28e2097d7af6ebaa041194257543dc112514238361a6498f9a38f70fd56493f96@8.221.140.134:30303" \
--nat extip:$(curl -4 -s ifconfig.me)
Restart=always
RestartSec=3
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
EOFNote on HTTP Host:
The service above uses127.0.0.1(local access only). Change to0.0.0.0if you need public access and have secured it with a firewall or reverse proxy.
Step 12: Update app.toml for 0gchaind
Since reth uses authrpc as its engine API (replacing the AuthPort in geth-config.toml), make sure app.toml points to the correct port:
CONFIG="$HOME/.0gchaind/0g-home/0gchaind-home/config"
sed -i "s|^rpc-dial-url *=.*|rpc-dial-url = \"http://localhost:${OG_PORT}551\"|" $CONFIG/app.tomlStep 13: Start Reth, then 0gchaind
Start reth first, wait for the engine API to be ready, then start 0gchaind:
sudo systemctl daemon-reload
sudo systemctl enable reth
sudo systemctl start rethConfirm the engine API is listening before starting the CL:
ss -tlnp | grep ${OG_PORT}551Once confirmed, start 0gchaind:
sudo systemctl start 0gchaindStep 14: Verify
# Check reth logs
journalctl -u reth -f
# Check 0gchaind logs
journalctl -u 0gchaind -f
# Check both at once
journalctl -u 0gchaind -u reth -f
# Check sync status
curl -s localhost:${OG_PORT}657/status | jq .result.sync_infoThe node is running normally when:
- reth logs show syncing messages or "Starting consensus engine"
- 0gchaind logs show "Committed state" with no
-38002errors catching_up: falsein the sync status output
Rollback to Geth (If Migration Fails)
If the migration fails and you need to restore geth:
# Stop all processes
sudo systemctl stop 0gchaind
sudo systemctl stop reth
# Restore geth data from backup
rm -rf $HOME/.0gchaind/0g-home/geth-home
cp -r $BACKUP_DIR/geth-home $HOME/.0gchaind/0g-home/geth-home
# Re-enable geth.service (from the original validator setup)
# If the service file was removed, recreate it from the validator setup guide
sudo systemctl enable geth
sudo systemctl start geth
sudo systemctl start 0gchaindTroubleshooting
-38002 Invalid forkchoice state — reth is still syncing when 0gchaind was started. Stop 0gchaind, wait for reth to reach chain head, then restart 0gchaind.
EL and CL not communicating (engine API errors) — confirm the jwt.hex used by --chaincfg.engine.jwt-secret-path and --authrpc.jwtsecret are the same file. They must be identical.
missing priv_validator_state.json — create an empty state file:
echo '{}' > $HOME/.0gchaind/0g-home/0gchaind-home/data/priv_validator_state.jsonreth has no peers — this is expected at first, as reth cannot peer with geth nodes. Wait for reth-compatible peers to become available on the network. Make sure port ${OG_PORT}303 TCP/UDP is open in your firewall:
sudo ufw allow ${OG_PORT}303/tcp comment 'reth_p2p_port'
sudo ufw allow ${OG_PORT}303/udp comment 'reth_p2p_port'Import fails with mismatched block state root — this is a compatibility issue between 0G's geth and reth forks. Record the block number and state root from the log and report to the 0G development team. Do not skip the block manually.
Cleanup (Optional)
After confirming reth has been running stably for at least 24–48 hours, you can remove the temporary files:
# Remove the geth binary if no longer needed
# sudo rm /usr/local/bin/geth
# Remove the RLP export files (large)
rm -f $HOME/.0gchaind/chain-export.rlp
rm -f $HOME/.0gchaind/chain-export-from-*.rlp
# Remove the backup once you're confident
# rm -rf $BACKUP_DIR⚠️ Do not remove the backup until you are confident reth is running stably.
