すべての記事

台帳シリーズ 第 2 部:残高と不変性

·
2026年9月15日
·
17 分で読めます
·
••• 回閲覧
·
••• いいね

英語の原文から翻訳した記事です。 英語で読む

台帳シリーズ 第 2 部:残高と不変性

この「台帳シリーズ」の第 1 部は、もっと単純な世界で終わりました。すべてのお金の動きが、記録されたその瞬間に確定する世界です。問題は、現実のお金はそんなふうには振る舞わないということです。例えば Tobi はあなたのユーザーの 1 人で、アプリに 100 USDC を持っています。さらに 50 USDC がまだ承認されていない入金として待機していて、30 USDC の出金が外部ウォレットに向かっている途中です。このとき、彼の残高には何と表示すべきでしょうか。そして、そのうち実際にいくら使えるのでしょうか?

アプリが何を数えるかによって、表示は 100、120、150 のどれにもなりえます。そして、彼が使える額を正直に答えるなら 70 です。記録されている額、見込まれている額、その時点で使える額、この三者のあいだのずれこそが、この第 2 部のテーマです。

残高が 1 つではもう足りない理由

ここでも複式簿記モデルは何も変わりません。すべての動きは今でも釣り合い、エントリは今でも一度だけ書き込まれ、決して触られることはありません。変わるのは、お金がさまざまな状態で時間を過ごすようになることです。カード決済は確定までに数日かかりますし、暗号資産の入金はブロックの承認数が決められた数に達するまで未承認のまま待機しますし、払い出しはプロバイダーのもとで処理中のまま、失敗するかどうかは誰にもわかりません。

第 1 部の単一の残高は、届いたお金しか表せません。まだ確定していないお金や、移動中のお金を表す手段がないのです。そのため、数値を 1 つだけ持つシステムは、ユーザーが持っているお金を隠してしまうか、まだカストディにないお金を手放してしまうかのどちらかになります。システムは、保留状態のお金も記録しなければなりません。

3 つの残高

移動中のお金をモデル化する台帳は、3 つの数値で答えます。そして、そのどれもが今でもただの合計です。エントリと、記帳を待っている入金を合計したものです。

  1. Posted(記帳済み):これは Tobi のすべてのアカウントにわたる、確定済みの動きの合計です。彼のウォレットに加えて、彼の出金のために確保されたお金を持つアカウントも含みます。つまり最終的な事実です。Tobi の記帳済み残高は 100 USDC です。

  2. Pending(保留中):これはたいていの場合いちばん混乱を招くものです。「保留中の残高」という言葉が 2 つの異なる意味で使われるからです。1 つは保留中の動きだけを指すもので、入ってくる +50 USDC と出ていく -30 USDC、移動中の 80 USDC、差し引き +20 です。もう 1 つは記帳済み残高にその動きを加えたもので、100 + 50 - 30 = 120 USDC、つまり残高が最終的に落ち着く可能性が高い額です。この 2 つは別々の問いなので、1 つのエンドポイントで決して混ぜてはいけません。

  3. Available(利用可能):これは Tobi が今すぐ実際に使える額です。記帳済みの額から出ていく額を引いたもので、100 - 30 = 70 USDC です。まだ確定していない入金は利用可能ではありません。チェーン上でまだ承認されていない 50 USDC は、確定するまで、このウォレットでは使えるお金として存在しないのです。

ここで利用可能残高が 1 つの数値なのは、このウォレットが 1 つのポリシーで動いているからです。多くのシステムはこれを分けていて、Tobi が使える額と出金できる額が 2 つの別々の数値になります。カードでの入金は、アプリ内ではすぐに使えても、支払いには数日間ロックされたままになることがあります。そうしないと、誰かが盗んだカードで入金し、その瞬間に暗号資産として引き出して、チャージバックを抱えるのはあなた、ということになるからです。逆に、一時的なクレジットを付与する企業もあります。何らかの条件に基づいて、確定する前にお金を利用可能にするもので、即時入金はこの仕組みで動いています。米国では Regulation CC が、入金された資金をいつ利用可能にしなければならないかについて、銀行が従うべきスケジュールまで定めています。さらに加盟店の残高には、そのすべての上に現金のバッファやリスクホールドが乗ってきます。

ただし、一時的なクレジットには代償があります。入金が見えた瞬間に Tobi に入金記帳する、DR deposits_in_transit 50 / CR tobi:wallet 50、彼がそれを使う、何らかの妙な理由で入金が失敗する、すると彼のウォレットはマイナスになります。だから台帳は、特定のアカウントについては設計としてマイナス残高を許容し、それを Tobi があなたに負っている債権として追跡しなければなりません。そして、不正による損失や信用リスクが生まれるのはまさにそこなので、どの種類のアカウントがマイナスになってよいかを事前に決めておきます。例えば私たちが作ったような台帳システムには、まさにこのためのフラグ debits_must_not_exceed_credits があります。アカウントにこれを設定すると、借方が貸方を超えてしまうような送金はすべて拒否されます。

