Skip to content

Lightning(決済チャネルとペナルティ、HTLC)

ブロックチェーンは 1 秒に数件しかさばけないので、コーヒー 1 杯を毎回載せるのは遅く高い。Lightning は 2 者が一度だけ資金をロックし、以降の残高更新は署名済みの差し替えでオフチェーンに置く。チェーンに触れるのは開閉の 2 回だけだ。ただし古い残高を出して巻き戻す不正が生まれるので、リボケーションと全額没収で封じる。直接繋がらない相手へは HTLC を多段に張って届ける。

この章で作るもの

blockchain 編で見たとおり、取引をチェーンに取り込む速度には天井がある。少額の支払いを毎回そこに載せるのは割に合わない。Lightning の考え方は、支払いのほとんどをチェーンの外に出すことだ。2 者が最初に一度だけ資金をチェーン上の 2-of-2 マルチシグにロックし(open)、以降は「今の残高の割り当て」を 2 人で署名して交換するだけで送り合う。チェーンに触れるのは、開くときと閉じるときの 2 回きりになる。

この設計には落とし穴がある。オフチェーンの残高割り当ては署名済みなので、どちらの当事者もいつでもチェーンに提出して閉じられる。ここで自分が有利だった過去の割り当てを出せると、支払ったはずの金が戻ってしまう。これを封じるのがこの章の主題で、答えはリボケーションとペナルティ、そして多段送金のための HTLC だ。

   チェーン上                     オフチェーン(2 人だけで交換)
   ┌───────────┐  funding   ┌──────────────────────────────┐
   │ 2-of-2 に  │ ─────────▶ │ commit#0  alice100 / bob 0    │
   │ 100 をロック │            │ commit#1  alice 60 / bob 40   │ ← 瞬時・無料
   └───────────┘            │ commit#2  alice 30 / bob 70   │   何千回でも
        ▲                    └──────────────────────────────┘
        │ close(最新の残高だけを書く)          │
        └──────────────────────────────────────┘
決済チャネルの一生。funding でチェーンに一度だけ資金をロックし、以降の送金は commitment(残高割り当て)をオフチェーンで差し替えるだけ。何千回更新してもチェーンには載らない。最後に閉じるときだけ、最新の残高がチェーンに書かれる

順に見ていく。

  1. ステートチャネル: funding で一度チェーンにロックし、以降 Pay は commitment を差し替えるだけ。チェーンに触れるのは開閉の 2 回だけ
  2. リボケーションとペナルティ: 状態を進めるたびに旧 commitment の秘密を相手に渡す。古い状態を提出すると相手に全額没収される
  3. HTLC とマルチホップ: 受取人だけが知る preimage のハッシュで各ホップを縛り、直接繋がらない相手へも仲介者を信頼せず届ける

① ステートチャネル: 送金はチェーンに載らない

まずチャネルの中身を定める。Commitment は「資金を今どう割るか」のスナップショットで、更新のたびに番号が 1 つ増える。Channel はその履歴と、どの番号がすでに revoke されたかを持つ:

go

// Commitment は「チャネルの資金を今どう割るか」のスナップショット。
// 更新のたびに番号が 1 つ増える。過去の commitment は revoke され、提出すると罰される。
type Commitment struct {
	Number   uint64
	BalanceA uint64
	BalanceB uint64
	// RevocationSecret は、この commitment が次で置き換えられたとき相手に渡す秘密。
	// これを握った側は、相手がこの(古い)状態を提出したら全額を没収できる。
	RevocationSecret string
}

// Channel は 2 者間の決済チャネル。資金はチェーン上で一度ロックされ、以降の残高更新は
// commitment の差し替えとしてオフチェーンで進む。
type Channel struct {
	A, B     string
	Capacity uint64

	commitments []Commitment    // index == 番号。末尾が最新
	revoked     map[uint64]bool // 置き換え済み(=相手が秘密を握っている)番号
	htlcs       []*HTLC         // 進行中/確定した HTLC(htlc.go)

	state          ChanState
	finalA, finalB uint64 // 閉じたときの最終残高

	clock         int
	disputeWindow int
	dispute       *dispute
}

type dispute struct {
	cheater    string
	commitment Commitment
	deadline   int
}

