BTCの処理フローをコードベースで理解する

に公開

tl;dr

  • 今頃だが Bitcoin が海外で盛り上がりを見せているので、ざっと処理のフロートと処理内容をコードベースで理解する

概要

Bitcoin は UTXO モデルを採用しており、取引は未使用の取引出力(UTXO)を入力として使用し、新しい取引出力を生成します。
アドレスに UTXO が紐付き、その UTXO の合計がアドレスの残高となります。

今回は適当に mempool(explorer) からアドレスをコピーしてきて、UTXO を取得してみます。
(ちなみに mempool 以外にも blockstream などの explorer もあるので、見やすい方を使うと良い)

UTXO

まず UTXO を略さずに言うと Unspent Transaction Output(未使用トランザクション出力) のことで、未使用の取引出力を意味します。
Bitcoin ではお金は 「トランザクションの出力(Output)」という形で存在します。
その出力がまだ次のトランザクションの入力として消費されていない状態が未使用(Unspent)ということになります。

mempool を閲覧して適当に見つけたアドレスがこちら。
https://mempool.space/testnet/address/tb1qtcruplnz89xw5f86kw8sj7x9r23d5yffrysx2p

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 つに分けることができ、これは他の通貨でもほぼ同じです。

  1. Transaction を作成
  2. 署名
  3. 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
}
GitHubで編集を提案

Discussion