utxo(UTXO モデルと「送金」の正体)
Bitcoin をはじめ多くのチェーンは、口座の残高という数字をどこにも持たない。あるのは「まだ誰にも使われていない出力(UTXO)」の集まりだけで、残高は自分あての出力を数え上げて初めて分かる。送金は残高を書き換えるのではなく、過去の出力を消費して新しい出力を生む操作だ。所有権は出力に刻まれた公開鍵への署名で守り、二重支払いは一度使った出力が集合から消えることで防ぐ。
この章で作るもの
blockchain 編では、ハッシュチェーンと Proof of Work で「改竄しにくい追記ログ」を作った。だがそこには肝心の取引が無く、ブロックの中身はただの文字列だった。この章はその中身、「A が B に送る」の正体を作る。
問いは 2 つある。中央の銀行なしに「誰が何を持っているか」をどう決めるか。そして「他人の金を勝手に使えない」ようにどうするか。UTXO モデルの答えは、残高を持たないことだった。
coinbase ──▶ out#0: 50 → Alice UTXO セット: { (cb,0): 50→A }
Alice → Bob 30:
input : (cb,0) を消費 ← Alice の署名で「使ってよい」を証明
output: [ 30 → Bob ][ 20 → Alice(お釣り) ]
適用後 UTXO セット: { (tx,0): 30→B,
(tx,1): 20→A }
↑ (cb,0) は消えた = もう使えない
残高 = 自分あて UTXO の合計: Bob=30, Alice=20順に見ていく。
- 残高は状態でなく集計: どこにも残高は保存されない。UTXO セットを数え上げて初めて分かる
- 送金 = 消費と生成: 過去の出力を input で消し、新しい output を生む。入力合計と出力合計の差が手数料
- 正しさは署名と一意消費: 所有者だけが消費でき(署名)、同じ出力は二度使えない(UTXO セットから消える)
① 取引の形: input と output
取引は「過去の出力を消費し、新しい出力を生む」ものだ。だから 2 つの部品でできている。output は「誰にいくら」、すなわち受取人の公開鍵(Owner)と金額を刻む。input は過去の出力を 1 つ指す参照(OutPoint)と、それを消費してよいことを示す署名を持つ:
// OutPoint は「どの取引の何番目の出力か」を指す参照。UTXO セットの鍵になる。
type OutPoint struct {
TxID string // 参照先取引の ID
Index int // その取引の Outputs の何番目か
}
// TxOutput は「誰にいくら」を表す出力。Owner は受取人の公開鍵(=アドレス)。
// この出力を将来消費できるのは、Owner に対応する秘密鍵の持ち主だけ。
type TxOutput struct {
Amount int
Owner ed25519.PublicKey
}
// TxInput は過去の出力を 1 つ消費する。Sig は「その出力の所有者による署名」で、
// 消費する権利を証明する。検証時の公開鍵は Input には持たせず、消費先 UTXO の
// Owner を使う——鍵をすり替えて他人の出力を奪えないようにするため。
type TxInput struct {
Prev OutPoint
Sig []byte
}
// Tx は 1 つの取引。Inputs が空のものを coinbase と呼び、無から出力を生む
// (マイニング報酬 + 手数料の回収)。Memo は coinbase を一意にするためのタグ
// (実際のチェーンではブロック高など)で、同額 coinbase が同じ ID になるのを防ぐ。
type Tx struct {
Inputs []TxInput
Outputs []TxOutput
Memo string
}
// IsCoinbase は input を持たない取引(=無から出力を生む)かどうか。
func (tx Tx) IsCoinbase() bool { return len(tx.Inputs) == 0 }
// signingBytes は署名と ID の対象になる正規バイト列。
// 署名(Sig)そのものは含めない——署名する前に確定している必要があるし、
// Sig を含めないことで ID が署名に左右されず、参照が安定する。
func (tx Tx) signingBytes() []byte {
var b []byte
b = append(b, tx.Memo...)
b = append(b, 0)
for _, in := range tx.Inputs {
b = append(b, in.Prev.TxID...)
b = binary.BigEndian.AppendUint32(b, uint32(in.Prev.Index))
}
b = append(b, 0)
for _, out := range tx.Outputs {
b = binary.BigEndian.AppendUint64(b, uint64(out.Amount))
b = append(b, out.Owner...)
}
return b
}
// ID は取引を一意に指す短いハッシュ(消費参照に使う)。
// signingBytes から作るので、署名前でも決まる。
func (tx Tx) ID() string {
sum := sha256.Sum256(tx.signingBytes())
return hex.EncodeToString(sum[:])[:16]
}ここで効いているのが、input が公開鍵を持たないことだ。検証時に使う鍵は、消費先 UTXO の Owner から取る。もし input に鍵を持たせたら、攻撃者は「自分の鍵とその署名」を添えて他人の出力を指し、奪えてしまう。鍵を出力側に固定することで、「その出力の所有者だけが消費できる」を強制する。
ID を署名(Sig)から独立させているのにも理由がある。署名する前に取引 ID が決まっていないと、「何に署名するのか」が循環してしまう。signingBytes は Sig を含めないので、ID は署名前に確定し、input はそれを安定して参照できる。
② UTXO セット: 残高を持たない台帳
台帳の実体は「未使用出力の集合」だ。取引を適用するとは、input が指す出力を消し、output を加えること。残高はこの集合を数えるだけで、状態としては存在しない:
// UTXOSet は「まだ消費されていない出力」の集まり。これがその瞬間の台帳=残高。
// 取引を適用するたびに、消費された出力が消え、新しい出力が加わる。
type UTXOSet struct {
utxos map[OutPoint]TxOutput
}
// NewUTXOSet は空の UTXO セットを作る。
func NewUTXOSet() *UTXOSet {
return &UTXOSet{utxos: map[OutPoint]TxOutput{}}
}
// Get は参照先の出力を返す。第 2 返り値が未使用として存在するかどうか。
func (s *UTXOSet) Get(op OutPoint) (TxOutput, bool) {
out, ok := s.utxos[op]
return out, ok
}
// Len は未使用出力の個数。
func (s *UTXOSet) Len() int { return len(s.utxos) }
// Balance は特定の所有者が持つ未使用出力の合計額。
// 「残高」という状態はどこにも保存されておらず、UTXO を数え上げて初めて分かる。
func (s *UTXOSet) Balance(owner ed25519.PublicKey) int {
total := 0
for _, out := range s.utxos {
if bytes.Equal(out.Owner, owner) {
total += out.Amount
}
}
return total
}
// Apply は取引を台帳に反映する: input が指す出力を削除し、output を新規に加える。
// 検証(Validate)を通ったあとに呼ぶ前提。ここが「送金の確定」に当たる。
func (s *UTXOSet) Apply(tx Tx) {
id := tx.ID()
for _, in := range tx.Inputs {
delete(s.utxos, in.Prev) // 消費された出力は二度と使えない=二重支払い防止
}
for i, out := range tx.Outputs {
s.utxos[OutPoint{TxID: id, Index: i}] = out
}
}
// Outpoints は現在の未使用出力を決定的な順序で返す(表示・列挙用)。
func (s *UTXOSet) Outpoints() []OutPoint {
ops := make([]OutPoint, 0, len(s.utxos))
for op := range s.utxos {
ops = append(ops, op)
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].TxID != ops[j].TxID {
return ops[i].TxID < ops[j].TxID
}
return ops[i].Index < ops[j].Index
})
return ops
}Apply の delete が二重支払い防止の核心だ。一度消費された OutPoint は集合から消える。だから後から同じ出力を指す取引が来ても、「そんな UTXO は無い」と弾かれる。二重支払いは集合から消えることそのもので防がれ、特別なフラグも履歴走査も要らない。
Balance が「集計」なのも見どころだ。銀行なら口座の数字を読むだけだが、ここでは全 UTXO を走査して自分あてを足す。残高は保存された事実ではなく、その瞬間の集合から導かれる。
③ 検証: 署名・一意消費・収支
取引を台帳に適用してよいかは、Validate が決める。通常取引で見るのは 4 点。input が未使用として存在するか、同じ input を取引内で二度使っていないか、所有者の署名が有効か、入力合計が出力合計以上か(差が手数料):
// 検証で返しうる理由。呼び出し側が原因を見分けられるよう、番兵エラーで公開する。
var (
ErrNoOutputs = errors.New("utxo: 取引に出力がない")
ErrNonPositive = errors.New("utxo: 出力額は正でなければならない")
ErrUnknownInput = errors.New("utxo: input が指す UTXO が存在しない(使用済みか未知)")
ErrDoubleSpend = errors.New("utxo: 同一 UTXO を同じ取引内で二重に使っている")
ErrBadSignature = errors.New("utxo: 署名が所有者の鍵と一致しない")
ErrInsufficient = errors.New("utxo: 入力合計が出力合計に足りない")
)
// Validate は取引が台帳に対して正しいかを確かめる。正しければ nil を返す。
//
// 通常取引で見るのは 4 点:
// 1. input が指す UTXO が「未使用として存在」するか
// 2. 同じ UTXO を同じ取引内で二度使っていないか(取引内の二重支払い)
// 3. その UTXO の所有者による署名が有効か(=消費する権利があるか)
// 4. 入力合計 >= 出力合計 か(差額は手数料)
//
// coinbase(input なし)は 1〜3 が無く、「出力が正の額を持つ」ことだけ確かめる。
func Validate(tx Tx, s *UTXOSet) error {
if len(tx.Outputs) == 0 {
return ErrNoOutputs
}
for _, out := range tx.Outputs {
if out.Amount <= 0 {
return ErrNonPositive
}
}
if tx.IsCoinbase() {
return nil // 無から生む出力。上限などの経済ルールは本章では簡略化
}
sig := tx.signingBytes()
seen := map[OutPoint]bool{}
inSum := 0
for _, in := range tx.Inputs {
if seen[in.Prev] {
return fmt.Errorf("%w: %s#%d", ErrDoubleSpend, in.Prev.TxID, in.Prev.Index)
}
seen[in.Prev] = true
prev, ok := s.Get(in.Prev)
if !ok {
return fmt.Errorf("%w: %s#%d", ErrUnknownInput, in.Prev.TxID, in.Prev.Index)
}
// 公開鍵は Input ではなく消費先 UTXO の Owner を使う——鍵のすり替えを許さない。
if !ed25519.Verify(prev.Owner, sig, in.Sig) {
return fmt.Errorf("%w: %s", ErrBadSignature, AddressOf(prev.Owner))
}
inSum += prev.Amount
}
outSum := 0
for _, out := range tx.Outputs {
outSum += out.Amount
}
if inSum < outSum {
return fmt.Errorf("%w: 入力 %d < 出力 %d", ErrInsufficient, inSum, outSum)
}
return nil
}
// Fee は入力合計 − 出力合計(=手数料)を返す。coinbase では意味を持たないので 0。
// 取引が Validate を通る前提。存在しない input があれば error。
func Fee(tx Tx, s *UTXOSet) (int, error) {
if tx.IsCoinbase() {
return 0, nil
}
inSum := 0
for _, in := range tx.Inputs {
prev, ok := s.Get(in.Prev)
if !ok {
return 0, fmt.Errorf("%w: %s#%d", ErrUnknownInput, in.Prev.TxID, in.Prev.Index)
}
inSum += prev.Amount
}
outSum := 0
for _, out := range tx.Outputs {
outSum += out.Amount
}
return inSum - outSum, nil
}ed25519.Verify(prev.Owner, sig, in.Sig) が所有権の門番だ。公開鍵は必ず消費先 UTXO の Owner を使う。coinbase(input なし)は「無から出力を生む」特別な取引で、署名も収支チェックも無い。マイニング報酬と手数料の回収がここから入る。
送金を組み立てる
送金は、自分あての UTXO を必要額まで集め、相手への output と自分へのお釣りを作り、署名する。この「集めてお釣りを作る」が現金の支払いそのものだ:
// ErrCannotAfford は送金額 + 手数料に足る UTXO を送り主が持っていないとき。
var ErrCannotAfford = errors.New("utxo: 残高が送金額 + 手数料に足りない")
// Coinbase は input を持たない取引を作る。無から amount を to に与える。
// memo で一意にする(同額 coinbase の ID 衝突を避ける)。マイニング報酬の入口。
func Coinbase(to ed25519.PublicKey, amount int, memo string) Tx {
return Tx{
Outputs: []TxOutput{{Amount: amount, Owner: to}},
Memo: memo,
}
}
// BuildTransfer は from が to へ amount を送る取引を組み立てて署名する。
//
// UTXO モデルの送金は「差額を引く」のではなく、from が持つ未使用出力を
// 必要額に届くまで集めて input にし、to への出力と、余りを from へ戻す
// 「お釣り」出力を作る——現金の支払いと同じ。fee は入力合計と出力合計の差。
func BuildTransfer(s *UTXOSet, from *Wallet, to ed25519.PublicKey, amount, fee int) (Tx, error) {
if amount <= 0 {
return Tx{}, ErrNonPositive
}
need := amount + fee
// from が所有する UTXO を決定的な順で集める。
var mine []OutPoint
for _, op := range s.Outpoints() {
out, _ := s.Get(op)
if string(out.Owner) == string(from.Pub) {
mine = append(mine, op)
}
}
sort.Slice(mine, func(i, j int) bool {
oi, _ := s.Get(mine[i])
oj, _ := s.Get(mine[j])
if oi.Amount != oj.Amount {
return oi.Amount > oj.Amount // 大きい出力から使い、input 数を抑える
}
if mine[i].TxID != mine[j].TxID {
return mine[i].TxID < mine[j].TxID
}
return mine[i].Index < mine[j].Index
})
var inputs []TxInput
gathered := 0
for _, op := range mine {
out, _ := s.Get(op)
inputs = append(inputs, TxInput{Prev: op})
gathered += out.Amount
if gathered >= need {
break
}
}
if gathered < need {
return Tx{}, ErrCannotAfford
}
outputs := []TxOutput{{Amount: amount, Owner: to}}
if change := gathered - need; change > 0 {
outputs = append(outputs, TxOutput{Amount: change, Owner: from.Pub}) // お釣り
}
tx := Tx{Inputs: inputs, Outputs: outputs}
// 全 input を同じ送り主が持つので、本体に 1 度署名して各 input に載せる。
sig := from.Sign(tx)
for i := range tx.Inputs {
tx.Inputs[i].Sig = sig
}
return tx, nil
}動かす
下のデモは、この筋書きをそのままブラウザで動かしている。「1手すすめる」で coinbase → 送金 → お釣り → 二重支払いの試み、と進む。UTXO セットが取引ごとにどう入れ替わるか、残高がどう集計されるか、そして二度目の消費がなぜ弾かれるかが見えるはずだ。
残高はどこにも保存されず、自分あて UTXO の合計として毎回導かれる
UTXO セットは空。残高という数字はどこにもない
残高は保存されない。あるのは「未使用出力」の集合だけ。今は空っぽ
送金は残高を書き換える操作ではなく、過去の出力を消費して新しい出力を生むこと。 残高は集合を数えた結果にすぎず、二重支払いは一度消費された出力が集合から消えることで防がれる。
設計の観点: UTXO vs アカウント
- なぜ残高を持たないか: 「口座の数字を書き換える」には、その口座への並行アクセスを直列化する必要がある。UTXO は各出力が独立に一度だけ消費されるので、異なる UTXO を使う取引は互いに衝突せず並列検証しやすい。プライバシーも上がる(毎回アドレスを変えられる)
- 二重支払いをどう防ぐか: 「消費 = 集合から削除」で、同じ出力は二度使えない。ネットワーク全体では「どの取引を先に鎖へ取り込むか」の合意(PoW や PoS)が、競合する二重支払いのどちらを正とするかを決める
- UTXO の弱点: 残高照会が「自分あて UTXO の走査」になり、状態(スマートコントラクトの変数)を素直に持てない。だから Ethereum はアカウントモデル(口座 → 残高 + ストレージ)を選んだ。次章の evm で扱う主題
- お釣りと手数料: 送金は input 合計を output で使い切る。余りはお釣りとして自分に戻し、意図的に残した差が手数料としてマイナーに渡る。「お釣りを作り忘れる」と全額が手数料になる(実際に起きた事故がある)
- なぜ署名対象から Sig を外すか: 署名前に ID が確定しないと循環する。また Sig を ID に含めると、署名を作り替えて別 ID にする**トランザクション展性(malleability)**が起き、未確認取引を参照する仕組み(Lightning など)が壊れる
メリット・デメリットと実例
| モデル | 並列性 | 状態の持ちやすさ | プライバシー | 実例 |
|---|---|---|---|---|
| UTXO | 高い(独立出力を並列検証) | 持ちにくい(残高は集計、変数を持てない) | 高め(アドレス使い捨て) | Bitcoin、Litecoin、Cardano(EUTXO) |
| アカウント | 低い(同口座は直列化) | 持ちやすい(残高 + ストレージ) | 低め(残高が紐づく) | Ethereum、多くの L1 |
| EUTXO(拡張) | 高い | 持てる(出力にデータ + 検証子) | 高め | Cardano |
裏どり:
- Bitcoin: UTXO モデルの原典。ノードは「UTXO セット」を chainstate として保持し、これが実質の台帳。取引検証は各 input の UTXO 存在と署名(Script)チェック
- Ethereum がアカウントを選んだ理由: スマートコントラクトは「状態(変数)」を持つ。UTXO で状態を表すのは不自然なので、口座 → 残高 + ストレージのアカウントモデルにした。代わりに nonce で二重実行(リプレイ)を防ぐ
- Cardano の EUTXO: UTXO の並列性を保ちつつ、出力にデータと検証スクリプトを載せてコントラクトを可能にした拡張。UTXO とアカウントの中間
- お釣り事故: 「お釣り output を付け忘れて巨額を手数料にした」取引が現実に複数ある。UTXO は差額を明示的にお釣りへ戻す必要があるため、実装ミスが金額の消失に直結する
簡略化したこと
- ブロック・PoW は別章: 取引を鎖へ取り込む採掘と合意は blockchain 編。ここは 1 つの UTXO セットに取引を順に適用する台帳として扱う
- Script は署名に固定: Bitcoin Script の一般性(マルチシグ・タイムロック・P2SH)は扱わず、「所有者の署名」に固定(P2PKH 相当)
- 署名は標準 ed25519: 署名アルゴリズム自体は crypto 編の主題。ここは UTXO の構造に集中する
- 経済ルール省略: coinbase の報酬上限・halving・難易度調整・mempool・手数料市場は扱わない
参考資料
- Satoshi Nakamoto, "Bitcoin: A Peer-to-Peer Electronic Cash System"(2008) — UTXO と二重支払い防止の原典
- Bitcoin Developer Guide, "Transactions" — input/output と Script の実際
- Vitalik Buterin, "A note on UTXOs vs Account/Balance model" — 2 モデルの比較
- 実装: chain/utxo