お金の流れを追う

では、Tobi のお金の動きを 1 つずつ見ていきましょう。第 1 部と同じく、どれも釣り合いのとれたトランザクションとして記録されます。

彼の 50 USDC の入金は、1 回の承認で検知されます。ご存じのとおり、コインはチェーン上にあってもまだ確定していません。ネットワークは、それを含むブロックを捨てて別のブロックに差し替えることがまだできるのです。これは 「reorg」 と呼ばれます。つまり、見えてはいるけれど、まだ保有してはいないお金です。この時点では台帳には何も記帳されません。代わりに、入金は記帳ではなく独自の追記専用の履歴を持ちます。新しい承認や新しいステータスなど、入金について何かが変わるたびに新しい行が追加され、Tobi の保留中残高はそこから 50 を読み取ります。カストディにも彼のウォレットにも、エントリは一切触れません。カストディは、チェーン上で確定した残高と照合されるものであり、それこそが監査人やプルーフ・オブ・リザーブのシステムが確認するものです。もちろん、標準的なやり方としてです。

現在の残高:記帳済み 100、保留中の動き +50、利用可能 100。

入金が記帳されるのは、チェーンがそれを確定とみなし、さらにカストディプロバイダー側のその入金そのものの記録も一致したときだけです。入金ごとに確認してください。プロバイダーの総残高とだけ比較していると、同時に届いた別の入金のせいで、この 50 がまだ欠けているのに合計が正しく見えてしまうことがあります。そのうえで、1 つのトランザクションになります:

DR custody       50
CR tobi:wallet   50

さて、同じコミットの中で、入金の履歴に入金記帳済みの行が追加され、50 が保留中から利用可能に移ります。現在の残高:記帳済み 150、保留中の動き 0、利用可能 150。何も編集されていません。台帳は、すでに確定していたこの入金について、今ようやく知っただけなのです。

「確定」が何を意味するかはチェーンによって異なります。12 回の承認というのは Proof of Work の話です。Proof of Stake の Ethereum では、本当のシグナルはファイナライズされたチェックポイントで、2 から 3 エポック、通常の日なら約 15 分です。Solana には独自のコミットメントレベルがあり、2026 年 10 月に Alpenglow アップグレードが稼働してからは、confirmed と finalized が同じ意味になりました。Base や OP のようなロールアップでは、シーケンサーがトランザクションを承認するのは最初のステップにすぎず、L1 のファイナリティはその約 20 分後です。結局、ファイナリティはチェーンごとに設定する下限になります。そして、どの入金もどの下限で確定したのかを記録しておくべきです。そうすれば、あるチェーンがおかしな挙動をした日に、どの入金がどのルールで入金記帳されたのかを正確に把握できます。また、プロバイダーが入金を完了とみなしても、まだあなたの下限に達していなければ、レビューのために保留し、何も入金記帳しません。Fireblocks のようなシステムを使っている場合は、ダッシュボードで設定するだけです。

さて、ファイナリティ前の reorg は単純なケースで、入金が失敗したわけではありません。トランザクションはたいてい mempool に戻り、後のブロックに再び取り込まれます。なので入金は未承認の状態に戻り、承認数が最初から数え直されるだけです。この時点で失敗とマークしてしまうと、再び取り込まれたときに 2 つのうちどちらかが起こります。システムがそれを無視して Tobi には一度も入金されず、カストディには誰にも負っていないコインが残るか、まったく新しい入金のように見えて Tobi に 2 回入金されるかです。入金が失敗するのは、それが確実に消えたと証明できたときだけです。例えば Ethereum なら、同じ送信者からの別のトランザクションがその nonce を使い、そのトランザクションが確定したときです。そしてその場合でも、台帳はそれを一度も見ていないので、取り消すものは何もありません。

その 2 つ目のケースこそ、入金のキーが非常に重要になる理由です。1 つのトランザクションが複数の USDC 送金を含むことはありえるので、キーはチェーン、トランザクションハッシュ、そしてそのトランザクションの中での送金の位置になります。Ethereum なら、トランザクションのレシートの中での位置です。この場合は logIndex に手を出さないでください。これはブロック全体を通して数えるので、捨てられたブロックのトランザクションが別のブロックに取り込まれた瞬間に変わってしまいます。そして、各入金が一度だけ入金記帳されることを保証するユニーク制約は、それを記帳する台帳トランザクションの側に置きます。履歴の側に置くと、2 回目の承認の行を拒否してしまいます。そこにあるすべての行が同じキーを持つからです。

