Bitcoin ZeroMQ
Bitcoin Core's RPC interface is request-driven: a client asks, the node answers. That works for queries but not for reacting to events, since finding out about a new block means polling for it.
The ZeroMQ interface fills that gap. The node publishes a message whenever it validates a block or transaction, and any number of subscribers receive it without polling. Block explorers, wallets and monitoring dashboards are the usual consumers.
How it works
ZeroMQ is an asynchronous messaging library that needs no broker — the publisher and subscriber connect directly.
Properties worth knowing before building on it:
- Read-only and unauthenticated. There is no request path and no credentials. Anything that can reach the port receives everything published.
- Not a delivery guarantee. Messages arriving out of date, incomplete or duplicated is normal. Treat a notification as a hint to go and verify over RPC, not as a source of truth.
- Self-healing. Sockets reconnect automatically after an outage, and either end can start or stop in any order.
- Message-oriented. Each block or transaction arrives whole, so subscribers need no buffering or reassembly.
Because the feed is unauthenticated and unvalidated, never expose the ZMQ port beyond the machine or private network the subscriber runs on.
Requirements
Access to a Bitcoin Core node. ZeroMQ support is compiled in by default when the
prerequisites are present; --disable-zmq at configure time turns it off.
bitcoin.conf:
daemon=1
server=1
txindex=1
rpcthreads=10
regtest=1
[regtest]
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
rpcauth=rpcu5er:SALT$HASH
zmqpubrawblock=tcp://127.0.0.1:29000
zmqpubrawtx=tcp://127.0.0.1:29000
zmqpubhashtx=tcp://127.0.0.1:29000
zmqpubhashblock=tcp://127.0.0.1:29000
Notes on this file:
- The file is
bitcoin.conf. Bitcoin Core does not readbitcoin.config, and gives no warning about a file it was never looking for. rpcallowip=0.0.0.0/0accepts RPC from anywhere. Combined with a weak password that is remote control of the wallet. Bind to127.0.0.1and widen it only deliberately.rpcuserandrpcpasswordare deprecated in favour ofrpcauth, which stores a salted hash rather than the password. Generate a line with therpcauth.pyscript shipped in Bitcoin Core'sshare/directory. By default the node also writes a.cookiefile thatbitcoin-cliuses automatically, so no credentials are needed at all for local use.- Regtest defaults are RPC on 18443 and P2P on 18444; testnet uses 18332/18333, mainnet 8332/8333. Override them only if something else already holds the port — using mainnet's numbers on regtest works but guarantees confusion later.
- All four topics may share one port. ZeroMQ multiplexes by topic, and the subscriber chooses what it wants.
txindex=1is needed forgetrawtransactionto find transactions outside the wallet.
Notification topics
Publish/subscribe: the node publishes on events, and applications subscribe to what interests them.
| Topic | Payload |
|---|---|
hashblock | 32-byte block hash |
hashtx | 32-byte transaction hash |
rawblock | Serialised block |
rawtx | Serialised transaction |
bitcoind --help lists them:
bitcoind --help | grep zmq -A3 # -A shows N lines after each match
-zmqpubhashblock=<address>
Enable publish hash block in <address>
-zmqpubhashtx=<address>
Enable publish hash transaction in <address>
-zmqpubrawblock=<address>
Enable publish raw block in <address>
-zmqpubrawtx=<address>
Enable publish raw transaction in <address>
Setting debug=1 in bitcoin.conf during development makes the node log what
it publishes, which turns "is it the node or my client" into a one-line answer.
Enabling
getzmqnotifications reports which publishers are running:
# Git Bash on Windows; adjust the path form for cmd or PowerShell
BTC_DATA_DIR=/d/temp/bitcoin-data-test/chain-regtest
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf getzmqnotifications
[
]
An empty array means no zmqpub* entries are configured. Add them, then restart
— they are read only at startup.
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf stop
bitcoind -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf
bitcoin-qt -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf # with GUI
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf getzmqnotifications
[
{
"type": "pubhashblock",
"address": "tcp://127.0.0.1:29000"
},
{
"type": "pubhashtx",
"address": "tcp://127.0.0.1:29000"
},
{
"type": "pubrawblock",
"address": "tcp://127.0.0.1:29000"
},
{
"type": "pubrawtx",
"address": "tcp://127.0.0.1:29000"
}
]
A subscriber
Any language with ZeroMQ bindings works. JavaScript here.
mkdir bitcoin-zmq && cd bitcoin-zmq
npm init -y
npm install zeromq
Install zeromq, not zmq — the latter is a deprecated package that no longer
builds against current Node versions.
index.js:
const zmq = require("zeromq");
async function run() {
const sock = new zmq.Subscriber();
sock.connect("tcp://127.0.0.1:29000");
sock.subscribe("hashtx");
console.log("Subscriber connected to port 29000");
for await (const [topic, msg] of sock) {
console.log("topic:", topic, "message:", msg);
}
}
run();
subscribe is what actually starts the flow — connecting alone delivers
nothing, because a ZeroMQ subscriber with no subscription filters everything
out.
Run it:
node ./index.js
The process sits waiting. On regtest nothing happens until a block is generated, so from a second terminal:
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf -generate 1
{
"address": "bcrt1qatkaqfj0hfcugerl3pn7ydy8rg2vv533nnx0ul",
"blocks": [
"07b5b948259341607063f777152c93e35b6af524198630434aeedeecbdf20077"
]
}
The subscriber prints the coinbase transaction of the new block:
topic: <Buffer 68 61 73 68 74 78> message: <Buffer 62 6b 71 8b 24 37 9e 48 49 9a
d8 79 e0 35 bc 36 d1 8d 9a 65 78 ab 8d 78 f8 b8 2e dc ef e4 87 94>
Both parts arrive as binary Buffers. Decode them — the topic is ASCII, the
payload is bytes best read as hex:
for await (const [topic, msg] of sock) {
console.log("topic:", topic.toString(), "message:", msg.toString("hex"));
}
topic: hashtx message: b1d5e0b0782b66a805d91bd241d8f2b0a519eacb80a9fb34ad576a75384dc963
Subscribing to more than one topic is just more subscribe calls:
sock.subscribe("rawtx");
sock.subscribe("hashtx");
topic: hashtx message: 14fecd3257fe6fc47ee6e676b5f338c58b736d47ed45b6dc26e9ed1bb77894e1
topic: rawtx message: 0200000000010100000000000000000000000000000000000000000000
00000000000000000000ffffffff025700ffffffff0200f2052a0100000016001404589644ebb0a3
5cd53afb548f38f8c3a635103b0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999df
a36953755c690689799962b48bebd836974e8cf9012000000000000000000000000000000000000
0000000000000000000000000000000000000
Decoding raw transactions
The node will decode a raw transaction for you:
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf \
decoderawtransaction 0200000000010100000000000000000000000000000000000000000000000000000000000000ffffffff025700ffffffff0200f2052a0100000016001404589644ebb0a35cd53afb548f38f8c3a635103b0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000
{
"txid": "14fecd3257fe6fc47ee6e676b5f338c58b736d47ed45b6dc26e9ed1bb77894e1",
"hash": "1ec9b682c9f63b57c312704811e7b933540d4b4cdc8853ae9d0fca55593e26e5",
"version": 2,
"size": 167,
"vsize": 140,
"weight": 560,
"locktime": 0,
"vin": [
{
"coinbase": "5700",
"txinwitness": [
"0000000000000000000000000000000000000000000000000000000000000000"
],
"sequence": 4294967295
}
],
"vout": [
{
"value": 50.00000000,
"n": 0,
"scriptPubKey": {
"asm": "0 04589644ebb0a35cd53afb548f38f8c3a635103b",
"desc": "addr(bcrt1qq3vfv38tkz34e4f6ld2g7w8ccwnr2ypmjtgn8h)#lzw75pt5",
"hex": "001404589644ebb0a35cd53afb548f38f8c3a635103b",
"address": "bcrt1qq3vfv38tkz34e4f6ld2g7w8ccwnr2ypmjtgn8h",
"type": "witness_v0_keyhash"
}
},
{
"value": 0.00000000,
"n": 1,
"scriptPubKey": {
"asm": "OP_RETURN aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9",
"hex": "6a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9",
"type": "nulldata"
}
}
]
}
txid and hash differ here because the transaction has a witness: txid
excludes witness data, hash (the wtxid) includes it. The second output is the
SegWit commitment every coinbase transaction carries.
getrawtransaction TXID returns the same hex given a transaction id, which is
useful when you have a hashtx notification rather than a rawtx one.
Decoding from the client
Rather than shelling out to bitcoin-cli, call the RPC interface directly.
npm install bitcoind-rpc
const zmq = require("zeromq");
const RpcClient = require("bitcoind-rpc");
const config = {
protocol: "http",
user: "rpcu5er",
pass: "rpcpassw0rd",
host: "127.0.0.1",
port: "18443", // regtest RPC default
};
const rpc = new RpcClient(config);
async function run() {
const sock = new zmq.Subscriber();
sock.connect("tcp://127.0.0.1:29000");
sock.subscribe("rawtx");
sock.subscribe("hashtx");
console.log("Subscriber connected to port 29000");
for await (const [topic, msg] of sock) {
if (topic.toString() !== "rawtx") continue;
rpc.decodeRawTransaction(msg.toString("hex"), (err, resp) => {
if (err) return console.error(err);
console.log(JSON.stringify(resp.result, null, 4));
});
}
}
run();
The user and pass must match the node's configuration, host is where its
RPC server listens, and port is its RPC port — not the ZMQ port.
Filtering on the topic matters. Passing a hashtx payload to
decodeRawTransaction produces:
{
"result": null,
"error": { "code": -22, "message": "TX decode failed" },
"id": 3354
}
which is the node correctly refusing to parse a 32-byte hash as a transaction.
Exposing over WebSockets
Useful for a browser-based dashboard.
npm install ws bitcoinjs-lib
npm install -g wscat
const WebSocket = require("ws");
const bitcoinjs = require("bitcoinjs-lib");
const zmq = require("zeromq");
const wss = new WebSocket.Server({ port: 8090 });
async function run() {
const sock = new zmq.Subscriber();
sock.connect("tcp://127.0.0.1:29000");
sock.subscribe("rawtx");
sock.subscribe("hashtx");
console.log("Subscriber connected to port 29000");
for await (const [topic, msg] of sock) {
const payload =
topic.toString() === "rawtx"
? bitcoinjs.Transaction.fromHex(msg.toString("hex"))
: msg.toString("hex");
const frame = JSON.stringify(payload);
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
}
}
}
run();
Send to each client with ws.send. Calling ws.emit("message", ...) fires the
server's own listener for an inbound message rather than transmitting anything —
it appears to work only if a connection handler happens to echo back, and it
breaks as soon as that handler changes.
Watch it from the command line:
# Terminal 1
node ./index.js
# Terminal 2
wscat -c ws://localhost:8090
# Terminal 3
bitcoin-cli -datadir=$BTC_DATA_DIR -conf=$BTC_DATA_DIR/bitcoin.conf -generate 1
Terminal 2 then shows the hash and the decoded transaction:
< "e63467ba146194acc0adcb14b5dbd51e21ae3b0503b026ba3957907149666fe9"
< {"version":2,"locktime":0,"ins":[...],"outs":[{"value":5000000000,...}]}
Or from a browser:
const ws = new WebSocket("ws://localhost:8090");
ws.onmessage = (event) => console.log(JSON.parse(event.data));
Nothing here authenticates the WebSocket server, so keep it on localhost.
Auto-mining on regtest
On regtest a transaction stays in the mempool until a block is generated. Mining one automatically whenever a non-coinbase transaction arrives makes local testing feel like a live chain:
const tx = bitcoinjs.Transaction.fromHex(msg.toString("hex"));
if (!tx.isCoinbase()) {
const address = await getMiningAddress();
rpc.generateToAddress(1, address, console.log);
}
generate was removed from Bitcoin Core in 0.18 — generateToAddress replaced
it and requires an address, which you can get once at startup from
getnewaddress and reuse.
Guarding on isCoinbase() is what stops the loop: mining a block publishes its
coinbase transaction, which would otherwise trigger another block, and so on
indefinitely.
This page began as notes taken from a third-party Bitcoin Core ZeroMQ tutorial. The explanations have been rewritten; the console transcripts are output from running the commands shown.