Set up a localnet
Goal
This article will guide you to set up a localnet that serves as a sandbox for developing Hathor nodes, wallets, blueprints, nano contracts, DApps, and integrations with Hathor Network.
The localnet is a sandboxed local instance of Hathor system, fully isolated from public Hathor Network mainnet and testnet, and comprises five interconnected autonomous components:
- Full node (Hathor core)
- Mining server (Transaction mining service)
- CPU miner
- Headless wallet
This guide uses only Docker Compose to set up the localnet — sufficient for DApps, wallets, integrations, and blueprints. To develop Hathor core itself, prefer running from source; we will cover, in an optional last step, how to connect a source-built full node to this localnet.
Requirements
- Platform: Linux, macOS, or WSL
- Docker v27.3.1
- Docker Compose v2.29.7
- Node.js v20.0.0
- Available ports (not in use): 3000, 8000, 8080, 9000, 9080, 40403
Step-by-step
- Install localnet.
- Test localnet.
- Test blueprint deployment.
- Test nano contracts.
- Test end-user experience.
Step 1: install localnet
- Open a terminal.
- Change the working directory to where you want to install the localnet — namely, configuration files and database.
- Create the directory
data, to store the full node's database (mainly the ledger). - Create the file
docker-compose.ymlwith the configuration of your localnet:
networks:
hathor:
driver: bridge
name: hathor
services:
full-node:
image: hathornetwork/hathor-core
ports:
- "8080:8080"
- "40403:40403"
volumes:
- ${PWD}/data:/data
- ${PWD}/localnet.yml:/app/localnet.yml:ro
environment:
- HATHOR_CONFIG_YAML=/app/localnet.yml
- HATHOR_DATA=/data
- HATHOR_LISTEN=tcp:40403
- HATHOR_STATUS=8080
- HATHOR_WALLET_INDEX=true
- HATHOR_NC_INDEXES=true
- HATHOR_CACHE=true
- HATHOR_CACHE_SIZE=100000
- HATHOR_ALLOW_MINING_WITHOUT_PEERS=true
networks:
hathor:
aliases:
- full-node
command: run_node
healthcheck:
test: ["CMD", "sh", "-c", "timeout 1 bash -c 'echo > /dev/tcp/localhost/8080'"]
start_period: 2s
timeout: 2s
retries: 10
interval: 2s
mining-server:
image: hathornetwork/tx-mining-service
depends_on:
full-node:
condition: service_healthy
ports:
- "9000:9000"
- "9080:9080"
networks:
hathor:
aliases:
- mining-server
command: >
--testnet
--address WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS
--stratum-port 9000
--api-port 9080
http://full-node:8080
healthcheck:
test: ["CMD", "sh", "-c", "timeout 1 nc -z 0.0.0.0 9000 && timeout 1 nc -z 0.0.0.0 9080"]
start_period: 2s
timeout: 2s
retries: 10
interval: 2s
cpu-miner:
image: hathornetwork/cpuminer
depends_on:
mining-server:
condition: service_healthy
networks:
hathor:
aliases:
- cpu-miner
entrypoint: ["/bin/sh", "-c"]
command: >
"minerd \
--algo sha256d \
--threads 1 \
--coinbase-addr WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS \
--url stratum+tcp://mining-server:9000 \
2>&1 | tee /tmp/miner.log"
healthcheck:
test: ["CMD", "sh", "-c", "grep -q 'hashes' /tmp/miner.log"]
start_period: 2s
timeout: 2s
retries: 10
interval: 2s
deploy:
resources:
limits:
cpus: '0.1'
wallet:
image: hathornetwork/hathor-wallet-headless
depends_on:
cpu-miner:
condition: service_healthy
ports:
- "8000:8000"
environment:
- HEADLESS_NETWORK=privatenet
- HEADLESS_SEEDS=genesis alice
- HEADLESS_SEED_GENESIS=avocado spot town typical traffic vault danger century property shallow divorce festival spend attack anchor afford rotate green audit adjust fade wagon depart level
- HEADLESS_SEED_ALICE=music endless reduce plunge accident multiply two curtain match balance present belt price burger mother crisp sock tumble napkin leopard unit upset original cause
- HEADLESS_SERVER=http://full-node:8080/v1a/
- HEADLESS_TX_MINING_URL=http://mining-server:9080
- HEADLESS_HTTP_PORT=8000
- HEADLESS_CONSOLE_LEVEL=debug
- HEADLESS_HISTORY_SYNC_MODE=polling_http_api
networks:
hathor:
aliases:
- wallet
healthcheck:
test: ["CMD", "sh", "-c", "timeout 1 nc -z 0.0.0.0 8000"]
start_period: 2s
timeout: 2s
retries: 10
interval: 2s
- Create the file
localnet.ymlto complement the full node configuration:
P2PKH_VERSION_BYTE: x49
MULTISIG_VERSION_BYTE: x87
NETWORK_NAME: privatenet
BOOTSTRAP_DNS: []
# Ledger genesis
GENESIS_OUTPUT_SCRIPT: 76a91466665b27f7dbc4c8c089d2f686c170c74d66f0b588ac
GENESIS_BLOCK_TIMESTAMP: 1643902665
GENESIS_BLOCK_NONCE: 4784939
GENESIS_BLOCK_HASH: 00000334a21fbb58b4db8d7ff282d018e03e2977abd3004cf378fb1d677c3967
GENESIS_TX1_NONCE: 0
GENESIS_TX1_HASH: 54165cef1fd4cf2240d702b8383c307c822c16ca407f78014bdefa189a7571c2
GENESIS_TX2_NONCE: 0
GENESIS_TX2_HASH: 039906854ce6309b3180945f2a23deb9edff369753f7082e19053f5ac11bfbae
# Genesis wallet:
# avocado spot town typical traffic vault danger century property shallow divorce festival spend attack anchor afford rotate green audit adjust fade wagon depart level
MIN_TX_WEIGHT_K: 0
MIN_TX_WEIGHT_COEFFICIENT: 0
MIN_TX_WEIGHT: 1
REWARD_SPEND_MIN_BLOCKS: 1
CHECKPOINTS: []
ENABLE_NANO_CONTRACTS: 'enabled'
NC_ON_CHAIN_BLUEPRINT_RESTRICTED: False
extends: testnet.yml
- Make sure you have the latest Docker images for all services:
cd ..
docker compose pull
- Start the five services that make up your localnet:
docker compose up
Wait a few seconds while all five services initialize. Keep this terminal open to monitor their logs. This will allow you to confirm that your localnet is functioning properly and to identify errors in the software you are developing.
- Open a new terminal.
- Change the working directory to where you installed the localnet.
- Confirm that all five services have started correctly:
docker compose ps
You should see five running containers and their STATUS should be marked as (healthy). Note that this health check only confirms that the applications have been initialized and are running. It does not detect behavioral failures, nor does it serve as continuous monitoring.
Finally, by checking the logs in the other terminal(s), you can confirm that your localnet is ready for use. After a few seconds, you should see the CPU miner hashing, the mining server finding and submitting blocks to the full node, and the full node adding new blocks to the ledger.
Step 2: test localnet
Now, you need to test whether the wallets are interacting correctly with the blockchain and whether transactions are being processed properly. There are two pre-configured wallets in localnet: Genesis (genesis) and Alice (alice). Use the available terminal — i.e., the one that is not receiving the localnet logs — to perform the following procedure:
- Start Genesis wallet:
curl -X POST \
-H "Content-Type: application/json" \
-d '{
"wallet-id": "genesis",
"seedKey": "genesis"
}' \
http://localhost:8000/start | jq
- Check Genesis' balance:
curl -X GET \
-H "X-Wallet-Id: genesis" \
http://localhost:8000/wallet/balance | jq
Genesis wallet is configured with an initial balance of 1B HTR tokens when running the entire localnet via Docker. On the other hand, it starts with a zero balance when running the full node from source code.
- Start Alice wallet:
curl -X POST \
-H "Content-Type: application/json" \
-d '{
"wallet-id": "alice",
"seedKey": "alice"
}' \
http://localhost:8000/start | jq
- Check Alice's balance:
curl -X GET \
-H "X-Wallet-Id: alice" \
http://localhost:8000/wallet/balance | jq
Alice wallet is configured to receive block mining rewards in HTR. You will need Alice’s funds (and Genesis’ if available) to test the localnet and, afterward, your software.
- Use Alice wallet to create a token:
RESP=$(curl -s -X POST \
-H "X-Wallet-Id: alice" \
-H "Content-type: application/json" \
-d '{
"name": "alice-coin",
"symbol": "ALICE",
"amount": 1000
}' \
http://localhost:8000/wallet/create-token)
TOKEN_UID=$(echo "$RESP" | jq -r '.hash');
echo "Token UID: $TOKEN_UID"
A token is identified by its token UID, which is the hash of the transaction that created it.
The last command stores the token UID in the variable TOKEN_UID to be used in subsequent commands.
You can also see the full API response by inspecting the variable RESP (echo "$RESP").
This is an example of a response, where the token UID is 00000943573723a28e3dd980c10e08419d0e00bc647a95f4ca9671ebea7d5669:
{
"success": true,
"configurationString": "[alice-coin:ALICE:00000943573723a28e3dd980c10e08419d0e00bc647a95f4ca9671ebea7d5669:f57c591a]",
"inputs": [ ... ],
"outputs": [ ... ],
"signalBits": 0,
"version": 2,
"weight": 19.362482584834382,
"nonce": 2073499,
"timestamp": 1733336251,
"parents": [ ... ],
"tokens": [],
"hash": "00000943573723a28e3dd980c10e08419d0e00bc647a95f4ca9671ebea7d5669",
"_dataToSignCache": { ... },
"name": "alice-coin",
"symbol": "ALICE"
}
- Send an amount of the created token from Alice to Genesis:
RESP=$(jq -n --arg token "$TOKEN_UID" '
{
address: "WRTFYzhTHkfYwub8EWVtAcUgbdUpsYMBpb",
value: 100,
token: $token
}' \
| curl -s -X POST \
-H "X-Wallet-Id: alice" \
-H "Content-Type: application/json" \
-d @- \
http://localhost:8000/wallet/simple-send-tx)
TX_ID=$(echo "$RESP" | jq -r '.hash');
echo "Transaction ID: $TX_ID"
- Confirm that Genesis received the transfer from Alice:
curl -X GET \
-H "X-Wallet-Id: genesis" \
"http://localhost:8000/wallet/balance?token=${TOKEN_UID}" | jq
Step 3: test blueprint deployment
In this step, we will be deploying a blueprint to the localnet. We will use the bet blueprint as an example.
- Create a new blueprint by copying the bet blueprint:
# Copyright 2025 Hathor Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional, TypeAlias
from hathor import (
Address,
Blueprint,
Context,
NCAction,
NCDepositAction,
NCFail,
NCWithdrawalAction,
SignedData,
Timestamp,
TokenUid,
TxOutputScript,
export,
public,
view,
)
Result: TypeAlias = str
Amount: TypeAlias = int
class InvalidToken(NCFail):
pass
class ResultAlreadySet(NCFail):
pass
class ResultNotAvailable(NCFail):
pass
class TooManyActions(NCFail):
pass
class TooLate(NCFail):
pass
class InsufficientBalance(NCFail):
pass
class InvalidOracleSignature(NCFail):
pass
@export
class Bet(Blueprint):
"""Bet blueprint with final result provided by an oracle.
The life cycle of contracts using this blueprint is the following:
1. [Owner ] Create a contract.
2. [User 1] `bet(...)` on result A.
3. [User 2] `bet(...)` on result A.
4. [User 3] `bet(...)` on result B.
5. [Oracle] `set_result(...)` as result A.
6. [User 1] `withdraw(...)`
7. [User 2] `withdraw(...)`
Notice that, in the example above, users 1 and 2 won.
"""
# Total bets per result.
bets_total: dict[Result, Amount]
# Total bets per (result, address).
bets_address: dict[tuple[Result, Address], Amount]
# Bets grouped by address.
address_details: dict[Address, dict[Result, Amount]]
# Amount that has already been withdrawn per address.
withdrawals: dict[Address, Amount]
# Total bets.
total: Amount
# Final result.
final_result: Optional[Result]
# Oracle script to set the final result.
oracle_script: TxOutputScript
# Maximum timestamp to make a bet.
date_last_bet: Timestamp
# Token for this bet.
token_uid: TokenUid
@public
def initialize(self, ctx: Context, oracle_script: TxOutputScript, token_uid: TokenUid,
date_last_bet: Timestamp) -> None:
if len(ctx.actions) != 0:
raise NCFail('must be a single call')
self.bets_total = {}
self.bets_address = {}
self.address_details = {}
self.withdrawals = {}
self.oracle_script = oracle_script
self.token_uid = token_uid
self.date_last_bet = date_last_bet
self.final_result = None
self.total = Amount(0)
@view
def has_result(self) -> bool:
"""Return True if the final result has already been set."""
return bool(self.final_result is not None)
def fail_if_result_is_available(self) -> None:
"""Fail the execution if the final result has already been set."""
if self.has_result():
raise ResultAlreadySet
def fail_if_result_is_not_available(self) -> None:
"""Fail the execution if the final result is not available yet."""
if not self.has_result():
raise ResultNotAvailable
def fail_if_invalid_token(self, action: NCAction) -> None:
"""Fail the execution if the token is invalid."""
if action.token_uid != self.token_uid:
token1 = self.token_uid.hex() if self.token_uid else None
token2 = action.token_uid.hex() if action.token_uid else None
raise InvalidToken(f'invalid token ({token1} != {token2})')
def _get_action(self, ctx: Context) -> NCAction:
"""Return the only action available; fails otherwise."""
if len(ctx.actions) != 1:
raise TooManyActions('only one token supported')
if self.token_uid not in ctx.actions:
raise InvalidToken(f'token different from {self.token_uid.hex()}')
return ctx.get_single_action(self.token_uid)
@public(allow_deposit=True)
def bet(self, ctx: Context, address: Address, score: str) -> None:
"""Make a bet."""
action = self._get_action(ctx)
assert isinstance(action, NCDepositAction)
self.fail_if_result_is_available()
self.fail_if_invalid_token(action)
if ctx.block.timestamp > self.date_last_bet:
raise TooLate(f'cannot place bets after {self.date_last_bet}')
amount = Amount(action.amount)
self.total = Amount(self.total + amount)
if score not in self.bets_total:
self.bets_total[score] = amount
else:
self.bets_total[score] += amount
key = (score, address)
if key not in self.bets_address:
self.bets_address[key] = amount
else:
self.bets_address[key] += amount
# Update dict indexed by address
if address not in self.address_details:
self.address_details[address] = {}
self.address_details[address][score] = self.bets_address[key]
@public
def set_result(self, ctx: Context, result: SignedData[Result]) -> None:
"""Set final result. This method is called by the oracle."""
self.fail_if_result_is_available()
if not result.checksig(self.syscall.get_contract_id(), self.oracle_script):
raise InvalidOracleSignature
self.final_result = result.data
@public(allow_withdrawal=True)
def withdraw(self, ctx: Context) -> None:
"""Withdraw tokens after the final result is set."""
action = self._get_action(ctx)
assert isinstance(action, NCWithdrawalAction)
self.fail_if_result_is_not_available()
self.fail_if_invalid_token(action)
caller_address = ctx.get_caller_address()
assert caller_address is not None
address = Address(caller_address)
allowed = self.get_max_withdrawal(address)
if action.amount > allowed:
raise InsufficientBalance(f'withdrawal amount is greater than available (max: {allowed})')
if address not in self.withdrawals:
self.withdrawals[address] = action.amount
else:
self.withdrawals[address] += action.amount
@view
def get_max_withdrawal(self, address: Address) -> Amount:
"""Return the maximum amount available for withdrawal."""
total = self.get_winner_amount(address)
withdrawals = self.withdrawals.get(address, Amount(0))
return total - withdrawals
@view
def get_winner_amount(self, address: Address) -> Amount:
"""Return how much an address has won."""
self.fail_if_result_is_not_available()
if self.final_result not in self.bets_total:
return Amount(0)
result_total = self.bets_total[self.final_result]
if result_total == 0:
return Amount(0)
address_total = self.bets_address.get((self.final_result, address), 0)
winner_amount = Amount(address_total * self.total // result_total)
return winner_amount
- Load the blueprint code into a variable, escaping it for JSON:
ESCAPED_CODE=$(jq -Rs . < bet_blueprint.py)
- Use Alice wallet to deploy the blueprint:
RESP=$(
jq -n \
--argjson code "$ESCAPED_CODE" \
--arg address "WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS" \
'{code: $code, address: $address}' \
| curl -s -X POST \
-H "X-Wallet-Id: alice" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d @- \
http://localhost:8000/wallet/nano-contracts/create-on-chain-blueprint
)
BLUEPRINT_ID=$(echo "$RESP" | jq -r '.hash')
echo "Blueprint ID: $BLUEPRINT_ID"
Like the token UID, the blueprint ID is the hash of the transaction that deployed the blueprint.
The last command stores the blueprint ID in the variable BLUEPRINT_ID to be used in subsequent commands.
You can also see the full API response by inspecting the variable RESP (echo "$RESP").
Step 4: test nano contracts
- Use Alice wallet to create a nano contract from the
bet blueprint.
At least one block should be mined after deploying the blueprint to ensure the blueprint is available for creating the contract. Waiting a few seconds should be enough to guarantee this.
RESP=$(
jq -n \
--arg blueprint_id "$BLUEPRINT_ID" \
--arg address "WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS" \
--arg oracle "0000298f16599418b0475762c9ce570fe966fd8a62fd933888a96a16c0b893b7" \
--arg token "$TOKEN_UID" \
--argjson date_last_bet 1891123200 \
'{
blueprint_id: $blueprint_id,
address: $address,
data: {
actions: [],
args: [$oracle, $token, $date_last_bet]
}
}' \
| curl -s -X POST \
-H "X-Wallet-Id: alice" \
-H "Content-Type: application/json" \
-d @- \
http://localhost:8000/wallet/nano-contracts/create
)
CONTRACT_ID=$(echo "$RESP" | jq -r '.hash')
echo "Contract ID: $CONTRACT_ID"
In this request we send three arguments to initialize the contract:
- Oracle address:
0000298f16599418b0475762c9ce570fe966fd8a62fd933888a96a16c0b893b7 - Token UID:
${TOKEN_UID}(the token created in Step 2) - Date last bet:
1891123200(Unix timestamp for 2029-01-01 00:00:00)
These arguments correspond to the initialize method of the Bet blueprint. They will vary depending on the blueprint you are using.
For this blueprint specifically, the Oracle address defines who can set the final result of the bet. The token UID defines which token will be used for betting. The date last bet defines the deadline for placing bets.
Analogous to the token UID, the contract ID is the hash of the transaction that created the contract.
The last command stores the contract ID in the variable CONTRACT_ID to be used in subsequent commands.
You can also see the full API response by inspecting the variable RESP (echo "$RESP").
- Use Alice wallet to execute the contract:
RESP=$(jq -n \
--arg nc_id "$CONTRACT_ID" \
--arg method "bet" \
--arg address "WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS" \
--arg token "$TOKEN_UID" \
--arg bet_address "WiGFcSYHhfRqWJ7PXYvhjULXtXCYD1VFdS" \
--arg score "Real-Madrid2x2Barcelona" \
'{
nc_id: $nc_id,
method: $method,
address: $address,
data: {
actions: [
{
type: "deposit",
token: $token,
amount: "100"
}
],
args: [$bet_address, $score]
}
}' \
| curl -s -X POST \
-H "X-Wallet-Id: alice" \
-H "Content-Type: application/json" \
-d @- \
http://localhost:8000/wallet/nano-contracts/execute)
TX_ID=$(echo "$RESP" | jq -r '.hash')
echo "Contract Execution Tx ID: $TX_ID"
The contract history lists all transactions that executed the contract. Transactions whose contract execution was unsuccessful will be marked as voided.
- Check the contract history to verify whether the contract execution in the previous step was successful:
curl -X GET \
-H "X-Wallet-Id: alice" \
"http://localhost:8000/wallet/nano-contracts/history?id=${CONTRACT_ID}" | jq
Step 5: test end-user experience
This step is specific to developing DApps.
To test your DApp's user experience, you need to add Hathor desktop wallet to your local development environment:
- Install Hathor desktop wallet.
- Start Hathor desktop wallet.
- Initialize Alice wallet by importing its seed:
music endless reduce plunge accident multiply two curtain match balance present belt price burger mother crisp sock tumble napkin leopard unit upset original cause
- Connect Hathor desktop wallet to your localnet:
- Navigate to Settings.
- Click Change network to open Network Settings.
- In the dropdown menu, select Custom network.
- Set Node URL to
http://localhost:8080/v1a/. - Set Transaction Mining Service URL to
http://localhost:9080. - Leave all other fields blank.
- Click Connect to save configuration.
Optional: run Hathor core from source and connect to this localnet
Use this if you need to develop or debug Hathor core itself while keeping the rest of the localnet in Docker.
- Install Hathor core from source code. You can skip the step to start the full node, as we will do it in the next steps.
- Open a terminal.
- Change the working directory to the cloned
hathor-corerepository. - Ensure the directory
dataexists inside it and is empty. - Copy the
localnet.ymlcreated earlier to thehathor-coredirectory; no changes are needed. - Start the full node from source using that config (bind to different ports to avoid conflicts):
HATHOR_CONFIG_YAML=./localnet.yml \
poetry run hathor-cli run_node \
--testnet \
--data ./data \
--listen tcp:40405 \
--status 8081 \
--wallet-index \
--nc-indexes \
--allow-mining-without-peers \
--bootstrap tcp://localhost:40403
Keep this terminal open to monitor the full node's output log.
The new full node should connect to the network and sync the blockchain. After this, you can make any changes to the source code, restart the full node, and test it against the rest of the localnet running in Docker.
Task completed
You now have a fully functional Hathor localnet in your development environment. The full node exposes its HTTP API on port 8080. To monitor its status, open a browser and navigate to http://localhost:8080/v1a/status/. The headless wallet exposes its HTTP API on port 8000. Note that only the main features of Hathor technology have been tested. For how to test other features, see Hathor headless wallet pathway.
Finally, you may need to integrate other Docker containerized applications with the localnet. To do this, simply add these containers to Docker network hathor defined in docker-compose.yml. For example:
docker network connect hathor <other_docker_container>
Then, you can reference Hathor localnet components by their service names as defined in docker-compose.yml — e.g., http://wallet:8000.