Controlling a Bitcoin Node with Elixir
I’ve been bit by the Bitcoin bug, and I’ve been bit hard. To satiate my thirst for knowledge, I’ve been reading the fantastic Mastering Bitcoin (affiliate link) book by Andreas Antonopoulos, and diving into the brave new world of Bitcoin development.
Mastering Bitcoin does a fantastic job of outlining the technical underpinnings of Bitcoin, but I wanted to solidify my understanding with some hands-on experience.
Writing a simple Elixir application to communicate and control a Bitcoin Core full node through its JSON-RPC interface seems like a fantastic “hello world” exercise. Let’s get to it!
You’ll Need a Full Node
The first step in communicating with a Bitcoin Core full node is getting our hands on one. While publicly available nodes with wide open JSON-RPC interfaces are few and far between, it’s fairly simple to run our own Bitcoin Core node locally.
Assuming we’ve installed the bitcoind
daemon on our system, we’ll need to configure it with a bitcoin.config
file:
rpcuser=<username>
rpcpassword=<password>
The <username>
and <password>
values we define in our configuration will be used to authenticate ourselves when making requests to the Bitcoin node.
Once we’ve created our configuration file, we can spin up our full node:
bitcoind -conf=<path to bitcoin.config> -daemon
Once started, our full node daemon will begin connecting to peer nodes, downloading, and verifying blocks from the blockchain.
We can verify that everything is working as expected:
bitcoin-cli getinfo
This command should return some basic information about the node, including the node’s "version"
, and the number of "blocks"
it’s received and verified. It may take several days to download and verify the entire blockchain, but we can keep continue on with our project in the meantime.
The Bitcoin Node’s JSON-RPC
Our Bitcoin full node implements a JSON-based RPC API which can be used to retrieve information about the Bitcoin blockchain, and to interact with the node itself.
Interestingly, the bitcoin-cli
tool that we used to get information about the node leverages this JSON-RPC API. You can fetch a list of all of the available RPC commands on the node by calling bitcoin-cli help
, or by browsing through the Bitcoin Wiki.
The node’s JSON-RPC accepts incoming commands through an HTTP server, which means that we can manually craft these RPC commands and bypass the bitcoin-cli
tool entirely.
For example, we can run getinfo
manually with curl
:
curl --data-binary '{"jsonrpc":"1.0","method":"getinfo","params":[]}' \
http://<user>:<pass>@localhost:8332/
Similarly, we can execute these commands from any programming environment with an HTTP client, like Elixir!
Setting Up Our Elixir Application
Now that we have a strategy for communicating with our Bitcoin full node, let’s start building out our Elixir application.
First, we’ll create a new Elixir project and update our mix.exs
to add dependencies on poison
, which we’ll need to encode and decode JSON objects, and httpoison
, our go-to Elixir HTTP client.
defp deps do
[
{:httpoison, "~> 0.13"},
{:poison, "~> 3.1"}
]
end
Now that we’ve laid out the scaffolding for our application, let’s turn our attention towards talking with our Bitcoin node.
We’ll start by gutting our HelloBitcoin
module, and stubbing out a new getinfo
function:
defmodule HelloBitcoin do
def getinfo do
raise "TODO: Implement getinfo"
end
end
To keep things simple, we’ll interact with this module through iex -S mix
. As a sanity check, let’s verify that everything is working correctly before moving on to the next section.
Calling our HelloBitcoin.getinfo
stub should raise a runtime exception:
iex(1)> HelloBitcoin.getinfo
HelloBitcoin.getinfo
** (RuntimeError) TODO: Implement getinfo
(hello_bitcoin) lib/hello_bitcoin.ex:4: HelloBitcoin.getinfo/0
Perfect. Progress through failure.
Constructing the GetInfo Command
Let’s start to flesh out our getinfo
function.
To recap, our goal is to send a POST
HTTP request to our Bitcoin node’s HTTP server (usually listening on http://localhost:8332
), passing in a JSON object that holds the command we’re trying to execute and any required parameters.
It turns out this is incredibly easy with httpoison
:
def getinfo do
with url <- Application.get_env(:hello_bitcoin, :bitcoin_url),
command <- %{jsonrpc: "1.0", method: "getinfo", params: []},
body <- Poison.encode!(command),
headers <- [{"Content-Type", "application/json"}] do
HTTPoison.post!(url, body, headers)
end
end
We start by pulling our url
from the bitcoin_url
key in our application’s configuration. This needs to be set in config/config.exs
and should point to your local node:
config :hello_bitcoin, bitcoin_url: "http://<user>:<password>@localhost:8332"
Next, we build a map that represents our JSON-RPC command
. In this case, our method
is "getinfo"
, which requires no params
. Finally, we construct the body
of our request by JSON encoding our command
with Poison.encode!
.
Calling HelloBitcoin.getinfo
should give us a successful 200
response from the Bitcoin node, along with the JSON encoded response to our getinfo
command:
%HTTPoison.Response{
body: "{\"result\":{\"version\":140200,\"protocolversion\":70015,\"walletversion\":130000,\"balance\":0.00000000,\"blocks\":482864,\"timeoffset\":-1,\"connections\":8,\"proxy\":\"\",\"difficulty\":888171856257.3206,\"testnet\":false,\"keypoololdest\":1503512537,\"keypoolsize\":100,\"paytxfee\":0.00000000,\"relayfee\":0.00001000,\"errors\":\"\"},\"error\":null,\"id\":null}\n",
headers: [{"Content-Type", "application/json"}, {"Date", "Thu, 31 Aug 2017 21:27:02 GMT"}, {"Content-Length", "328"}],
request_url: "http://localhost:8332",
status_code: 200
}
Beautiful.
Let’s decode the resulting JSON in body
and return the result:
HTTPoison.post!(url, body)
|> Map.get(:body)
|> Poison.decode!
Now our call to HelloBitcoin.getinfo
returns the result returned by bitcoind
in a more usable format:
%{"error" => nil, "id" => nil,
"result" => %{"balance" => 0.0, "blocks" => 483001, "connections" => 8,
"difficulty" => 888171856257.3206, "errors" => "",
"keypoololdest" => 1503512537, "keypoolsize" => 100, "paytxfee" => 0.0,
"protocolversion" => 70015, "proxy" => "", "relayfee" => 1.0e-5,
"testnet" => false, "timeoffset" => -1, "version" => 140200,
"walletversion" => 130000}}
You’ll notice that the "result"
, the data we actually want, is wrapped in a map containing metadata about the request itself. This metadata includes a potential error
string, and the id
of the request.
Let’s refactor our getinfo
function to include some error handling, and to return the actual data we care about in the case of an error-free response:
with url <- Application.get_env(:hello_bitcoin, :bitcoin_url),
command <- %{jsonrpc: "1.0", method: "getinfo", params: []},
{:ok, body} <- Poison.encode(command),
{:ok, response} <- HTTPoison.post(url, body),
{:ok, metadata} <- Poison.decode(response.body),
%{"error" => nil, "result" => result} <- metadata do
result
else
%{"error" => reason} -> {:error, reason}
error -> error
end
Now our getinfo
function will return an {:ok, result}
tuple containing the result of our getinfo
RPC call if everything goes well. In the case of an error we’ll receive an {:error, reason}
tuple, explaining the failure.
Generalizing Commands
We could implement another Bitcoin RPC command, like getblockhash
, in a nearly identical fashion:
def getblockhash(index) do
with url <- Application.get_env(:hello_bitcoin, :bitcoin_url),
command <- %{jsonrpc: "1.0", method: "getblockhash", params: [index]},
{:ok, body} <- Poison.encode(command),
{:ok, response} <- HTTPoison.post(url, body),
{:ok, metadata} <- Poison.decode(response.body),
%{"error" => nil, "result" => result} <- metadata do
{:ok, result}
else
%{"error" => reason} -> {:error, reason}
error -> error
end
end
Calling our new getblockhash
with an index of 0
gives us the hash of the Bitcoin genesis block, as we would expect.
HelloBitcoin.getblockhash(0)
{:ok, "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"}
While it’s great that this works, you’ll notice that there’s a huge amount of code duplication going on here. Our getblockhash
function is nearly identical to our getinfo
function.
Let’s abstract out the common functionality into a new bitcoin_rpc
helper function:
defp bitcoin_rpc(method, params \\ []) do
with url <- Application.get_env(:hello_bitcoin, :bitcoin_url),
command <- %{jsonrpc: "1.0", method: method, params: params},
{:ok, body} <- Poison.encode(command),
{:ok, response} <- HTTPoison.post(url, body),
{:ok, metadata} <- Poison.decode(response.body),
%{"error" => nil, "result" => result} <- metadata do
{:ok, result}
else
%{"error" => reason} -> {:error, reason}
error -> error
end
end
Now we can redefine our getinfo
and getblockhash
functions in terms of this new bitcoin_rpc
helper function:
def getinfo, do: bitcoin_rpc("getinfo")
def getblockhash(index), do: bitcoin_rpc("getblockhash", [index])
Our bitcoin_rpc
now acts as a fully functional and complete Bitcoin RPC interface. We can easily implement any of the Bitcoin RPC commands using this helper function.
If you’re curious and want to interact with a Bitcoin node yourself, the full source for this HelloBitcoin
project is available on GitHub.
Wrap Up
In hindsight, this was a long article explaining a relatively simple idea. The Bitcoin full node software exposes a JSON-RPC interface that can easily be accessed by your favorite language or stack, such as Elixir.
I’m incredibly excited about Bitcoin development, and I’m planning on spending more time diving deeper into this world in the future.
If you’re interested in the technical ideas behind Bitcoin, or are interested in Bitcoin development, I highly recommend you read Mastering Bitcoin (affiliate link).