本文介绍如何通过Clojure程序向比特币区块链网络写入永久保存的公开消息。本文的演示代码使用比特币测试网,你不需支付任何费用即可测试消息发送。
永久消息的存储位置
比特币区块链是许多区块组成的链式结构,每个区块都由多条交易记录组成。独特的哈希算法确保了成功写入区块链的交易记录不可更改。比特币协议允许交易带有一段自定义数据,我们的消息就是通过这些自定义数据写入区块链的,可以说我们发送的消息正是因为搭乘了交易的便车才得以永久保存。
准备工作:私钥及比特币
如果你已经有比特币地址和测试币可以跳过本节内容。
- 生成私钥及地址的工具很多,你也通过此网站在线生成:https://www.bitaddress.org/
- 从这个网站获取些测试网比特币:https://testnet.manu.backend.hamburg/faucet
代码
实现原理很简单:运行比特币轻钱包(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"})
- 如果一切正常,你会看到如下结果:
把txid
记下,就可以在blockchain.info上查看这条永久记录的消息了:
发送消息到主网
上文的代码用的是比特币测试网,好处是写入消息时你不需要支付任何真实费用,坏处是比特币社区可能会在未来某一天决定重置测试网数据。
要发送到主网,只需对上面的代码稍做调整即可。但需要注意两点,
- 测试网可以发起金额为0且矿工费极低的交易,但主网对最低交易金额及矿工费都有要求。
- 数据长度:少数矿工可能会接受更长的数据的消息,但建议将消息字节数限制在128字节以内。