Spinup a local devnet
This tutorial walks through spinning up a local Ethereum development network using Geth, deploying an ERC-20 smart contract to it, and connecting the Shinzo Generator client and Host so blockchain data is generated into DefraDB and queryable via GraphQL.
How it fits together
Here's the basic flow for what we're about to build:
Geth (--dev) → mines blocks on demand
↓
Shinzo Generator client → watches for new blocks via HTTP/WS RPC
↓
DefraDB (embedded in Generator client) → stores generated block/transaction/log data
↓ (P2P passive replication)
DefraDB (embedded in Host) → receives replicated data
↓
GraphQL API (port 9182) → query the replicated data
Prerequisites
- A cloud VM running Ubuntu 24.04 on amd64 (e.g. Hetzner CX22 or similar). arm64 won't work as the Generator and Host images are amd64-only.
- A GitHub account with access to the Shinzo container registry.
Geth
Install Geth
Add the official Ethereum PPA and install Geth:
sudo add-apt-repository -y ppa:ethereum/ethereum
sudo apt install -y geth
Verify the install:
geth version
Start Geth in dev mode
geth --dev \
--http \
--http.addr 0.0.0.0 \
--http.api eth,net,web3 \
--http.vhosts='*' \
--ws \
--ws.addr 0.0.0.0 \
--ws.api eth,net,web3 \
--verbosity 3
Things to note from the output:
- Geth logs a pre-funded developer account address and private key. Save these; you'll need them later.
- HTTP RPC listens on
0.0.0.0:8545. - WebSocket listens on
0.0.0.0:8546. - The chain only mines a block when a transaction is pending, so it won't produce empty blocks.
Verify Geth is running
In a second terminal, check the chain is responding:
curl -s -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
Expected response: {"jsonrpc":"2.0","id":1,"result":"0x0"}
0x0 means the chain is at block zero. That's correct for a fresh devnet.
Attach the Geth console and test
Attach to the running node:
geth attach http://localhost:8545
Check your dev account and balance:
eth.accounts
eth.getBalance(eth.accounts[0])
The balance will be an enormous number. That's the pre-funded dev account balance in wei, essentially unlimited play money.
Send a test transaction to trigger block mining:
eth.sendTransaction({from: eth.accounts[0], to: eth.accounts[0], value: 1})
Confirm the block number advanced:
eth.blockNumber
Generator client
Install Docker
Install Docker and add your user to the docker group. Verify with:
docker --version
Authenticate with GitHub Container Registry
Generate a GitHub Personal Access Token with the read:packages scope, then log in:
echo YOUR_PAT | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
Pull the Shinzo Generator image
Images are published per chain rather than as a single standard tag. For Ethereum:
docker pull ghcr.io/shinzonetwork/shinzo-generator-client:ethereum-mainnet-latest
Run the Generator client
Get your VM's public IP:
hostname -I
Use the first IP in the output. Then run the Generator client, pointing it at your Geth node. Three ports are exposed: 9181 (DefraDB GraphQL API), 9171 (libp2p, for the Host to connect over), and 8080 (health/metrics/registration):
docker run --rm \
-e GETH_RPC_URL=http://YOUR_VM_IP:8545 \
-e GETH_WS_URL=ws://YOUR_VM_IP:8546 \
-e GETH_API_KEY="" \
-e INDEXER_START_HEIGHT=0 \
-e DEFRADB_KEYRING_SECRET=devnet-secret \
-e DEFRADB_P2P_ENABLED=true \
-e DEFRADB_P2P_LISTEN_ADDR=/ip4/0.0.0.0/tcp/9171 \
-e LOGGER_DEBUG=true \
-p 9181:9181 \
-p 9171:9171 \
-p 8080:8080 \
ghcr.io/shinzonetwork/shinzo-generator-client:ethereum-mainnet-latest
INDEXER_START_HEIGHT keeps its old name deliberately: like the GETH_* variables, it's a historical env var name that wasn't renamed when the client itself became the "Generator client." DEFRADB_P2P_LISTEN_ADDR tells DefraDB which interface and port to bind libp2p to inside the container; binding to 0.0.0.0:9171 means the Host container, on the same Docker bridge, can reach it.
You should see the Generator client connect to Geth, generate block 0, and then wait for new blocks.
Get the Generator client's Peer ID
You'll need this to connect the Host. The Peer ID and dialable address both come from the Generator client's /health endpoint once it's finished starting up:
curl -s http://localhost:8080/health | jq '.p2p.self'
{
"id": "12D3KooWK8zmiDmX91PwDV1PsqtgA1UUDuuyipVBVPEjrvwgoFJH",
"addresses": [
"/ip4/127.0.0.1/tcp/9171",
"/ip4/172.17.0.2/tcp/9171"
],
"public_key": "8a7f061eeaaec8b8130ce4b9d6e519bbe76b9a4bc038b7e6743a773ad3915e02"
}
id is the Generator client's libp2p Peer ID, stable across restarts as long as the keyring secret stays the same. Of the addresses, ignore the 127.0.0.1 one. It's loopback and useless to other containers. Use the other one (the Generator client container's IP on the Docker bridge), which is what the Host will dial.
Capture both into shell variables:
PEER_ID=$(curl -s http://localhost:8080/health | jq -r '.p2p.self.id')
GENERATOR_IP=$(curl -s http://localhost:8080/health \
| jq -r '[.p2p.self.addresses[] | capture("/ip4/(?<ip>[0-9.]+)/").ip
| select(. != "127.0.0.1" and . != "0.0.0.0")][0]')
BOOTSTRAP_PEER="/ip4/${GENERATOR_IP}/tcp/9171/p2p/${PEER_ID}"
echo "$BOOTSTRAP_PEER"
Verify data in DefraDB
First check what query fields are available:
curl -s -X POST http://localhost:9181/api/v0/graphql \
-H "Content-Type: application/json" \
--data '{"query":"{ __schema { queryType { fields { name } } } }"}'
Then query the generated blocks:
curl -s -X POST http://localhost:9181/api/v0/graphql \
-H "Content-Type: application/json" \
--data '{"query":"{ Ethereum__Mainnet__Block { hash number } }"}'
You should see block 0 and block 1 returned with their hashes.
Smart contract
Install Foundry
Foundry provides the forge and cast CLI tools for compiling and deploying smart contracts.
curl -L https://foundry.paradigm.xyz | bash
source ~/.bashrc
Verify the install:
forge --version
Deploy an ERC-20 token contract
Create a new Foundry project:
forge init shinzo-token && cd shinzo-token
Install the OpenZeppelin contracts library:
forge install OpenZeppelin/openzeppelin-contracts
Create the token contract:
cat > src/ShinzoToken.sol << 'EOF'
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ShinzoToken is ERC20 {
constructor() ERC20("ShinzoToken", "SHINZO") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
}
EOF
Set up the OpenZeppelin import remapping:
cat > remappings.txt << 'EOF'
@openzeppelin/=lib/openzeppelin-contracts/
EOF
Compile the contract:
forge build
Deploy it to the devnet, replacing YOUR_PRIVATE_KEY with the key Geth logged on startup:
forge create src/ShinzoToken.sol:ShinzoToken \
--rpc-url http://localhost:8545 \
--private-key YOUR_PRIVATE_KEY \
--broadcast
Note down the Deployed to address from the output; you'll need it in the next step.
Send a token transfer
Use cast to call the transfer function on the deployed contract. This emits a Transfer event which the Generator client will capture:
cast send YOUR_CONTRACT_ADDRESS \
"transfer(address,uint256)" \
YOUR_DEV_ACCOUNT_ADDRESS \
1000 \
--rpc-url http://localhost:8545 \
--private-key YOUR_PRIVATE_KEY
Watch the Generator client terminal. You should see it pick up the new block and commit it to DefraDB.
Query the generated data
Query all generated transactions:
curl -s -X POST http://localhost:9181/api/v0/graphql \
-H "Content-Type: application/json" \
--data '{"query":"{ Ethereum__Mainnet__Transaction { hash blockNumber } }"}'
Query the generated logs to see the Transfer events:
curl -s -X POST http://localhost:9181/api/v0/graphql \
-H "Content-Type: application/json" \
--data '{"query":"{ Ethereum__Mainnet__Log { address topics blockNumber } }"}'
You should see two Transfer events: one from the contract deployment (tokens minted from the zero address to your dev account) and one from the token transfer. The first topic on both (0xddf252ad...) is the keccak256 hash of Transfer(address,address,uint256), the standard ERC-20 Transfer event signature, identical across every Ethereum network.
Host
Pull the Shinzo Host image
Like the Generator, Host images are published per chain:
docker pull ghcr.io/shinzonetwork/shinzo-host-client:ethereum-mainnet-latest
Create the Host config
The Host's config format has been simplified: most of the manual tuning fields (batch processing, pruner, snapshot) are no longer part of the base setup. Create the config file:
cat > ~/host-config.yaml << 'EOF'
defradb:
url: "localhost:9181"
keyring_secret: "host-devnet-secret"
p2p:
enabled: true
bootstrap_peers:
- '${BOOTSTRAP_PEER}'
listen_addr: "/ip4/0.0.0.0/tcp/9171"
enable_auto_reconnect: true
store:
path: "./.defra"
shinzo:
hub_base_url: testnet.shinzo.network:26657
minimum_attestations: 1
start_height: 0
logger:
development: true
level: "info"
host:
lens_registry_path: "./.defra/lens"
health_server_port: 8080
EOF
A few notes on this:
defradb.url: localhost:9181is the Host's internal DefraDB API, not the Generator's. Inside the Host container, DefraDB binds to9181onlocalhost. The Generator's API happens to use the same port number because they're both DefraDB; the published ports are remapped in the next step so they don't collide on the host machine.bootstrap_peersuses the$BOOTSTRAP_PEERvalue you built in the Generator section above. The Host learns everything else (schemas, signed data) from the Generator over P2P once it connects.minimum_attestations: 1means the Host will serve data as soon as it has one signature on it. Production setups use higher values to require independent confirmation from multiple Generator clients.hub_base_urlpoints at ShinzoHub's testnet endpoint. This devnet setup isn't registering with the Hub, but the Host expects the field to be present.
Run the Host
The Generator is already using 9181, 9171, and 8080 on the host machine, so the Host container's ports get bumped by one:
docker run -d \
--name shinzo-host \
-e BOOTSTRAP_PEERS="$BOOTSTRAP_PEER" \
-v ~/host-config.yaml:/app/config.yaml:ro \
-p 9182:9181 \
-p 9172:9171 \
-p 8081:8080 \
ghcr.io/shinzonetwork/shinzo-host-client:ethereum-mainnet-latest
BOOTSTRAP_PEERS is an override; the same value is already in the config file, but some Host builds also read the env var, so it's set in both places.
Verify the peering
Check the Host's /health for the Generator's Peer ID:
curl -s http://localhost:8081/health | jq '{status, current_block, p2p: {self: .p2p.self.id, peers: [.p2p.peers[].id]}}'
The peers array should contain the PEER_ID value from the Generator section. You can cross-check from the Generator's side too; its peer list should now contain the Host:
curl -s http://localhost:8080/health | jq '[.p2p.peers[].id]'
If both list each other, libp2p is connected and DefraDB is replicating between them.
Verify data is replicating
Send a transaction from the Geth console to produce a new block:
eth.sendTransaction({from: eth.accounts[0], to: eth.accounts[0], value: 1})
Once current_block is non-zero on the Host's /health, query its GraphQL API on the remapped port 9182:
curl -s -X POST http://localhost:9182/api/v0/graphql \
-H "Content-Type: application/json" \
--data '{"query":"{ Ethereum__Mainnet__Block { hash number } }"}' | jq
You should see the new block returned. Passive replication only syncs new documents created after the P2P connection was established, so historical blocks won't appear in the Host's DefraDB.