Powered by AppSignal & Oban Pro

Verify On-Chain

transactions/verify-on-chain.livemd

Verify On-Chain

Mix.install([
  {:bsv, "~> 2.1"},
  {:httpoison, "~> 2.0"},
  {:jason, "~> 1.4"},
  {:qr_code, "~> 3.0"},
  {:kino, "~> 0.14"}
])

network =
  case System.get_env("LB_NETWORK", "test") do
    "main" -> :main
    "test" -> :test
    other -> raise ArgumentError, "LB_NETWORK must be \"main\" or \"test\", got: #{inspect(other)}"
  end

if network == :main and System.get_env("LIVEWALLET_HOSTED") == "true" do
  raise "This is a hosted LiveWallet. Mainnet is only for a Livebook you run yourself."
end

Application.put_env(:bsv, :network, network)

Setup

Look up the transaction that paid you and read its inputs and outputs, so that you trust the ledger because you checked it.

Before this: Receive Bitcoin. You need the txid and the address from that notebook.

network = BSV.network()
woc = "https://api.whatsonchain.com/v1/bsv/#{network}"

{label, hint} =
  case network do
    :main -> {"MAINNET", "Real bitcoin. Keep amounts under 1 USD."}
    :test -> {"TESTNET", "Coins have no value. Addresses start with m or n, not 1."}
  end

Kino.Markdown.new("""
> **Network: #{label}** (`#{network}`).
> #{hint}
> To switch, set the Livebook secret `LB_NETWORK` to `main` or `test`, then re-evaluate the setup cell.
""")

Look up the transaction by its id

Every transaction has an id, the txid. A wallet app usually links the txid to a block explorer website. Here you make the same request the website makes.

sequenceDiagram
  participant You as Livebook
  participant API as WhatsOnChain API
  participant Node as bitcoin node
  You->>API: GET /tx/hash/txid
  API->>Node: getrawtransaction
  Node-->>API: transaction
  API-->>You: JSON with vin, vout, blockheight
txid_input = Kino.Input.text("txid from Receive Bitcoin")
address_input = Kino.Input.text("Your address")
Kino.Layout.grid([txid_input, address_input], columns: 1)
txid = String.trim(Kino.Input.read(txid_input))
address = String.trim(Kino.Input.read(address_input))
tx_url = "#{woc}/tx/hash/#{txid}"

Make the request, take the body, and decode the JSON.

%{status_code: 200, body: body} = HTTPoison.get!(tx_url)
tx = Jason.decode!(body)

A MatchError above means the API did not return 200. Check the txid, and check that the network banner matches the network the transaction is on.

Read the structure

A transaction has inputs, vin, and outputs, vout. Inputs point at earlier outputs and unlock them. Outputs lock value to a new script, usually one that names an address.

%{
  "txid" => tx["txid"],
  "inputs" => length(tx["vin"]),
  "outputs" => length(tx["vout"]),
  "size_bytes" => tx["size"],
  "locktime" => tx["locktime"],
  "blockheight" => Map.get(tx, "blockheight", "not yet mined"),
  "confirmations" => Map.get(tx, "confirmations", 0)
}

Zero confirmations means the transaction is in the mempool, accepted by nodes but not yet in a block. Blocks arrive roughly every ten minutes. For small payments, a transaction that nodes have accepted is already useful.

Here are the outputs. One of them should be locked to your address.

outputs =
  for out <- tx["vout"] do
    %{
      n: out["n"],
      value_bsv: out["value"],
      address: out["scriptPubKey"]["addresses"] |> List.wrap() |> Enum.join(", ")
    }
  end

Kino.DataTable.new(outputs, name: "Outputs")

This is close to what a bitcoin node itself returns. WhatsOnChain adds convenience endpoints on top, such as the unspent-output list you used before.

Your unspent outputs

An unspent transaction output, or UTXO, is an output no later transaction has consumed. Your balance is the sum of the UTXOs locked to your keys. There is no account and no balance field anywhere on the chain; only outputs.

%{status_code: 200, body: body} = HTTPoison.get!("#{woc}/address/#{address}/unspent")
utxos = Jason.decode!(body)
Kino.DataTable.new(utxos, name: "Unspent outputs for #{address}")

Review

You fetched a transaction by id, read its inputs and outputs, found the output that pays you, and listed the UTXOs your address controls.

checks = [
  {"API returned the transaction you asked for", tx["txid"] == txid},
  {"one of its outputs is locked to your address", Enum.any?(outputs, &(&1.address == address))},
  {"that transaction appears in your unspent outputs", Enum.any?(utxos, &(&1["tx_hash"] == txid))}
]

Enum.each(checks, fn {label, ok?} -> IO.puts("#{if ok?, do: "ok  ", else: "FAIL"}  #{label}") end)
failed = for {label, false} <- checks, do: label
if failed == [], do: "PASS", else: raise("FAIL: " <> Enum.join(failed, "; "))

If the last check fails but the first two pass, you may have already spent that output. That is fine; the next notebook works from whatever is unspent.

Keep for later: the address, its index, and your seed phrase.

Next

Send a Transaction: spend that output by building, signing and broadcasting a transaction yourself.

Stuck or have a question? Open an issue.