RevocationSecret が後で効く。今は「各 commitment に、それを無効化するための秘密が結びついている」とだけ押さえておく。

送金は Pay だが、やっていることは新しい commitment を 1 つ作るだけだ。チェーンには一切触れない。advance が番号を進め、同時に直前の状態を revoked に落とす:

go

// advance は新しい残高で次の commitment を作り、直前を revoke する。
// Pay も HTLC の確定も、内部的にはこの「番号を 1 つ進める」操作に還元される。
func (c *Channel) advance(na, nb uint64) {
	cur := c.Current()
	c.revoked[cur.Number] = true // 直前の状態は以後 revoked(相手が秘密を握る)
	n := cur.Number + 1
	c.commitments = append(c.commitments, Commitment{
		Number:           n,
		BalanceA:         na,
		BalanceB:         nb,
		RevocationSecret: revocationSecret(c.A, c.B, n),
	})
}

// Pay は from から相手へ amount をオフチェーンで送る。新しい commitment を作るだけで、
// チェーンには一切触れない。これを何千回でも瞬時に繰り返せるのが Lightning の値打ち。
func (c *Channel) Pay(from string, amount uint64) error {
	if c.state != StateOpen {
		return ErrChannelClosed
	}
	if from != c.A && from != c.B {
		return ErrUnknownParty
	}
	na, nb := c.Balances()
	if from == c.A {
		if na < amount {
			return ErrInsufficient
		}
		na, nb = na-amount, nb+amount
	} else {
		if nb < amount {
			return ErrInsufficient
		}
		na, nb = na+amount, nb-amount
	}
	c.advance(na, nb)
	return nil
}

advance が Pay の実体で、UTXO 編のように出力を作り替えるのでも、EVM 編のように状態を書き換えるのでもない。ただ「新しい残高割り当てに 2 人で署名し直す」だけ。この軽さが、Lightning が瞬時・ほぼ無料で何千回でも送れる理由になる。

② リボケーションとペナルティ: 古い状態の提出を罰する

問題はここからだ。commitment は署名済みなので、alice も bob もいつでも好きな番号をチェーンに出して一方的に閉じられる(unilateral close)。最新の番号を出すぶんには正当だが、自分が有利だった古い番号を出せてしまうと、オフチェーンでの支払いを巻き戻せる。

封じ方は、状態を 1 つ進めるたびに旧 commitment のリボケーション秘密を相手に渡すことだ。これは「この状態を今後もし出したら、その秘密で全額没収してよい」という許諾を相手に与えることに等しい。Broadcast は古い(revoked)状態の提出を即確定させず係争にし、Penalize が被害者による没収を担う:

go

// Broadcast は by が commitment comm をチェーンに提出して一方的に閉じようとする操作。
//
//   - 最新の commitment なら、正当な unilateral close(最新残高で確定)。
//   - revoked な古い commitment なら、不正の疑い。即確定はせず係争(Disputed)に入り、
//     相手が異議申立て期間内に Penalize できる。誰も咎めなければ、期間経過で不正が通る。
func (c *Channel) Broadcast(by string, comm Commitment) error {
	if c.state != StateOpen {
		return ErrChannelClosed
	}
	if by != c.A && by != c.B {
		return ErrUnknownParty
	}
	cur := c.Current()

	if comm.Number == cur.Number {
		// 最新状態の提出。正当な一方的クローズ。
		c.finalize(cur.BalanceA, cur.BalanceB, StateClosedUnilateral)
		return nil
	}
	if comm.Number < cur.Number && c.revoked[comm.Number] {
		// 古い(revoked)状態の提出 = 過去への巻き戻しを狙った不正。
		// 相手が咎める猶予(異議申立て期間)を置く。
		c.dispute = &dispute{
			cheater:    by,
			commitment: comm,
			deadline:   c.clock + c.disputeWindow,
		}
		c.state = StateDisputed
		return nil
	}
	return ErrInvalidCommit
}