Tobi の話に戻りましょう。彼の 50 は無事に通りました。今度は 30 USDC の出金を求めています。どのプロバイダーを呼び出すよりも前に、お金が確保されます:

DR tobi:wallet        30
CR tobi:withdrawals   30

現在の残高:記帳済み 150、保留中の動き -30、利用可能 120。記帳済みがまだ 150 なのは、Tobi のすべてのアカウントにわたって読み取るからです。ウォレットは 120 に下がり、出金用アカウントが 30 を受け取りました。この確保は本物の複式記帳です。だからこそ、出金が記帳される前に利用可能額が下がるのです。30 はまだ手元を離れていなくても、すでに行き先が決まっています。

お気づきかもしれませんが、入金は確定するまで台帳の外にとどまっていたのに、出金は何かが出ていく前に台帳に載りました。これは、それぞれが利用可能額に対して何をするかの違いです。ホールドは Tobi が使える額を減らすので、残高チェックと一緒にコミットしなければなりません。そうしないと、2 つのリクエストが同じ 30 を両方とも使えてしまいます。保留中の入金は、まだ彼が使える額を何も増やさないので、確定するまで外で待っていられます。暫定的なクレジットは唯一の例外で、だからこそその場合は入金がすぐに記帳されるのです。

プロバイダーが送金を届け、払い出しが完了します:

DR tobi:withdrawals   30
CR custody            30

現在の残高:記帳済み 120、保留中の動き 0、利用可能 120。完了の処理は確保を解消するだけです。カストディがレールに支払い、ウォレットには一切手を付けません。もし記帳の際に利用可能額をもう一度差し引いてしまうと、Tobi は 30 を支払っただけなのに、150 が 90 になるのを目にすることになります。

さて、この出金はあえてきれいな形にしておきました。30 を確保して 30 で決済しています。実際の出金がそうなることはめったにありません。ガス代はトランザクションがマイニングされるまでわかりませんし、プロバイダーは独自の手数料を取ります。そして USDC 送金のガス代は、別の資産である ETH で支払われます。そのため、ホールドする額と最終的に決済される額が一致することはほとんどありません。そこで上限を確保します。30 に、手数料として考えられる最大額、例えば 2 を加えた額です:

DR tobi:wallet        32
CR tobi:withdrawals   32

そのうえで、実際の金額で決済し、残りを解放します。これを 1 つのトランザクションで行います:

DR tobi:withdrawals      32
CR custody            30.00
CR revenue:fees        0.40
CR tobi:wallet         1.60

利用可能額は処理中に 32 下がり、最終的に 119.60 に落ち着きます。そして出金用アカウントは差し引きゼロのままです。ネットワークが実際に請求したガス代はあなたの費用であり、自分の ETH から別の動き DR expense:gas / CR treasury:eth として支払います。第 1 部のチェックは資産ごとに実行されるので、これはそれ単体で釣り合います。0.40 は今やあなたの収益なので、これも顧客のカストディから移しておきます。DR treasury:usdc 0.40 / CR custody 0.40 です。カストディには顧客のお金だけを置いておきます。そこからガス代を払うと、出金のたびにほかの全員のお金が少しずつ削られていきます。そしてそれが最初に表に出るのは、資産ごとに保有している額とユーザーに負っている額を比べる準備金の cron で、不足という結果が返ってきます。

カードのレールでは、これがさらに難しくなります:

出金が失敗したらどうなるか

ここでの展開は 3 通りあり、そのうち失敗は 2 つだけです。

単純なエラー:保留中の出金が記帳される前に拒否される場合です。例えば、アドレスがスクリーニングに通らなかったのかもしれません。確保は解放されます:

DR tobi:withdrawals   30
CR tobi:wallet        30

利用可能額は 150 に戻ります。お金はとどまっていたので、記帳済み残高は一度も動いていません。

高くつくケース:払い出しが記帳され、チェーンもそれを承認したのに、数日後に送り先の取引所がそれを送り返してくる場合です。その返金は、それ自体が新しいお金の動きです。チェーン上ではただの入ってくる送金なので、ほかの入金とまったく同じ道をたどります。独自の履歴を持ち、あなたのファイナリティの下限を満たし、返金元となる払い出しそのものと照合され、そこで初めて記帳されます。記帳するのは実際に届いた額です。送り返してくる取引所が自分の手数料を取ることもあるので、30 が出ていって、戻ってくるのは 29.50 かもしれません:

DR custody       29.50
CR tobi:wallet   29.50

元の払い出しは履歴に残り、返金も同じく残ります。

そして 3 つ目のケースがあります。たいていの人が損失を出すのはこれです。プロバイダーがタイムアウトして、払い出しが届いたかどうか誰にもわからない場合です。ステータス不明を失敗として扱ってはいけません。失敗として扱って 30 を解放したのに、結局チェーン上に届いてしまえば、あっという間に 2 回支払ったことになります。おめでとうございます!

実際の標準は、結果が不明なものは、プロバイダーが答えるまで保留中のままにしておく、というものです。

ただ、システムの作りによっては、まったく答えてくれないプロバイダーもあります。なので、不明な状態を永遠に保留中にしておくこともできません。はしごを一段ずつ下りていき、各段階で台帳トランザクションによって解決するか、次の段階に引き継ぐかします:

  1. webhook をただ待つのではなく、プロバイダーに直接ステータスを問い合わせます。「再照会」です。
  2. プロバイダーの記録やチェーンそのものと照合します。どちらにするかはあなた次第です。
  3. バックオフィスで承認を得たうえで、償却します。

そして、何かが詰まっていることに気づくためには、清算用アカウントが 1 つの数値のままではいけません。例えば Tobi が 30 の出金を 2 回行い、1 つは決済され、もう 1 つはいつまでも決済されないとします。tobi:withdrawals には 30 と表示されますが、どちらも 30 だったので、その数値からはどちらが詰まっているのかわかりません。そこで、何かを決済または解放するすべてのエントリが、それによって閉じる元のエントリをピンポイントで指すようにします。これはオープンアイテム照合、いわゆる消込で、売掛金管理からそのまま借りてきた考え方です。そして、レールの SLA を過ぎてもまだ開いたままのものがあれば、アラートが発火します。

履歴は残る

ここまでの Tobi のお金の動きを振り返ると、既存のエントリが更新されたことは一度もなく、入金の履歴の行も 1 つとして更新されていません。状態が変わったのは、古いトランザクションの上に新しいトランザクションが積み重なったからであり、それがこの記事の後半のテーマです。

この種のシステムは、修正が入ることを前提に設計されています。ゼロが 1 つ多いエントリや、間違ったアカウントに記帳された動きなどです。深夜 2:45 には UPDATE したくなる誘惑に駆られます。データは修正用の txn で直すもので、行の更新で直すことは決してありません。修正は、借方と貸方を入れ替えて元のトランザクションを鏡写しにし、元のトランザクションを指し示し、さらに理由とソースを持たせて、あとから見たときに記録そのものが経緯を説明できるようにしなければなりません:

function reverse(original: Txn, reason: string, source: string): Txn {
  return {
    id: randomUUID(),
    referenceId: original.id,
    effectiveAt: original.effectiveAt,
    reason,
    source,
    entries: original.entries.map((e) => ({
      accountId: e.accountId,
      side: e.side === "DR" ? "CR" : "DR",
      amountMinor: e.amountMinor,
      assetId: e.assetId,
    })),
  };
}

すべてのフィールドを名前で 1 つずつコピーしているのは意図的です。元のエントリを ...e で展開してしまうと、修正側にも元の id や timestamps まで入ってしまいます。2 つの txn を合わせると差し引きゼロになり、最終的な残高がその経緯を隠すことは決してありません。サポートは Tobi の履歴、つまり入金自体の行と台帳を再生して、入金、承認、確保、解放を順番に確認できます。DB も第 1 部のルールと同じようにこれを裏付けます。エントリのテーブルに対する UPDATE や DELETE は拒否されるのです! これについては迷う余地はありません。

reverse() が書かれたままの形では防げないことが 1 つあります。同じトランザクションを 2 回取り消すことです。2 回実行すれば、Tobi はお金を 2 回取り戻すことになります。あるトランザクションの取り消しの合計が、元の額を超えることは決してあってはなりません。単純なユニークインデックスで 2 回目の全額取り消しは防げますが、合計を計算することはできないので、全額取り消しと一部取り消しの組み合わせはすり抜けてしまいます。本当のチェックは、すでに取り消された額を読み取り、新しい取り消しを 1 つのアトミックな txn として書き込むもので、それは第 3 部の問題です。そこで詳しく話します。

保存された残高は一致しなければならない

第 1 部では、保存された残高はログと一致しなければならないキャッシュだと説明しました。残高が 3 つになると、そのキャッシュが支出を承認するものになるので、事の重大さが増します。出金の承認に使う残高は、コミット済みの確保と記帳を反映していなければならず、エントリを書き込むのと同じコミットの中で更新されなければなりません。そうしないと、その間に行われた読み取りが、すでに使い道の決まったお金を見てしまいます。

