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 で一度チェーンにロックし、以降
Payは commitment を差し替えるだけ。チェーンに触れるのは開閉の 2 回だけ - リボケーションとペナルティ: 状態を進めるたびに旧 commitment の秘密を相手に渡す。古い状態を提出すると相手に全額没収される
- HTLC とマルチホップ: 受取人だけが知る preimage のハッシュで各ホップを縛り、直接繋がらない相手へも仲介者を信頼せず届ける
① ステートチャネル: 送金はチェーンに載らない
まずチャネルの中身を定める。Commitment は「資金を今どう割るか」のスナップショットで、更新のたびに番号が 1 つ増える。Channel はその履歴と、どの番号がすでに revoke されたかを持つ:
// 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 に落とす:
// 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 が被害者による没収を担う:
// 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 を出せた者にだけ払う」条件でロックする:
// 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 を上流ほど長くするのは、下流が確定してから上流を確定する時間差を作るためだ:
// 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 公開で経路が下流から成立する。チャネルの残高がチェーンに載らずに動くこと、そして不正が割に合わないことが見えるはずだ。
alice が 100 をチェーン上の 2-of-2 マルチシグにロックする。ここがチェーンに触れる 1 回目。以降の送金はチェーンに載らない
チェーンに触れるのは開閉の 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 決済(タイムアウト分岐)の実際のスクリプトは省く
参考資料
- Joseph Poon, Thaddeus Dryja, "The Bitcoin Lightning Network"(2016) — 原論文。チャネルとペナルティ、HTLC の定義
- BOLT specifications — commitment・リボケーション・HTLC・オニオンの実仕様
- utxo 編(土台の取引モデル)・rollup 編(別方向のスケール)と合わせて読むと、L1 の外へ出す手法が見渡せる
- 実装: chain/lightning