// Penalize は係争中に、被害者(不正提出者の相手)がリボケーション秘密を示して全額を没収する。
//
// 検査は 2 つ。(1) 行使者が被害者本人か。(2) 示した秘密が、提出された古い commitment の
// リボケーション秘密と一致するか。両方満たし、かつ期間内なら、チャネルの全資金が被害者に渡る。
// これが「不正の期待値をマイナスにする」罰で、Lightning がオフチェーン更新を安全にする要。
func (c *Channel) Penalize(by, secret string) error {
	if c.state != StateDisputed {
		return ErrNoDispute
	}
	d := c.dispute
	victim := c.other(d.cheater)
	if by != victim {
		return ErrNotVictim
	}
	if c.clock > d.deadline {
		return ErrWindowClosed
	}
	if secret != d.commitment.RevocationSecret {
		return ErrBadSecret
	}
	// 被害者が全額を没収する。
	if victim == c.A {
		c.finalize(c.Capacity, 0, StateClosedPenalty)
	} else {
		c.finalize(0, c.Capacity, StateClosedPenalty)
	}
	return nil
}

// FinalizeDispute は異議申立て期間が過ぎた係争を確定させる。誰も Penalize しなければ、
// 不正提出者の古い残高がそのまま通ってしまう(StateClosedExpiredCheat)。
//
// これが「自分のチャネルを見張っていないと損をする」現実で、常時監視を肩代わりする
// 第三者サービス(watchtower)が生まれた理由でもある。
func (c *Channel) FinalizeDispute() {
	if c.state != StateDisputed {
		return
	}
	if c.clock < c.dispute.deadline {
		return // まだ期間内。咎める余地が残っている
	}
	comm := c.dispute.commitment
	c.finalize(comm.BalanceA, comm.BalanceB, StateClosedExpiredCheat)
}

Penalize の 2 つの検査が要になる。行使できるのは被害者(提出者の相手)だけで、しかも提出された古い commitment のリボケーション秘密を示せたときだけだ。両方を満たすとチャネルの全資金が被害者に渡る。だから古い状態の提出は、成功すれば元本、失敗すれば全額没収という一方的に不利な賭けになる。正直でいるのが最も得、という均衡がここで生まれる。

FinalizeDispute にも意味がある。異議申立て期間内に誰も咎めなければ、不正な古い残高がそのまま通る。つまり自分のチャネルを見張っていないと損をする。この監視を肩代わりする第三者サービスが watchtower だ。

③ HTLC: 直接繋がらない相手へ届ける

チャネルは 2 者間のものだが、全員と個別にチャネルを開くのは非現実的だ。alice が carol と直接繋がっていなくても、間に bob がいれば bob を経由して送りたい。だが bob が金だけ受け取って carol へ渡さないかもしれない。これを暗号で縛るのが HTLC(Hash Time-Locked Contract)で、受取人 carol だけが知る秘密 preimage のハッシュ H を使い、各ホップを「H の preimage を出せた者にだけ払う」条件でロックする:

go

// Hash は preimage のハッシュ。HTLC のロック条件になる(本物は SHA-256)。
func Hash(preimage string) string {
	h := sha256.Sum256([]byte(preimage))
	return hex.EncodeToString(h[:])[:12]
}

// HTLC は 1 つのチャネル上でロックされた条件付き支払い。
// preimage が示されれば Payee が受け取り、示されなければ期限後に Offerer へ戻る。
type HTLC struct {
	Hash    string
	Amount  uint64
	Expiry  int    // この時刻以降は失効(offerer へ返る)
	Offerer string // 成立すれば amount を失う側
	Payee   string // 成立すれば amount を得る側
	Settled bool
	Failed  bool
}

// LockHTLC は offerer の残高から amount を切り出し、条件付き支払いとしてロックする。
// ロック中は誰の残高でもない「宙に浮いた」状態で、新しい commitment に反映される。
func (c *Channel) LockHTLC(offerer string, amount uint64, hash string, expiry int) (*HTLC, error) {
	if c.state != StateOpen {
		return nil, ErrChannelClosed
	}
	if offerer != c.A && offerer != c.B {
		return nil, ErrUnknownParty
	}
	na, nb := c.Balances()
	if offerer == c.A {
		if na < amount {
			return nil, ErrInsufficient
		}
		na -= amount
	} else {
		if nb < amount {
			return nil, ErrInsufficient
		}
		nb -= amount
	}
	c.advance(na, nb) // ロックも状態更新の一種(revocable)
	h := &HTLC{Hash: hash, Amount: amount, Expiry: expiry, Offerer: offerer, Payee: c.other(offerer)}
	c.htlcs = append(c.htlcs, h)
	return h, nil
}

