BTCの処理フローをコードベースで理解する
tl;dr
- 今頃だが Bitcoin が海外で盛り上がりを見せているので、ざっと処理のフロートと処理内容をコードベースで理解する
概要
Bitcoin は UTXO モデルを採用しており、取引は未使用の取引出力(UTXO)を入力として使用し、新しい取引出力を生成します。
アドレスに UTXO が紐付き、その UTXO の合計がアドレスの残高となります。
今回は適当に mempool(explorer) からアドレスをコピーしてきて、UTXO を取得してみます。
(ちなみに mempool 以外にも blockstream などの explorer もあるので、見やすい方を使うと良い)
UTXO
まず UTXO を略さずに言うと Unspent Transaction Output(未使用トランザクション出力) のことで、未使用の取引出力を意味します。
Bitcoin ではお金は 「トランザクションの出力(Output)」という形で存在します。
その出力がまだ次のトランザクションの入力として消費されていない状態が未使用(Unspent)ということになります。
mempool を閲覧して適当に見つけたアドレスがこちら。
ADDR="tb1qtcruplnz89xw5f86kw8sj7x9r23d5yffrysx2p"
curl -s "https://mempool.space/testnet/api/address/$ADDR/utxo" | jq
取得結果がこちら
[
{
"txid": "3cc8b57ea6a0f5dab16051c6c6864ac4dd173ebd60b7d8c4e5b0c83bcbe62931",
"vout": 0,
"status": {
"confirmed": true,
"block_height": 3974586,
"block_hash": "00000000000001853fec330ad63cea4b6ccc3a42f2b3a53abb5005276a36b18b",
"block_time": 1741042079
},
"value": 19073
},
// 省略
{
"txid": "6dc1a75a10a8e5a35dc6ee13075bc401a62e105898b39d4c8b400776daf9e0b3", // トランザクションのID
"vout": 0, // そのトランザクションの 何番目の出力か(0始まり)
"status": {
"confirmed": true,
"block_height": 4656449,
"block_hash": "00000000000002c84828a8a4b736ac127970c32433ea97b82ab23d2e98254099",
"block_time": 1756617689
},
"value": 3633527 // 出力量(satoshi 単位。1 BTC = 100,000,000 sats)
}
]
ちなみにほんとに未使用かは次の API で確認することができる。
curl -s "https://mempool.space/testnet/api/tx/6dc1a75a10a8e5a35dc6ee13075bc401a62e105898b39d4c8b400776daf9e0b3/outspend/0" | jq
結果
{
"spent": false
}
以下の API で UTXO の詳細を確認することができ、vout の最初の情報は署名の際に必要になる。
curl -s "https://mempool.space/testnet/api/tx/3cc8b57ea6a0f5dab16051c6c6864ac4dd173ebd60b7d8c4e5b0c83bcbe62931" \
| jq ".vout[]"
{
"scriptpubkey": "00145e07c0fe62394cea24fab38f0978c51aa2da1129",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 5e07c0fe62394cea24fab38f0978c51aa2da1129",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qtcruplnz89xw5f86kw8sj7x9r23d5yffrysx2p",
"value": 19073
}
{
"scriptpubkey": "6a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9",
"scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_36 aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9",
"scriptpubkey_type": "op_return",
"value": 0
}
処理フロー
処理フローは大きく分けて 3 つに分けることができ、これは他の通貨でもほぼ同じです。
- Transaction を作成
- 署名
- Network に送信 (ブロードキャスト)
コードは以下のような感じになるイメージ。
func main() {
// transactionをブロードキャストに必要なparams
var (
network = flag.String("net", "testnet", "mainnet|testnet|signet")
wifStr = flag.String("wif", "", "sender WIF (compressed, matches network)")
utxoTxid = flag.String("utxo-txid", "", "prevout txid")
utxoVout = flag.Uint("utxo-vout", 0, "prevout vout")
prevValue = flag.Int64("prevout-value", 0, "prevout value (sats)")
prevScriptHex = flag.String("prevout-script", "", "prevout scriptPubKey (hex)")
toAddr = flag.String("to", "", "destination address")
sendSats = flag.Int64("amount", 0, "amount to send (sats)")
feeSats = flag.Int64("fee", 1000, "absolute fee (sats)")
changeAddrOpt = flag.String("change", "", "change address (optional; default derives from WIF)")
broadcast = flag.Bool("broadcast", false, "broadcast via mempool.space")
broadcastURL = flag.String("broadcast-url", defaultBroadcastURLTestnet, "POST /api/tx endpoint")
)
flag.Parse()
if *wifStr == "" || *utxoTxid == "" || *prevScriptHex == "" || *toAddr == "" || *prevValue == 0 || *sendSats == 0 {
panic("missing required flags; try -h")
}
params := paramsByName(netChoice(*network))
// WIF(Wallet Import Format)をdecodeして秘密鍵を取得
wif, err := btcutil.DecodeWIF(*wifStr)
if err != nil {
panic(fmt.Errorf("decode WIF: %w", err))
}
if !wif.IsForNet(params) {
panic(fmt.Errorf("WIF network mismatch (want %s)", params.Name))
}
// 未署名のトランザクションを作成
tx, prevPkScript, inValue, err := buildUnsignedTx(
params,
*utxoTxid, uint32(*utxoVout),
*prevValue, *prevScriptHex,
*toAddr, *sendSats, *feeSats, *changeAddrOpt, wif,
)
if err != nil {
panic(err)
}
// 署名する
if err := signP2WPKHInput(tx, 0, prevPkScript, inValue, wif); err != nil {
panic(err)
}
rawHex, err := txToHex(tx)
if err != nil {
panic(err)
}
if *broadcast {
if *network != "testnet" && *broadcastURL == defaultBroadcastURLTestnet {
panic("set a proper broadcast URL for this network")
}
// nodeに送信する
txid, err := broadcastRaw(rawHex, *broadcastURL)
if err != nil {
panic(err)
}
}
}
buildUnsignedTx
で未署名のトランザクションを作成し、signP2WPKHInput
で署名を行い、broadcastRaw
でネットワークに送信しています。
ブロードキャストは raw hex を text/plain で POST するだけです。
node でエラーにならなければ、broadcastRaw
で送信されたトランザクションの ID が返されます。
その後、トランザクションの ID は explorer で検索すればトランザクションの詳細が確認できます。
transaction 作成
func buildUnsignedTx(
net *chaincfg.Params,
utxoTxid string, utxoVout uint32,
prevoutValue int64, prevoutScriptHex string,
toAddr string, sendSats int64,
feeSats int64, changeAddrOpt string, wif *btcutil.WIF,
) (*wire.MsgTx, []byte, int64, error) {
// outpoint
h, err := chainhash.NewHashFromStr(utxoTxid)
if err != nil {
return nil, nil, 0, fmt.Errorf("bad txid: %w", err)
}
outpoint := wire.NewOutPoint(h, utxoVout)
// destination script
destAddr := mustDecodeAddress(toAddr, net)
destScript, err := txscript.PayToAddrScript(destAddr)
if err != nil {
return nil, nil, 0, fmt.Errorf("make dest script: %w", err)
}
// change script
var changeAddr btcutil.Address
if changeAddrOpt != "" {
changeAddr = mustDecodeAddress(changeAddrOpt, net)
} else {
changeAddr, err = p2wpkhChangeFromWIF(wif, net)
if err != nil {
return nil, nil, 0, fmt.Errorf("derive change from WIF: %w", err)
}
}
changeScript, err := txscript.PayToAddrScript(changeAddr)
if err != nil {
return nil, nil, 0, fmt.Errorf("make change script: %w", err)
}
change := prevoutValue - sendSats - feeSats
// 小さすぎるお釣りは fee に足す(出力を作らない)
makeChange := change >= dustLimitP2WPKH
if !makeChange {
feeSats += change
change = 0
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil))
tx.AddTxOut(wire.NewTxOut(sendSats, destScript))
if makeChange {
tx.AddTxOut(wire.NewTxOut(change, changeScript))
}
prevPkScript, err := hex.DecodeString(prevoutScriptHex)
if err != nil {
return nil, nil, 0, fmt.Errorf("prevout script hex: %w", err)
}
return tx, prevPkScript, prevoutValue, nil
}
署名
func signP2WPKHInput(tx *wire.MsgTx, inputIdx int, prevPkScript []byte, prevValue int64, wif *btcutil.WIF) error {
// SegWit(v0) 用:前出力の金額とscriptを使ってsighashを作る
// NewTxSigHashes + CannedPrevOutputFetcher は WitnessSig のキャッシュに必要。:contentReference[oaicite:4]{index=4}
fetcher := txscript.NewCannedPrevOutputFetcher(prevPkScript, prevValue)
sighashes := txscript.NewTxSigHashes(tx, fetcher)
wit, err := txscript.WitnessSignature(
tx, sighashes, inputIdx, prevValue, prevPkScript, txscript.SigHashAll, wif.PrivKey, true,
)
if err != nil {
return fmt.Errorf("witness signature: %w", err)
}
tx.TxIn[inputIdx].Witness = wit
return nil
}
Discussion