使用Clojure向比特币区块链写入永久消息

本文介绍如何通过Clojure程序向比特币区块链网络写入永久保存的公开消息。本文的演示代码使用比特币测试网,你不需支付任何费用即可测试消息发送。

永久消息的存储位置

比特币区块链是许多区块组成的链式结构,每个区块都由多条交易记录组成。独特的哈希算法确保了成功写入区块链的交易记录不可更改。比特币协议允许交易带有一段自定义数据,我们的消息就是通过这些自定义数据写入区块链的,可以说我们发送的消息正是因为搭乘了交易的便车才得以永久保存。

准备工作:私钥及比特币

如果你已经有比特币地址和测试币可以跳过本节内容。

代码

实现原理很简单:运行比特币轻钱包(SPV)以避免加载并运行整个比特币节点(但轻钱包的首次数据加载时间可能并不短);需要发送消息时,组建一个发送给自己的交易,签名并广播。

可通过注释了解具体实现,也可在Github查看

(ns btc-sender.core
  (:require
   [taoensso.timbre :as t]
   [clojure.java.io      :as io]
   [clojure.string       :as s]
   [clojure.core.async   :as a :refer [go-loop <! <!! timeout go chan sliding-buffer >!!]])
  (:import
   [java.nio.charset Charset]
   [java.io File]
   [org.bitcoinj.script ScriptOpCodes ScriptBuilder]
   [org.bitcoinj.wallet Wallet]
   [org.bitcoinj.core NetworkParameters ECKey DumpedPrivateKey Coin Transaction Address
    Transaction$SigHash Coin TransactionOutput TransactionOutPoint PeerGroup TransactionBroadcast
    TransactionBroadcast$ProgressCallback Sha256Hash]
   [org.bitcoinj.wallet.listeners WalletCoinsReceivedEventListener WalletCoinsSentEventListener]
   [org.bitcoinj.kits WalletAppKit]
   [org.bitcoinj.params MainNetParams TestNet3Params]))

;; 一些配置
(defonce btc-sender-config
  {:private "你的私钥"
   :store "SPV钱包本地缓存地址"
   :file-prefix "spv"
   :create-time-millis 1533772497726})

;; 广播超时设置
(defonce send-timeout 60000)

;; 用于缓存钱包对象,避免重重加载
(defonce wallet-kit-store (atom nil))

;; 消息发送队列,同时只允许一个交易在发送
(defonce msg-sending-chan (chan (sliding-buffer 100)))

;; 将字符串格式的私钥转换为对象
(defn- string->prvkey [^NetworkParameters network ss]
  (.getKey (DumpedPrivateKey/fromBase58 network ss)))

;; 使用上面的配置启动钱包服务,本方法会在启动成功前阻塞当前线程
(defn start-wallet-service! []
  (let [file-store (io/file (btc-sender-config :store))
        file-prefix (btc-sender-config :file-prefix)
        network (TestNet3Params/get)
        create-time-secs (int (/ (btc-sender-config :create-time-millis) 1000))
        wif (btc-sender-config :private)
        prv-key (string->prvkey network wif)
        pub-key (.getPubKeyHash prv-key)
        address (Address. network pub-key)
        kit (WalletAppKit. ^NetworkParameters network ^File file-store ^String file-prefix)]
    (try
      (.. kit startAsync awaitRunning)
      (let [wallet (.wallet kit)]
        (doto wallet
          ;; create-time-secs的时间设应该设为比本地址的第一次交易时间稍早点
          (.addWatchedAddress address create-time-secs)
          ;; 收到币的事件
          (.addCoinsReceivedEventListener
           (reify WalletCoinsReceivedEventListener
             (onCoinsReceived [this wallet tx prev-balance new-balance]
               (t/info "coin received: from" prev-balance "to" new-balance))))
          ;; 发送币的事件
          (.addCoinsSentEventListener
           (reify WalletCoinsSentEventListener
             (onCoinsSent [this wallet tx prev-balance new-balance]
               (t/info "coin sent:" prev-balance "to" new-balance)))))
        ;; 加入缓存
        (swap! wallet-kit-store assoc :testnet {:kit kit
                                                :o-network network
                                                :wallet wallet
                                                :stop-fn (fn [] (.stopAsync kit))
                                                :balance-fn (fn [] (.-value (.getBalance wallet)))
                                                :unspent-fn (fn [] (.getUnspents wallet))
                                                :address address
                                                :prv-key prv-key
                                                :pub-key pub-key}))
      (t/info "Btc Wallet Service start success! Current balance:" (.getBalance (.wallet kit)) " SAT")
      (catch Exception e
        (t/error e)
        (t/error "walletappkit start error, stopping service ...")
        (.stopAsync kit)))))