// SettleHTLC は preimage を示して HTLC を成立させる。ハッシュが合えば amount は Payee のものになる。
func (c *Channel) SettleHTLC(h *HTLC, preimage string) error {
	if h.Settled || h.Failed {
		return ErrHTLCDone
	}
	if Hash(preimage) != h.Hash {
		return ErrHTLCPreimage
	}
	na, nb := c.Balances()
	if h.Payee == c.A {
		na += h.Amount
	} else {
		nb += h.Amount
	}
	c.advance(na, nb)
	h.Settled = true
	return nil
}

// FailHTLC はタイムアウト後に、ロックした amount を offerer へ返す。
func (c *Channel) FailHTLC(h *HTLC, now int) error {
	if h.Settled || h.Failed {
		return ErrHTLCDone
	}
	if now < h.Expiry {
		return ErrHTLCNotExpired
	}
	na, nb := c.Balances()
	if h.Offerer == c.A {
		na += h.Amount
	} else {
		nb += h.Amount
	}
	c.advance(na, nb)
	h.Failed = true
	return nil
}

LockHTLC はロックした額を宙に浮かせる(どちらの残高でもない状態にする)。SettleHTLC は preimage を示せたときだけ受取側に渡し、FailHTLC は期限切れで offerer に返す。preimage を知らない限り、仲介者は金を横取りできない。

経路に張るのが Route、受取人が preimage を公開して成立させるのが Settle だ。expiry を上流ほど長くするのは、下流が確定してから上流を確定する時間差を作るためだ:

go

// Route は path(送金側→受取側)の各ホップに HTLC を張る。expiry は送金側を最も長くし、
// 1 ホップごとに delta 減らす——下流が確定してから上流を確定する時間差を作るため。
func (n *Network) Route(path []string, amount uint64, hash string, baseExpiry, delta int) (*Payment, error) {
	p := &Payment{Hash: hash}
	expiry := baseExpiry
	for i := 0; i < len(path)-1; i++ {
		ch := n.Channel(path[i], path[i+1])
		if ch == nil {
			return nil, ErrNoChannel
		}
		h, err := ch.LockHTLC(path[i], amount, hash, expiry)
		if err != nil {
			return nil, err
		}
		p.hops = append(p.hops, hop{ch: ch, htlc: h})
		expiry -= delta
	}
	return p, nil
}

// Settle は受取側が preimage を公開し、経路を下流から上流へ順に成立させる。
// preimage が上流へ伝播していく様子を、末尾から先頭への走査でモデル化する。
func (p *Payment) Settle(preimage string) error {
	if Hash(preimage) != p.Hash {
		return ErrHTLCPreimage
	}
	for i := len(p.hops) - 1; i >= 0; i-- {
		if err := p.hops[i].ch.SettleHTLC(p.hops[i].htlc, preimage); err != nil {
			return err
		}
	}
	p.Preimage = preimage
	return nil
}

Settle が経路を末尾(受取人)から先頭(送金人)へ向けて成立させることに注目してほしい。carol が preimage を出して B—C を成立させると、その瞬間に bob は preimage を知る。bob はそれで A—B を回収する。preimage が下流から上流へ伝播し、経路全体が同時に成立するか、まったく成立しないかのどちらかになる。途中の誰かが取りはぐれることはない。

動かす

下のデモは、この筋書きをそのままブラウザで動かしている。左の切り替えで 2 つの場面を比べられる。「オフチェーン + ペナルティ」では、alice が 2 回送ったあとに古い commitment を提出し、bob がリボケーション秘密で全額を没収する。「HTLC マルチホップ」では、alice → bob → carol の各ホップに HTLC が張られ、carol の preimage 公開で経路が下流から成立する。チャネルの残高がチェーンに載らずに動くこと、そして不正が割に合わないことが見えるはずだ。

デモlightning(決済チャネル)step 1
オフチェーン + ペナルティHTLC マルチホップ
channelalice 100 · bob 0
commitment(番号が進むほど新しい・過去は revoked)
#0alice 100 / bob 0最新
チャネルを開く(funding)

