Typed Odin bindings for a Bitcoin Core node, over both of its channels:
- RPC — complete. Named, typed wrappers for the entire JSON-RPC surface of Bitcoin Core 30.x (149 methods: 93 node-level + 56 wallet), plus a dynamic escape hatch for anything you'd rather call by hand.
- ZMQ — complete. A typed notification stream for all five topics
(
hashblock,hashtx,rawblock,rawtx,sequence), each event carrying its per-topic sequence counter for gap detection.
It is pure Bitcoin domain knowledge: all wire/transport/execution work (HTTP, JSON-RPC
correlation, TLS, ZeroMQ/ZMTP) is reused from
netclients, pulled in as a nested git
submodule. btcutils writes no socket, TLS, framing, or HTTP code.
The intended pattern is two channels working together: RPC to ask, ZMQ to be told. ZMQ is a low-latency hint; authoritative state is always confirmed via RPC.
- Odin
dev-2026-06(uses the redesignedcore:os). - OpenSSL 3.x (inherited from netclients; needed even for a plain-
http://local node because the engine links it):- macOS:
brew install openssl@3 - Linux:
libssl-dev(apt install libssl-dev) - Windows: an OpenSSL install (import libs + DLLs on
PATH)
- macOS:
btcutils has no package manager (Odin has none); use a git submodule. Because btcutils
itself vendors netclients as a nested submodule, clone recursively.
# from your project root
git submodule add https://github.com/Galaxoid-Labs/btcutils btcutils
git submodule update --init --recursive # pulls btcutils AND its nested netclientsIf you (or a CI job) cloned without --recursive, backfill the nested submodule with the same
git submodule update --init --recursive.
btcutils is self-contained: it imports its netclients engine by relative path, so
whatever resolves the btcutils package automatically finds netclients beneath it. With
btcutils vendored under your project, a plain relative import and a plain build are enough:
import btc "btcutils" // relative to the importing file's directoryodin build . # no -collection flagsOdin resolves a relative import like
btcutilsfrom the directory of the file doing the import (not the project root), and a package's own relative imports resolve from its directory — which is why the nestednetclientsis found with no configuration.
import btc "btcutils"
// Cookie auth (what most nodes use): reads <datadir>/<net>/.cookie automatically.
c := btc.btc_connect_cookie("/path/to/datadir", .Mainnet) or_return
defer btc.btc_destroy(&c)
// …or explicit rpcuser/rpcpassword:
// c := btc.btc_connect("user", "pass", .Mainnet) or_return
// …or a full URL you assembled yourself:
// c := btc.btc_connect_url("http://__cookie__:pass@127.0.0.1:8332/") or_returnNetworks: .Mainnet (8332), .Testnet (18332), .Testnet4 (48332), .Signet (38332),
.Regtest (18443). Host/port default to 127.0.0.1 and the network's default; override with
the host/port args.
count := btc.getblockcount(&c) or_return
hash := btc.getblockhash(&c, count) or_return
defer delete(hash)
info := btc.getblockchaininfo(&c) or_return
defer btc.blockchain_info_destroy(&info)
fmt.printfln("chain=%s height=%d ibd=%v", info.chain, info.blocks, info.initial_block_download)
blk := btc.getblock(&c, hash) or_return
defer btc.block_destroy(&blk)
fmt.printfln("tip has %d txs", blk.n_tx)
tx := btc.getrawtransaction_verbose2(&c, blk.tx[1]) or_return // needs -txindex
defer btc.tx_destroy(&tx)
fmt.printfln("vin=%d vout=%d fee=%v", len(tx.vin), len(tx.vout), tx.fee)Wallet methods target /wallet/<name>; derive a wallet-scoped handle with btc_wallet.
Management calls (createwallet/loadwallet/listwallets/…) use the base client.
names := btc.listwallets(&c) or_return // node-level
defer { for n in names { delete(n) }; delete(names) }
w := btc.btc_wallet(&c, "mywallet") or_return // /wallet/mywallet handle
defer btc.btc_destroy(&w)
bal := btc.getbalances(&w) or_return
fmt.printfln("trusted=%v pending=%v", bal.mine.trusted, bal.mine.untrusted_pending)
addr := btc.getnewaddress(&w) or_return
defer delete(addr)Anything not (or not-yet) typed is reachable by name, or via call / call_typed:
import "core:encoding/json"
// dynamic: json.Value in/out
v := btc.call(&c, "getmempoolinfo") or_return
defer json.destroy_value(v)
// typed: marshal params (a slice → positional array), unmarshal into T
h := btc.call_typed(&c, "getblockhash", []int{800_000}, string) or_return
defer delete(h)Enable in bitcoin.conf, e.g. zmqpubhashblock=tcp://127.0.0.1:28332,
zmqpubrawtx=…, zmqpubsequence=….
n := btc.btc_zmq_dial("tcp://127.0.0.1:28332", {.Hash_Block, .Raw_Tx, .Sequence}) or_return
defer btc.btc_zmq_destroy(&n)
for {
ev, err := btc.btc_zmq_recv(&n)
if err != nil { break } // Net_Error.Connection_Closed at stream end
defer btc.btc_zmq_event_destroy(&ev)
switch e in ev {
case btc.Hash_Block:
hash := btc.zmq_hash_hex(e.hash); defer delete(hash)
blk := btc.getblock(&c, hash) or_continue // confirm via RPC
defer btc.block_destroy(&blk)
case btc.Raw_Tx: /* e.bytes, e.seq */
case btc.Sequence: /* e.hash, e.label (C/D/A/R), e.mempool_seq, e.seq */
case btc.Hash_Tx, btc.Raw_Block:
}
}An empty topic set ({}) subscribes to everything. Each event exposes .seq, the per-topic
ZMQ counter — a gap means a dropped message; resync via RPC.
Calls are synchronous by default. For background fan-out, use a thread Pool (each call runs
on a worker; await returns the decoded result):
p: btc.Pool
btc.btc_pool_start(&p, &c, 8) // inits in place — never copy/move p after this
defer btc.btc_pool_shutdown(&p)
f1 := btc.btc_submit(&p, "getblockcount") or_return
f2 := btc.btc_submit(&p, "getblockhash", btc.params(800_000)) or_return
n := btc.btc_await_typed(&p, f1, int) or_return
h := btc.btc_await_typed(&p, f2, string) or_return; defer delete(h)For event-loop / non-blocking use there's an async (core:nbio) surface — you own the loop:
import "core:nbio"
nbio.acquire_thread_event_loop(); defer nbio.release_thread_event_loop()
state: My_State
btc.btc_async_call(&c, "getblockcount", nil, on_count, &state) or_return
nbio.run_until(&state.done)
on_count :: proc(result: json.Value, err: btc.Error, user: rawptr) { /* result borrowed — copy now */ }
// async ZMQ notifier (typed events, same Zmq_Event union):
n := btc.btc_zmq_async_dial("tcp://127.0.0.1:28332", {.Hash_Block}, on_ev, on_err, &state) or_return
defer btc.btc_zmq_async_destroy(n)
nbio.run()
on_ev :: proc(ev: btc.Zmq_Event, user: rawptr) { /* ev borrowed — valid only during the call */ }Async/threaded ride netclients' nbio + worker-pool drivers. Async RPC works over http:// and
https:// (TLS verify is on by default; pass options with ca_file/insecure_skip_verify for
HTTPS). Async ZMQ is tcp:// + NULL security (ZeroMQ's localhost model — not a TLS gap). Callback
payloads (RPC result, async ZMQ event) are borrowed: valid only during the call; copy out
what you keep.
Every call returns a single btc.Error union. The error model (and its variants) are
re-exported from btcutils, so you never import netclients yourself:
- Transport —
btc.Net_Error/btc.Tls_Error(node down, timeout, …). - Malformed envelope —
btc.Protocol_Error. - A server fault over a healthy transport —
btc.Rpc_Fault, carrying Bitcoin Core's{code, message}. This is not a transport error.
_, err := btc.getblock(&c, "deadbeef")
if code, ok := btc.fault_code(err); ok {
switch code {
case btc.RPC_INVALID_ADDRESS_OR_KEY: // -5: block not found
case btc.RPC_INVALID_PARAMETER: // -8
}
}
// or match the variant directly:
if f, ok := err.(btc.Rpc_Fault); ok {
fmt.eprintln(f.code, f.message)
fe := err; btc.error_destroy(&fe) // a captured fault owns heap memory
}All Bitcoin Core RPC error codes are exported as btc.RPC_* constants (see errors.odin).
Typed results are unmarshalled into the call's allocator; their string/slice/map fields are
heap-owned. Free them with the matching *_destroy (e.g. block_destroy, tx_destroy,
wallet_info_destroy, peer_info_destroy), or unmarshal into an arena and reset that.
Scalar results that allocate (a returned string) are freed with delete. ZMQ Raw_Block /
Raw_Tx own their bytes — free with btc_zmq_event_destroy. The whole package is
leak-tested.
./build.sh check # type-check (odin check . -no-entry-point)
./build.sh test # offline, deterministic, node-free unit/vector tests
./build.sh example # examples/blockcount against a local node (cookie auth)
./build.sh watch # examples/watch — ZMQ + RPC confirm (needs zmqpub* configured)
# Live integration tests (gated): point at a running node.
# BTCUTILS_LIVE=1 → RPC (sync + threaded + async) and read-only wallet
# BTCUTILS_LIVE_ZMQ=1 → ZMQ notifier (sync + async); needs zmqpub* configured
# Datadir via BTCUTILS_DATADIR (default $HOME/Library/...); ZMQ via BTCUTILS_ZMQ_ENDPOINT.
BTCUTILS_LIVE=1 BTCUTILS_LIVE_ZMQ=1 BTCUTILS_DATADIR="$HOME/Library/Application Support/Bitcoin" \
odin test . -define:ODIN_TEST_THREADS=1No -collection flags anywhere — btcutils imports its netclients submodule by
relative path, so it builds the same standalone or vendored in another project.
btcutils.odin facade: Client, btc_connect*/btc_wallet, call/call_typed
errors.odin Bitcoin RPC error codes + fault helpers (+ error-model re-exports)
types.odin node-level result structs + destructors
blockchain.odin mining.odin network.odin rawtx.odin util.odin control.odin
wallet.odin wallet_types.odin
zmq.odin zmq_events.odin typed ZMQ notifier + topic decoders
threaded.odin Pool: btc_pool_start/btc_submit/btc_await(_typed)
async.odin btc_async_call/notify + btc_zmq_async_dial (core:nbio)
params.odin params(...) builder + internal call helpers
*_test.odin offline vector tests; live_test.odin / live_zmq_test.odin /
live_concurrency_test.odin (gated)
examples/blockcount examples/watch
.claude/skills/btcutils usage skill (SKILL.md + references/)
netclients git submodule (the engine)
See the repository.