第 1 部のテストは今でも有効です。保存された数値をすべて捨て、台帳と入金の履歴を再生すれば、同じ 3 つの残高が得られます。そして保留中の部分は、いつでもチェーンを新たにスキャンして確認できます。前述のチェックポイントがあれば、再生はこれまでに書かれた最初のエントリからではなく、最後に検証されたチェックポイントから始められます。それが、このテストを大規模でも実用的なものにしています。そして、保存された数値と再生した数値が一致しないときは、照合ジョブが両者を比較し、ずれがあればアラートを出します。それから、どちらが嘘をついているのかを突き止めます。エントリが正しくてキャッシュが間違っているなら、エントリからキャッシュを再構築します。その修正に新しいお金の動きが入り込む余地はありません。エントリそのものが間違っているなら、上で見たような修正用の txn を記帳します。いずれにせよ、その数値を再び信用する前に調査します。このアラートは譲れません。

補足: 保存された残高は合計であり、合計には余裕が必要です。USDC は Ethereum では小数点以下 6 桁、Stellar では 7 桁で、ETH は 18 桁です。符号付き 64 ビット整数の上限はおよそ 9.22 × 10¹⁸ で、wei で言えばわずか約 9.22 ETH、まとまった入金 1 回分にすぎません。Postgres の bigint はまさにその 64 ビット整数です。第 1 部の TypeScript の bigint と同じ言葉ですが、上限はまったく違います。128 ビットの金額を使いましょう。私たちもまさにそうしています。Postgres では、スケール 0 の numeric を使うということです。TypeScript では number ではなく bigint を使い続け、各資産のスケールは資産と一緒に保存してください。30000000 は、それが小数点以下 6 桁の USDC だとわかるまで何の意味も持ちません。

さて、高負荷の下でコミットそのものをどう安全に保つか、ロックなどについては、第 3 部で扱います。

その時点の残高はいくらだったのか?

すべての動きには 2 つの時計があります。それが有効になった時点と、システムがそれを記録した時点です。カードの決済は、有効日が昨日で届くのが今日ということがありますし、プロバイダーの webhook が数時間遅れて届くこともあります。今日、昨日の残高を再構築すると、昨日の時点ではシステムがまだ知らなかった動きが含まれます。その数値は、昨日について今わかっていることであって、当時わかっていたことではありません。

修正と返金も、それぞれ有効日を選ばなければなりません。記帳ミスの修正は、元のトランザクションの作成日を引き継ぎます。元のトランザクションは一度も正しくなかったからです。上の reverse() が original.effectiveAt をコピーしているのはそのためです。返金は今日の日付を持ちます。払い出しは実際に行われ、お金は本当に後から戻ってきたからです。この 2 つを逆にしてしまうと、その期間のある時点の残高がすべて大きく間違って出てきて、また損をすることになります。

それから、締め済みの期間があります。ある期間が締められると、つまり明細書が送られ、報告書が提出され、利息が計上されると、その期間内に有効日を持つ遅れたエントリでも、もうそれらの数値を変えることはできません。そのエントリは、開いている期間に、過去を指す有効日を持つ期間外調整として記帳します。一般的な銀行では、いつから効力を持つかを起算日、いつ記帳されたかを記帳日と呼んでいます。そして、起算日を遡ったエントリがあるということは、利息や手数料が間違った残高で計算されていたということなので、その再計算も新しい調整トランザクションとして入れます。

これでもまだ保証できないこと

ここでのモデルは正しいものです。3 つの残高があり、追記専用の履歴があり、キャッシュは一致しています。さて、ここで 80 USDC ずつの出金が 2 件、1 ミリ秒違いで届いたとします。どちらも利用可能残高 120 を読み取り、どちらもチェックを通過し、どちらも確保を行います。120 しかないのに 160 が出ていき、履歴はあなたがどうやってそれを失ったかを淡々と記録するだけです。

ここまでの内容のどれもそれを止めませんし、ここまでの内容のどれも間違っていません。残高と履歴はお金を説明するものであって、書き込みを守るものではありません。2 つのリクエストは今でも同じ利用可能残高を読み取り、その中で両方とも書き込めてしまいます。その書き込みをアトミックにすること、つまりチェックと確保を一緒にコミットするか、まったくコミットしないかにすることが、第 3 部「トランザクションとアトミック性」のテーマです。

•••
この記事をシェア:
シリーズ
台帳シリーズ

最新情報を受け取る

最新の記事をメールでお届けします。