alice が 100 をチェーン上の 2-of-2 マルチシグにロックする。ここがチェーンに触れる 1 回目。以降の送金はチェーンに載らない

1 / 5

チェーンに触れるのは開閉の 2 回だけで、送金はオフチェーンの commitment 差し替えで進む。 古い状態を出す不正はリボケーション秘密による全額没収で割に合わなくし、直接繋がらない相手へは preimage のハッシュで縛った HTLC を多段に張って届ける。

設計の観点: なぜオフチェーンが安全か

  • なぜチャネルか: L1 のスループットには天井がある。少額・多頻度の支払いを毎回載せるのは遅く高い。チャネルは funding と close の 2 回だけ L1 を使い、その間の任意回数の更新を L1 の外に出す。L1 は最終決済と紛争解決の裁判所として使う
  • なぜ古い状態を出せるのに安全か: commitment は署名済みでいつでも提出できるが、revoked な状態を出すと相手がリボケーション秘密で全額を没収できる。不正の期待値をマイナスにすることで、提出を「最新の状態に限る」よう仕向ける。暗号でなく経済的な抑止で守っている
  • watchtower がなぜ要るか: ペナルティは被害者が異議申立て期間内に動いて初めて効く。オフラインだと不正提出を見逃す。常時監視を代行するのが watchtower。落ちていても後で罰せるよう、必要な情報だけを預ける
  • HTLC が置き換えているもの: 多段送金を「仲介者を信頼する」問題から「preimage を出せるか」の問題に置き換える。preimage が受取側から送金側へ伝播することで、経路全体が原子的に成立する。timelock を上流ほど長くするのは、下流の成立を見てから上流を安全に閉じるため
  • 流動性という制約: チャネルの片側に寄った残高では、その向きに送れる額が尽きる(inbound/outbound 流動性)。送金経路が見つかるかは、経路上の各チャネルに十分な向きの残高があるかに依存する。ここが Lightning 運用の難所

メリット・デメリットと実例

論点仕組み効果実例
スループット送金をオフチェーン化L1 は開閉の 2 回だけ、間は無制限Lightning の少額決済
不正の抑止リボケーション + 全額没収古い状態の提出を割に合わなくするpenalty transaction
多段送金preimage ハッシュ + HTLC仲介者を信頼せず原子的に届ける経路上の HTLC 転送
監視の代行watchtowerオフラインでも不正提出を罰せるEye of Satoshi 等

裏どり:

  • Bitcoin Lightning Network: 本章の題材。BOLT 仕様が commitment・リボケーション・HTLC・オニオンルーティングを定める。実チャネルは Bitcoin Script(HTLC 出力・タイムロック)で構成される
  • リボケーション鍵: 実装では番号ごとの秘密をハッシュチェーン(shachain)で圧縮して保持する。全番号の秘密を素朴に持たずに、任意の過去を復元できる
  • オニオンルーティング: 経路の各ホップは「次にどこへ渡すか」だけを知り、経路全体は知らない。Tor に似た層状の暗号化でプライバシーを保つ(本章では未実装)
  • 他チェーンの状態チャネル: Ethereum の state channel / Raiden も同じ「オフチェーン更新 + オンチェーン紛争解決」の発想。rollup 編が実行を L2 に集約するのに対し、チャネルは 2 者間に閉じる別方向のスケール策

簡略化したこと

  • 実クリプトなし: 署名・マルチシグ・Bitcoin Script は使わず、残高割り当てと論理時計で意味論だけを取り出す。preimage のロックはハッシュ一致で表す
  • リボケーション秘密は番号から導出: 本物は乱数 + ハッシュチェーン(shachain)。ここでは番号から決定的に導く
  • オニオンルーティングなし: 経路探索・層状暗号・プライバシーは扱わない。経路は与えられる前提
  • 流動性・手数料なし: inbound/outbound 流動性の枯渇、転送手数料、経路探索の失敗は扱わない
  • 単方向の HTLC タイムアウト: タイムロックの差は数値で表すのみ。オンチェーンでの HTLC 決済(タイムアウト分岐)の実際のスクリプトは省く

参考資料