;; 根据交易包的大小计算手续费,后面会用到
(defn- calculate-fee [msg-size input-count fee-factor]
  (* fee-factor (+ msg-size (* input-count 148) 34 20)))

;; 发送消息
(defn send-msg! [{:keys [^String msg progress-callback network]
                  :as params}]
  ;; 通过pre前置条件要求调用时钱包已初始化
  {:pre [(#{:mainnet :testnet} network) (network @wallet-kit-store) msg]}
  (let [ch (chan)]
    (try
      (let [fee-factor 1
            {:keys [o-network kit wallet balance-fn unspent-fn address prv-key]} (network @wallet-kit-store)
            tx (Transaction. o-network)
            msg-bytes (.getBytes msg (Charset/forName "UTF-8"))
            msg-script (.. (ScriptBuilder.)
                           (op ScriptOpCodes/OP_RETURN)
                           (data msg-bytes)
                           (build))
            x-unspent (unspent-fn) ;; UTXO
            in-value (reduce (fn [sum unspent] (+ sum (.-value (.getValue unspent)))) 0 x-unspent)
            fee (calculate-fee (count msg-bytes) (count x-unspent) fee-factor)
            peer-group ^PeerGroup (.peerGroup kit)]
        ;; 创建一个发送给自己的交易,这里添加交易的输出
        (doto tx
          (.addOutput (Coin/valueOf 0) msg-script)
          (.addOutput (Coin/valueOf (- in-value fee)) address))
        ;; 从UTXO中组建交易的输入并签名
        (doseq [unspent x-unspent]
          (let [out-point (TransactionOutPoint. ^NetworkParameters o-network ^long (.getIndex unspent) ^Sha256Hash (.getParentTransactionHash unspent))]
            (.addSignedInput tx ^TransactionOutPoint out-point (.getScriptPubKey unspent) prv-key org.bitcoinj.core.Transaction$SigHash/ALL true)))
        ;; 广播签名后的数据
        (let [txid (.getHashAsString tx)]
          (.. peer-group
              (broadcastTransaction tx)
              (setProgressCallback (reify TransactionBroadcast$ProgressCallback
                                     (onBroadcastProgress [req progress]
                                       (when progress-callback
                                         (progress-callback req progress))
                                       (when (> progress 0.9999999)
                                         (t/info "Send msg done:" txid)
                                         (a/put! ch {:success :success :txid txid}))))))
          (t/info "Sending btc msg: " txid msg)))
      (catch Throwable e
        (t/error "Sending btc req failed" params)
        (t/error e)
        (a/put! ch {:error e})))
    (let [[val c] (a/alts!! [ch (timeout send-timeout)])]
      (or val {:error :timeout}))))

;; 判断服务是否已经启动
(defn- service-ready? [network]
  (get @wallet-kit-store network))

;; 如果服务已启动发送消息
(defn send-msg-if-ready [req]
  (if (service-ready? :testnet)
    (send-msg! (assoc req :network :testnet))
    {:error :service-not-ready}))

测试

  • 在发起交易前,钱包后台服务必须已经成功运行:
(start-wallet-service!)
  • 收到启动成功的提示后,通过下面的函数发送消息:
    (send-msg-if-ready {:msg "hello"})
    
  • 如果一切正常,你会看到如下结果:

btc-msg

txid记下,就可以在blockchain.info上查看这条永久记录的消息了:

btc-msg-out

发送消息到主网

上文的代码用的是比特币测试网,好处是写入消息时你不需要支付任何真实费用,坏处是比特币社区可能会在未来某一天决定重置测试网数据。

要发送到主网,只需对上面的代码稍做调整即可。但需要注意两点,

  • 测试网可以发起金额为0且矿工费极低的交易,但主网对最低交易金额及矿工费都有要求。
  • 数据长度:少数矿工可能会接受更长的数据的消息,但建议将消息字节数限制在128字节以内。
Comment