Powered by AppSignal & Oban Pro

Order Lock

transactions/order-lock.livemd

Order Lock

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

Build and sign a transaction that cannot be mined until a block height you choose, so that you see how a transaction can carry a condition on time, not just on keys.

Before this: Send a Transaction. You need a funded address, its index, and your seed phrase.

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.
""")

Two fields you have not used yet

Every transaction has a lock_time. Every input has a sequence. When lock_time is set and at least one input's sequence is below the maximum, nodes treat the transaction as non-final until the chain reaches that height. Miners will not include it before then. A lock_time below 500,000,000 is a block height; at or above it is a unix time. This notebook uses a height; a time-based refund, the shape a marketplace escrow uses, is the same field with a bigger number.

This is the idea behind a payment that is signed today but only becomes spendable next week, an order that a seller cannot collect until a delivery window, or a refund that only becomes valid if a counterparty goes silent.

flowchart LR
  B[build tx with lock_time = H] --> S[sign]
  S --> P{chain height >= H?}
  P -->|no| W[nodes hold or reject as non-final]
  P -->|yes| M[miners may include it]

Inputs from you

address_input = Kino.Input.text("Your funded address")
index_input = Kino.Input.number("Its address index", default: 0)
mnemonic_input = Kino.Input.password("Your seed phrase")
target_input = Kino.Input.text("Address to pay")
amount_input = Kino.Input.number("Satoshis to pay", default: 1000)
blocks_input = Kino.Input.number("Blocks from now until it can be mined", default: 6)

Kino.Layout.grid(
  [address_input, index_input, mnemonic_input, target_input, amount_input, blocks_input],
  columns: 1
)

Derive the key and find an output

Same steps as in Send a Transaction, compressed.

address = String.trim(Kino.Input.read(address_input))
index = Kino.Input.read(index_input) || 0
mnemonic = String.trim(Kino.Input.read(mnemonic_input))
path = "m/44'/236'/0'/0/#{index}"

keypair =
  mnemonic
  |> BSV.Mnemonic.to_seed()
  |> BSV.ExtKey.from_seed!()
  |> BSV.ExtKey.derive(path)

derived = keypair.pubkey |> BSV.Address.from_pubkey() |> BSV.Address.to_string()

if derived != address do
  raise "The key at #{path} controls #{derived}, not #{address}"
end

%{status_code: 200, body: body} = HTTPoison.get!("#{woc}/address/#{address}/unspent")
utxos = Jason.decode!(body)
if utxos == [], do: raise("No unspent outputs at #{address}")

%{"tx_hash" => utxo_txid, "tx_pos" => vout, "value" => value} = Enum.max_by(utxos, & &1["value"])

%{status_code: 200, body: rawhex} = HTTPoison.get!("#{woc}/tx/#{utxo_txid}/hex")
prev_out = BSV.Tx.from_binary!(rawhex, encoding: :hex).outputs |> Enum.at(vout)

{:ok, utxo} =
  BSV.UTXO.from_params(%{
    "txid" => utxo_txid,
    "vout" => vout,
    "satoshis" => prev_out.satoshis,
    "script" => BSV.Script.to_binary(prev_out.script, encoding: :hex)
  })

"Spending output #{vout} of #{utxo_txid}, worth #{value} satoshis"

Pick the height

%{status_code: 200, body: body} = HTTPoison.get!("#{woc}/chain/info")
current_height = Jason.decode!(body)["blocks"]
target_height = current_height + (Kino.Input.read(blocks_input) || 6)

"Chain is at #{current_height}. This transaction will be valid from block #{target_height}."

Build with a lock time

The only differences from a plain payment are the lock_time on the builder and the sequence: 0 on the input. Without the sequence change, nodes ignore lock_time entirely.

{:ok, target_address} = BSV.Address.from_string(String.trim(Kino.Input.read(target_input)))
amount = Kino.Input.read(amount_input) || 1000
memo = "order lock until block #{target_height}"

builder =
  %BSV.TxBuilder{
    lock_time: target_height,
    inputs: [
      BSV.Contract.P2PKH.unlock(utxo, %{keypair: keypair}, sequence: 0)
    ],
    outputs: [
      BSV.Contract.P2PKH.lock(amount, %{address: target_address}),
      BSV.Contract.OpReturn.lock(0, %{data: [memo]})
    ]
  }
  |> BSV.TxBuilder.change_to(address)

fee = BSV.TxBuilder.calc_required_fee(builder)
if amount + fee > value, do: raise("#{amount} plus a fee of about #{fee} does not fit in #{value}")

tx = BSV.TxBuilder.to_tx(builder)
rawtx = BSV.Tx.to_binary(tx, encoding: :hex)
txid = BSV.Tx.get_txid(tx)

%{txid: txid, lock_time: tx.lock_time, sequence: hd(tx.inputs).sequence}

Decode it back

Anyone holding the raw hex can read the condition. Parse the bytes you just produced as if you had received them from a stranger.

decoded = BSV.Tx.from_binary!(rawtx, encoding: :hex)

%{
  lock_time: decoded.lock_time,
  inputs:
    for i <- decoded.inputs do
      %{sequence: i.sequence, unlocking_script_chunks: length(i.script.chunks)}
    end,
  outputs:
    for o <- decoded.outputs do
      %{satoshis: o.satoshis, script: inspect(o.script.chunks, limit: 4)}
    end
}

Try to broadcast it now

Set try_broadcast to true to see what the network says about a transaction that is not yet final. Some nodes hold non-final transactions for a while; others reject them. Either way, it will not be mined before block #{target_height}.

try_broadcast = false

if try_broadcast do
  case HTTPoison.post!("#{woc}/tx/raw", Jason.encode!(%{txhex: rawtx}), [
         {"content-type", "application/json"}
       ]) do
    %{status_code: 200, body: body} -> "Accepted into a non-final pool: #{String.trim(body, "\"")}"
    %{status_code: code, body: body} -> "Rejected (#{code}): #{body}"
  end
else
  "Not broadcast. Set try_broadcast = true to try, or keep the raw hex and broadcast it after block #{target_height}."
end

Keep rawtx. Once the chain passes the target height, broadcasting it works exactly like a normal payment. Notice that you could hand this hex to someone else now, and they could hold it until the height arrives; you signed it, so its content cannot change.

Review

You built a payment that carries a time condition, signed it, and read the condition back out of the raw bytes.

checks = [
  {"lock_time is the target height", decoded.lock_time == target_height},
  {"the input's sequence is below the maximum so lock_time applies", hd(decoded.inputs).sequence < 0xFFFFFFFF},
  {"target height is in the future", target_height > current_height},
  {"decoding the raw hex gives the same txid", BSV.Tx.get_txid(decoded) == 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, "; "))

Next

Stuck or have a question? Open an issue.