第8巻 処理方式設計書(4) ― クロスカレンシーFXとレガシー勘定系アダプタ
目次
- 第17章 クロスカレンシーFX 処理方式(Project Agorá / Icebreaker 参照)
- 17.1 設計方針
- 17.2 役割
- 17.3 FX レート・マーケットプレイス
- 17.4 取引構造(デフォルトはGTID、任意でHTLCレイヤを追加)
- 17.5 均衡検査の置換(FXP 導管不変条件)
- 17.6 決済・ファイナリティ(脚ごと・通貨別レール)
- 17.7 流動性・H枠
- 17.8 ワークドエグザンプル
- 第18章 レガシー勘定系アダプタ 処理方式
- 18.1 6つの施策と、その検証
- 18.2 ベンダー接続可否(勘定系ベンダーの目線で) <a id="vendor-connectability"></a>
第8巻 処理方式設計書(4) ― クロスカレンシーFXとレガシー勘定系アダプタ
本巻は
docs/specs/20_method_design.mdの第17章〜第18章(クロスカレンシーFX処理方式/レガシー勘定系アダプタ処理方式)を収める。前巻→第7巻の続き。
第17章 クロスカレンシーFX 処理方式(Project Agorá / Icebreaker 参照)
機能別索引:FXは要件定義(
10_requirements.md)・処理方式(本書 第17章)・内部設計(30_internal_design.md)の3文書にまたがる。本章は方式(役割・レート形成・取引構造・決済・流動性・ワークドエグザンプル)を扱う。
参照: BIS Project Agorá(統合台帳・トークン化準備/預金・原子決済)と
BIS/Bank of Israel/Riksbank Project Icebreaker(ハブ&スポーク・FXプロバイダ・
HTLC PvP・ブリッジ通貨)。本書のFXはIcebreakerの考え方を参照点としつつ、
実装は既存の GTID(複数の取引をひとつのグループとして束ね、一括して確定させる仕組み)
レーンの上に最小限の追加で構築する(既存コードへの接地は
30_internal_design.md§17.4)。
17.1 設計方針
| 論点 | 採用 |
|---|---|
| アトミシティ機構 | デフォルトはGTIDのみ: FXPを導管とする脚分解により各通貨が両側で均衡し、既存のGTID多脚協調がそのまま原子性を担う(HTLC不要)。任意で bind_htlc=true を指定すると、共有ハッシュロック+段階タイムロックを上乗せし、決済を secret 公開(claim)まで遅延させるクロスレール原子性レイヤを追加できる |
| 為替レート | Icebreaker 方式: 複数 FXP が方向別レートを提示、ZC が最良実効レートの経路を選定、ブリッジ通貨(中間通貨1つ、PvPvP) に対応 |
| FXP 主体 | 参加銀行が FXP を兼務(複数通貨に事前流動性を保有) |
| 中銀ファイナリティ | 脚ごと独立確定(JPY→BOJ-Net/DNS、非JPY→トークン化中銀当座/CB_TOKEN)。経済的な原子性はデフォルトでは導管GTIDの多脚協調、bind_htlc=true 使用時はそれに加えてHTLCハッシュロックが担保する |
17.2 役割
- Payer / Payee: 参加銀行の顧客。payer が支払う通貨をA、payee が受け取る通貨をBとする。
- FX プロバイダ (FXP): 参加銀行が兼務する。2通貨以上について事前に流動性
(各通貨の中銀当座/CBT 残高+通貨別 H 枠)を保有しておき、レートを提示する。 - ZC: Icebreaker でいう「ハブ」に相当する役割を担う。具体的には、公平なブローカー
(レートの集約・最良経路の選定・事前提示を行う)と、コーディネータ(導管GTIDの登録・
前進、および任意で HTLC の lock/claim/refund の起動を行う)を兼ねる。デフォルト経路での
原子性(取引が「全部成立」か「全部不成立」のどちらかにしかならない性質)は通常の GTID
協調がそのまま担い、bind_htlc=trueを使う場合はそこにハッシュロックが上乗せされる。
つまり ZC 自身が原子性を単独で支える単一障害点になる設計ではない。
規範(透明性): ZC は payer が承認する前に「採用 FXP・実効レート・総コスト・経路・
見積有効期限」を提示する(Icebreaker が求める中立ブローカーの要件)。ただし
POST /api/fx/transfersで確定する際には ZC が経路を必ず再計算するため(本書§17.5・
30_internal_design.md§17.2「権威的再プライシング」)、事前に提示した内容がレート変動なしにそのまま確定するという保証ではない点に注意が必要である(
min_effective_rateを指定すれば防御できる)。
17.3 FX レート・マーケットプレイス
前提(重要): レート形成はZCのスコープ外。ZC は価格を作らない。
このシステムは FXP がレートを決定する場所ではない。実効レートのプライシング
(建値・スプレッド・ヘッジ・在庫/市場リスク判断)は、各 FXP が系の外で自らの
裁量により行う。ZC 側には為替オラクル・参照レートフィード・レート算出ロジックは
一切存在しない。
ZC が行うのは次の3つだけ:
- 投入の受理: FXP が外で決めた方向別レートを
PUT /api/fx/ratesで受け取り、FxQuotesにそのまま保存する(形式検査と FXP 資格ゲートのみ。値は補正しない)。- 集約と最良選定: 投入済みの ACTIVE 見積を読み、最良実効レートの経路を選ぶ
(routing.ts。直接+ブリッジ合成。価格は作らず、並べて選ぶ)。- 凍結と逆行ガード: 見積有効期限(
valid_to)と、確定時のmin_effective_rate
による不利方向ガード(api.ts)。これは Icebreaker の中立ブローカー思想に忠実な役割分担であり、ZC は意図的に
価格形成の信頼点にならない(原子性の信頼点にもならない=本書§17.2)。丸め益の帰属
(デフォルトでFXP)や流動性連動の動的上限といった「レート周りで系内に踏み込む余地」は
任意の発展として
30_internal_design.md§17.4「将来課題」に分離してある。
17.3.1 レート表現(整数固定小数)
本コードベースは整数マネー前提。レートも固定小数(RATE_SCALE = 1e8)の整数で持つ
(src/zc/fx/rates.ts、内部演算はオーバーフロー回避のため BigInt)。
- 方向別レート
rate(X→Y)= 1 単位 X が何単位 Y になるか(×RATE_SCALE)。
bid/ask は「A→B」と「B→A」の2方向の見積として表現する。 - 変換(payer 建て、
convertForward):amount_B = floor(amount_A × rate(A→B) / RATE_SCALE)。 - 変換(payee 建て、
convertBackward):amount_A = ceil(amount_B × RATE_SCALE / rate(A→B))。 - ブリッジ合成(
composeRates)も同様に floor(下振れ方向)で丸める。 - 端数は常に FXP に不利にならない方向へ丸める(payer/payee 保護)。差分(丸め益)の
帰属はスキームルールで定義(デフォルト: FXP。明示的な帰属ルールの実装は30_internal_design.md§17.4「将来課題」)。
17.3.2 FxQuotes
FxQuotes(FXP の方向別レート市場)のスキーマ定義(列・索引)は 31_schema.md § FxQuotes を正とする(二重管理を避けるため本節ではDDLを再掲しない)。要点のみ:quote_id 主キー、fxp_bank_id/from_currency/to_currency/rate(×RATE_SCALE)/valid_to(有効期限)/status(ACTIVE|WITHDRAWN)を持ち、索引は idx_fxq_pair・idx_fxq_fxp。
FXP は PUT /api/fx/rates で自行・自ペアの見積を upsert する(src/zc/fx/quotes.ts のupsertQuote。同一 FXP+同一通貨ペアの ACTIVE 行を in-place 更新し version を進める。
同名の REST 動詞での認可は、参加銀行向けの汎用 API ゲート(同一オリジン/APIキー等の既存
仕組み)に加えて、ハンドラ内で Participants.is_fx_provider=1 かつ is_active=1 を検査する
だけであり、レート値自体に対する HMAC/KeyRegistry のような追加の暗号的署名検証は無い。
17.3.3 最良経路エンジン(routing、src/zc/fx/routing.ts)
要求: (from_currency, to_currency, amount, denomination, max_bridge_hops?) → 最良経路。
- 直接経路:
from→toの ACTIVE 見積から、payee 受取最大(payer 建て)/
payer 支払最小(payee 建て)となる FXP を選ぶ。amountが見積の[min_amount, max_amount]
内であること。 - ブリッジ経路:
max_bridge_hops(デフォルトは1、リクエストの任意パラメータ)が 1 以上のとき、
from から到達できる中間通貨 C を1つだけ挟む合成を試す:rate(from→C) × rate(C→to) / RATE_SCALE(例:rate(JPY→EUR)=1,500,000=0.015 とrate(EUR→USD)=110,000,000=1.1 を合成すると1,650,000=0.0165。本書§17.8.2)。
各ホップで別 FXP を許容し、両ホップの[min_amount, max_amount]を満たす必要がある。 - 選定: 直接とブリッジの実効レート(
amount_to/amount_from)を比較し最良を採用
(同額なら脚数が少ない方=直接を優先、isBetter())。 - 出力 =
FxRoute { hops: FxHop[]; effective_rate; amount_from; amount_to; expires_at }。FxHop = { fxp_bank_id, from_currency, to_currency, amount_in, amount_out, quote_id }。
ブリッジは中間通貨1つ(2ホップ)までという構造的な上限がある。これは
max_bridge_hopsに2以上の値を渡しても変わらない —実装は「直接 1 つ」と「中間通貨 1 つのブリッジ 1 つ」しか評価しないため、再帰的な
多段ブリッジは存在しない(
max_bridge_hops=0を渡すと直接のみに制限できる)。
17.4 取引構造(デフォルトはGTID、任意でHTLCレイヤを追加)
17.4.1 デフォルト経路: FXP導管GTID(HTLCなし、即時進行)
FX 取引の本体は「FXP を導管とする GTID」である。src/zc/fx/transfer.ts のbuildFxEdges が経路を脚(edge)に分解し(payer→fxp₀→…→payeeの順)、各脚をbuildFxGtidLegs が GTID の PAYER/PAYEE 脚ペア(leg_id = ${gtid}~L<NN>P /~L<NN>Q)に変換する。各脚は単一通貨で、FXP が両側に立つため通貨ごとに
payer 合計 == payee 合計が自動的に成り立つ(10_requirements.md §6.1.2 解決原理・本書§17.5)。
initiateFxTransfer は次を行う:
- 経路上の全ホップの quote が ACTIVE かつ有効期限内であることを検証(失効していれば
FX_QUOTE_EXPIRED)。 FxTransfersに事実を記録(status='INITIATED'、hashlockは未指定なら生成するが
この経路では決済の進行を制御しない)。registerGtidで導管 GTID を登録する。
initiateFxTransfer 自身は advanceGtid を呼ばない。registerGtid が積むZC_BANK_LEG_READY キューメッセージを消費する processQueueMessage
(src/zc/orchestrator.ts)が advanceGtid を呼び、GTID を前進させる。GTID がGT_SETTLED に達すると checkAndFinalizeGtid(src/zc/orchestrator/gtid.ts)がFxTransfers.status を 'SETTLED' に更新する(FXとは無関係なGTIDには無害な no-op)。
17.4.2 任意レイヤ: HTLC によるクロスレール原子性(bind_htlc=true)
POST /api/fx/transfers に bind_htlc: true を指定すると、決済を即時に進めずlockFxTransfer(src/zc/fx/htlc.ts)が起動する。
- lock: 経路上の全 quote の生存(ACTIVE・有効期限内)だけを検査し、各脚を独立表
FxLegLocksにLOCKEDとして記録する。この時点では GTID は登録されず、H予約も
一切起きない(流動性検査は無い。本書§17.7)。全脚が同一hashlock(=SHA-256(secret))を
共有し、timelockは脚ごとに段階的(上流ほど後に満了、本書§17.4.3)。
17.4.2.1 claim/refund のシリアライズ:FxTransfers.status を単一の権威ゲートにする
claim と refund はいずれも複数オペレーション(FxLegLocks の一括更新、registerGtid/advanceGtid による GtidTransactions/GtidLegs・H 予約・レーン行の生成)にまたがり、
これらを単一トランザクションに包むことはできない(クロスレールの境界)。そこで両者の
決定そのものを、FxTransfers.status 列に対する 1行・1回の CAS に集約して相互排他
させる:
LOCKED ──claim──▶ SETTLING ──▶ SETTLED
└────refund──▶ REFUNDED
LOCKED を離脱できるのは claim(LOCKED→SETTLING)か refund(LOCKED→REFUNDED)の
どちらか一方だけで、これは単一ノードでもCASを内包する分散SQLでも原子的に成り立つ。
ゲート通過後の各ステップ(脚の WHERE state='LOCKED' CAS 更新・冪等なregisterGtid/advanceGtid・最後の SETTLING→SETTLED CAS)はすべて冪等なので、
途中でクラッシュしても SETTLING のまま残り、再試行が再開(resume)する(その間
refund は割り込めない)。分散トランザクションを使わずに「部分決済も二重決済もしない」を
担保するサーガ型の作法である。並行・割り込みの検証はtest/integration/concurrent_races.test.ts(Promise.all による await 境界での
インターリーブ)が固定する。
- claim: payee が知り得た
secretをPOST /api/fx/transfers/{gtid}/claimに渡す。
まずsha256(secret) == hashlockを検証(誤 secret はPREIMAGE_MISMATCHで、状態を
一切変えずに拒否)。次にLOCKED→SETTLINGの CAS でゲートを取得した勝者だけが、全LOCKED脚を 1回のバッチで一括CLAIMEDに更新し(1回の secret 公開で全脚が同時に
解放される)、registerGtid+advanceGtidで導管 GTID を起動して決済を進め、最後にSETTLING→SETTLEDを CAS する。ゲートを取れなかった呼び出しは確定済み status を見て
分岐する:REFUNDEDならFX_ALREADY_REFUNDEDで拒否、SETTLEDなら冪等成功
(already: true)、SETTLINGなら冪等に決済を再開する(勝者のみalready: false)。
実際の進行状況は応答のgtid_state(GtidTransactions.stateの生値)で追う。 - refund: secret が来ないまま、全脚の中で最も遅い(最上流の)timelockが経過した
場合に限りPOST /api/fx/transfers/{gtid}/refundで払い戻す。claim と対称に、まずLOCKED→REFUNDEDの CAS でゲートを取得した場合のみ全脚を 1回のバッチで一括REFUNDEDにする。スナップショット時点で脚がLOCKEDに見えても、claim が先にゲートを
取ってSETTLING/SETTLEDに進んでいればこの CAS は失敗し、refund はSTATE_GUARDで
拒否される(脚ごとの CAS だけでは閉じられなかった多オペレーション境界をここで閉じる)。
1脚でもCLAIMEDの場合の早期STATE_GUARDガードも併存する。GTID は一度も登録されない
ため資金は一切動かない。 - sweep: 期限切れのまま放置されたロックは
sweepExpiredFxLocksが検出してrefundFxTransferを呼ぶ。runTimeoutSweep(src/cron/timeout_sweep.ts)に毎分
接続する(30_internal_design.md§17.4)。claim と sweep が競合した場合は上記のFxTransfers.statusゲートで
一方だけが成立し、敗者は安全にスキップされる。
17.4.2.2 SETTLING に期限を置く(規範)
上のゲートは claim と refund を相互排他にすることで「部分決済も二重決済もしない」を
担保するが、その代償として SETTLING は refund が割り込めない区間でもある。claim が
途中でクラッシュしたまま再試行が恒久的に失敗すると(下流レールの長期障害など)、当該
gtid は決済も払戻もされないまま滞留する。sweepExpiredFxLocks はこれを拾えない——
同 sweep の抽出条件は FxLegLocks.state='LOCKED' であり、ゲートを取った勝者は既に脚をCLAIMED へ落としているためである。サーガの中で唯一、有界な帰結を持たない窓が
ここに開く。
- 期限(規範):
SETTLINGへ入った時刻から、次の 2 つのうち早い方を期限とする。
(a)FX_SETTLING_TIMEOUT_MS(6h)、(b) 最上流の脚の timelock から 1 ホップ分
(FX_HTLC_HOP_MARGIN_MS)を差し引いた時刻。実効的に効くのは (b) である——当該
timelock は上流を claim できる最後の時点であり、これを越えてなお決済中であることは、
§17.4.3 が防いでいるはずの「下流に払ったのに上流から回収できない」そのものだからである。
実装はsettlingDeadline(src/zc/fx/htlc.ts)。 - 開始前の残余検査(規範):claim は、ゲートを取る前に上記の残余時間を検査し、
零以下であればゲートを取らずに払戻側へ倒す(FX_CLAIM_WINDOW_EXPIRED)。間に合わないと
判っている決済を開始してSETTLINGに置くことは、refund が割り込めない区間を無駄に作る。 - 超過時の収束(規範):期限を過ぎた
SETTLINGはsweepStuckFxSettlingが拾い、
GTID をGT_SUSPENDEDへ落として CASE を起票する。決済も払戻も強制しない——この
時点で脚はCLAIMEDであり、いずれかのレールで資金が動いている可能性があるため、
安全な収束は宣言済みの経路(中断+例外)である。回帰試験はtest/zc/fx_htlc.test.ts
§「SETTLING is bounded」。
17.4.3 タイムロック(bind_htlc=true の場合のみ)
上流ほど長くする。downstream(payee 側、最終脚)を基準 T_base とし、上流の脚ほどΔ を多く加算する。脚の総数を total、脚 index(0始まり、0が最も上流)として:
timelock(index) = now + T_base + (total − 1 − index) · Δ
定数は FX_HTLC_BASE_TIMEOUT_MS(24h)・FX_HTLC_HOP_MARGIN_MS(12h)
(src/zc/fx/htlc.ts)。これにより「下流がクレームされたら上流は必ずクレーム可能」
「上流が払う前に下流の払いが確定する」を保証する(FXP が払い損ねない)。Δ は脚の
中銀確定レイテンシ(非JPY CBT のチェーン確定時間を含む)の最悪値以上に取る想定。
17.4.4 ライフサイクル
- Quote: payer が
POST /api/fx/quoteを呼び、ZC が最良経路を返す(FxRoute、確定ではない)。 - Initiate: payer が
POST /api/fx/transfersを呼ぶ。ZC は渡された経路を信用せずfindBestRouteを再実行(権威的再プライシング、本書§17.5・30_internal_design.md§17.2)。fxp_accountsで経路上の
全 FXP×全通貨の決済アカウントが揃っているか検査(不足は 400FX_FXP_ACCOUNT_MISSING)。min_effective_rateを指定していれば悪化していないか検査(409FX_RATE_MISMATCH)。 - (デフォルト) 即時導管GTID:
bind_htlcを指定しない場合、initiateFxTransferがFxTransfers(status='INITIATED')を記録し導管 GTID を登録する。201FX_TRANSFER_INITIATEDを返す。以降はキュー経由のadvanceGtid/checkAndFinalizeGtid
が進め、GT_SETTLEDになった時点でFxTransfers.statusがSETTLEDになる。 - (任意) HTLCロック:
bind_htlc: trueの場合、lockFxTransferがFxTransfers
(status='LOCKED')と全脚のFxLegLocks(LOCKED)を記録するだけで止める。201FX_TRANSFER_LOCKED、hashlockと(生成した場合)secretを返す。 - Claim(HTLC経路のみ): payee が
secretでPOST /api/fx/transfers/{gtid}/claim
を呼ぶ。一致すれば全脚が一括CLAIMEDになり、導管 GTID が登録・前進する。200FX_TRANSFER_CLAIMED。 - Refund(HTLC経路のみ): secret が来なければ、
POST /api/fx/transfers/{gtid}/refund
(または cron のsweepExpiredFxLocks)が最遅 timelock 経過後に全脚を一括REFUNDED
にする。200FX_TRANSFER_REFUNDED。資金は一切動いていない。 - Status:
GET /api/fx/transfers/{gtid}でいつでもFxTransfersの事実+GtidTransactions.state(gtid_state)+(HTLC経路なら)leg_locksを確認できる。
17.4.5 状態(実装の実値)
- 導管 GTID 自体はFX専用の状態を持たない。通常の
GtidState(GT_RECEIVED→
… →GT_SETTLED/GT_CANCELLED/GT_SUSPENDED等)がそのまま使われる。 FxTransfers.status:INITIATED(即時経路で作成直後)|LOCKED(HTLC経路でロック直後)|SETTLING(HTLC claim の権威ゲートLOCKED→SETTLINGを取った勝者が決済を実行している間の
過渡状態。本書§17.4.2 のサーガ図LOCKED→SETTLING→SETTLEDの中間で、勝者だけがこの値を書く)|SETTLED(GTID がGT_SETTLEDに達した、または HTLC claim が成立した)|REFUNDED(HTLC経路でタイムアウト払戻)。実際に存在する値は上記の 5 つ(うちSETTLINGは
claim 実行中にのみ現れる過渡値)であり、CANCELLEDは実装では一度も書かれない。
DDL コメント(migrations/0001_consolidated_schema.sql/31_schema.md § FxTransfers)は
かつてINITIATED|SETTLED|CANCELLEDという歴史的な誤りを残していたが、本 5 値へ是正済み。FxLegLocks.state(HTLC経路でのみ行が存在する):LOCKED→CLAIMED|REFUNDED。SETTLEDの書き込みタイミングは経路で異なる(本書§17.4.1・§17.4.2 で述べた非同期/即時の違い)。
どちらの経路でも、実際の決済進行を正確に追うにはgtid_stateを見るのが確実。
17.5 均衡検査の置換(FXP 導管不変条件)
本節の結論(先に述べる):FX 専用の均衡検査は設けない。
FXP を明示的な導管として両側に置く脚分解(
buildFxEdges/buildFxGtidLegs)を採るため、各通貨は脚の中で payer 合計 == payee 合計に自動的に一致し、既存の
AMOUNT_BALANCE_MISMATCH検査をそのまま通る(接地は30_internal_design.md§17.4)。以下は、当初「置換すべき」と考えた 4 つのガードについて、なぜ不要になったかを
個別に示すものである(同じ検討を繰り返さないための経緯)。
- 脚内ゼロサム: 各脚は単一通貨の振替=銀行元帳で通貨別ゼロサム(
amount_currency)。
FXPの導管化により自動的に成立し、専用の検査は不要。 - レート整合: 当初は「
amount_out == convert(amount_in, quoted_rate)をホップごとに
検査し、違反はFX_RATE_MISMATCH」という独立ガードを想定した。実装ではrouting.ts
がamount_outを常に quote から計算して生成する(クライアントから受け取ったamount_outを後から検査するのではない)ため、この種の不整合はそもそも作れない。FX_RATE_MISMATCHという reason_code 自体は実在するが役割が異なり、POST /api/fx/transfersで価格発見から確定までの間にレートが不利な方向へ動いていないか
を、呼び出し側が任意で渡すmin_effective_rateと再プライシング結果の比較で守る
用途に使われている(api.ts、30_internal_design.md§17.2)。 - 導管整合: 当初は「経路の連結性(脚 k の出側 == 脚 k+1 の入側)」を検査し、違反を
FX_ROUTE_INCONSISTENTとする想定だった。実装ではbuildFxEdgesが経路の連結を
構築時に保証するため、構築後に壊れた経路を検査する出番がない。FX_ROUTE_INCONSISTENTはREASON_CODE_CATEGORYに予約されているが、現在どこからも
投げられていない。 - 見積有効性: これだけは専用の検査が要る——採用した経路の全 quote が確定(または
HTLC ロック)の直前に ACTIVE かつ有効期限内であることを検査し、違反はFX_QUOTE_EXPIREDとする。1〜3 と違い、時間の経過で後から壊れる性質のものは、
構築時の保証では閉じられない。
流動性不足(FXP の通貨残高/H 不足)も同様に専用ガードを置く必要がなかった。これは
導管 GTID が実際に前進する際、既存の GTID レーンの H 予約失敗パスがそのまま検出し、
GTID 自体を安全にキャンセル/中断するためである(
chaos_fx.test.ts#1, #7)。予約済みの
FX_LIQUIDITY_INSUFFICIENTreason_code は、この汎用パスに乗るため現在どこからも投げられていない。
17.6 決済・ファイナリティ(脚ごと・通貨別レール)
各脚は claim(デフォルト経路では GTID 前進時、HTLC経路では claim 成立時)に自通貨のレールで
独立確定する(既存の決済基盤をそのまま再利用):
- JPY 脚: BOJ-Net / 通貨別 DNS サイクル、または HIGH_VALUE 相当の IGS(即時グロス)。
- 非JPY 脚: トークン化中銀当座(CBT)。
settlementAccountId(bank, ccy, chain)で{bank}-CBT-{CCY}-{CHAIN}へ。確定の信頼アンカーはvenue='CB_TOKEN'の発行体署名付きSettlementProofRef(KeyRegistry検証、source='CB_TOKEN:{中銀}:{チェーン}')。
経済的原子性は経路によって担う層が異なる。デフォルト(HTLCなし)経路では、GTID レーン
自体の多脚協調が原子性を担う:いずれかの脚が確定前に失敗すれば GT_SUSPENDED 等へ
遷移し、FxTransfers.status は SETTLED に到達しない(途中半端な決済は起きない、chaos_fx.test.ts #5)。bind_htlc=true の経路では、これに加えて共有ハッシュロックが
「secret 公開まで一切の GTID 登録・資金移動を起こさせない」という保証を上乗せする
(同一 secret で全脚が一括解放されるため、部分クレームは構造的に存在しない)。
中銀ファイナリティ自体は脚ごと(結果整合):各脚が各々のレールで確定し、HTLC使用時の
段階的タイムロック設計が「上流は下流の確定を見てから確定/払戻を判断できる」ことを保証する。
FXP の市場リスク(レート変動)は見積をロック/確定した時点で FXP が負う
(
chaos_fx.test.ts#3: ロック後に quote が失効しても claim はロック時のレートで決済される)。決済リスク(相手方の不履行)は、デフォルト経路では GTID の中断(
GT_SUSPENDED、資金は動かない)で吸収され、HTLC 経路ではタイムロック払戻で吸収される(Icebreaker / Agorá と同じ結論)。
17.7 流動性・H枠
- 各脚の出側当事者がその通貨で H 予約する(通貨別 H=
ParticipantCurrencyLimits、reserveHは GTID 脚のrole==='PAYER'のものにのみ適用、src/zc/lanes/gtid/advance.ts)。
payer は通貨A、FXP は通貨B(ブリッジでは通貨Cも)を予約する。 - この H 予約は導管 GTID が実際に
advanceGtidで前進する時点で起きる。デフォルト経路ではinitiateFxTransfer直後(キュー経由)、HTLC 経路では claim 成立後(registerGtid+advanceGtidを呼ぶのは claim 時)。つまりlockFxTransfer自体は H 予約も流動性検査も
一切行わない — 検査するのは経路上の quote が生きているかだけである(本書§17.4.2・§17.5)。 - FXP の通貨残高/H 不足は、GTID が前進する段階で既存の GTID レーンの仕組みが検出し、
その脚を含む GTID 全体を安全にキャンセル/中断する。脚は一切前進しない
(chaos_fx.test.ts#1: FXP に USD の H 枠が無い、#7: ブリッジ中間 FXP の資金不足)。 - FXP は各通貨の中銀当座/CBT 残高で事前資金手当て(prefund)しておく必要がある
(脚確定時に保有していない通貨は払えない)。 - H は脚確定(DNS_CYCLE_SETTLED 相当 / CBT 確定)で解放される(既存ポリシー踏襲、FX固有の変更なし)。
17.8 ワークドエグザンプル
以下は、実際の数値を使って処理の流れを最初から最後まで追った具体例である。ここまでの
説明を、数字レベルで裏付けることを目的とする。
17.8.1 直接・デフォルト経路(JPY→USD, payer 建て 1,000,000 JPY、HTLCなし)
- FXP=銀行002 が
rate(JPY→USD)=670,000(=0.0067、RATE_SCALE=1e8)を提示する。 - これにより
amount_USD = floor(1,000,000 × 670,000 / 1e8) = 6,700 USDとなる。 - 脚0: payer(001の顧客) −1,000,000 JPY / FXP(002) +1,000,000 JPY。JPY レールで確定する(BOJ/DNS)。
- 脚1: FXP(002) −6,700 USD / payee(...) +6,700 USD。USD レールで確定する(CBT, CB_TOKEN)。
- 各脚は通貨別にゼロサムになっている。原子性は、導管GTIDの通常の多脚協調がそのまま担う
(test/integration/fx_settlement.test.tsで実際の顧客残高を使って検証済み)。
17.8.2 ブリッジ(JPY→USD via EUR、中間通貨1つ)
rate(JPY→EUR)=1,500,000(=0.015)、rate(EUR→USD)=110,000,000(=1.1)とする。- これらを合成すると
rate(JPY→USD)=floor(1,500,000×110,000,000/1e8)=1,650,000(=0.0165)
となる。直接見積よりもこちらのレートが有利であれば、ブリッジ経路を採用する。 - 脚0 payer→FXP_a(JPY, 1,000,000)、脚1 FXP_a→FXP_b(EUR, 15,000=floor(1,000,000×0.015))、
脚2 FXP_b→payee(USD, 16,500=floor(15,000×1.1))という3脚に分解される。 - 直接経路が合成レートと同額(タイ)の場合は、脚数が少ない直接経路の方を優先する
(routing.tsのisBetter())。 - これらの数値は
test/zc/fx_routing.test.tsで検証済みである。
17.8.3 HTLC経路(bind_htlc=true、決済を claim まで遅延)
- §17.8.1 と同じ経路を HTLC で束ねる場合、まず
lockFxTransferが2脚をFxLegLocksにLOCKEDとして記録し、共有hashlockと段階的な timelock(脚0が脚1より長い)を割り
当てる。この時点では資金は一切動かない。 - payee が
secretを知りPOST /api/fx/transfers/{gtid}/claimを呼ぶと、sha256(secret)==hashlockであることが確認され、2脚が一括でCLAIMEDになり、その場で
導管 GTID が登録・前進して決済が進む。 - secret が誰にも明かされなければ、両脚のうち遅い方の timelock が経過した後に
refundFxTransfer(手動呼び出し、またはsweepExpiredFxLockscron による自動実行)が
2脚を一括でREFUNDEDにし、資金は動かないまま終わる。 - この一連の流れは
test/zc/fx_htlc.test.tsで検証済みである。
第18章 レガシー勘定系アダプタ 処理方式
機能別索引:レガシー勘定系アダプタは要件定義(
10_requirements.md)・処理方式(本書 第18章)・内部設計(30_internal_design.md)の3文書にまたがる。本章は方式(6つの施策・ベンダー接続可否)を扱う。
18.1 6つの施策と、その検証
#1 能力プロファイル(異機種を設定として飲む)
LegacyProfiles に role / reservation_mode / settlement_mode / notify_mode /
sync_reserve / realtime_name_check / batch_ingest / window を宣言。アダプタは
毎 call これを読んで分岐する。バッチ専業行は PAYEE_ONLY + PREFUNDED_SHADOW
などに落ち、送金系コマンドは PARTICIPANT_CANNOT_SEND でクリーンに拒否
(クラッシュしない)。isCoreOnlineAt() は日跨ぎの窓も扱う純関数。
能力の宣言は互いに独立ではない:sync_reserve=false の行でreservation_mode='SUSPENSE' を宣言しても、予約は NONE に縮退する
(10_requirements.md §7.2.6)。宣言の組み合わせに矛盾があるとき、
アダプタは強いほうではなく弱いほう=実際に履行できるほうを採る。
#2 プレファンド・シャドウ(同期呪縛を外す)
settlement_mode='PREFUNDED_SHADOW' の行では、AdapterShadow の
available に対して承認し、コアに一切触れず ZC へ即答する。実 posting はAdapterOutbox に積み、コアがオンライン復帰したら drainOutbox が適用。
バッチ窓中でも即時着金が回る。
#3 照合 → 実際の CASE(シャドウの代償を必ず捕まえる)
シャドウを持った瞬間ドリフトの可能性が生まれる。reconcile.ts が全口座で
次の不変条件を検査し、残差を AdapterReconDrift(status=OPEN)に記録する
と同時に、実際の openCase()(ZC 本体が使うのと同じ Cases テーブル)を
呼び出して CASE を開く:
core.balance == shadow.available + shadow.reserved
+ Σ(pending DEBIT) − Σ(pending CREDIT)
「説明できない状態は禁止(未決は CASE へ)」という ZC の原則を、アダプタ層に
そのまま降ろしたもの。out-of-band なコア変更(lost posting)は OPEN ドリフト
として検出され、drift.case_id で実際のオペレーション監視対象になる。
#4 無予約 + Reversal 補償(予約すら持てないコア)
reservation_mode='NONE' では reserve は純粋な残高チェック(hold 無し)。
下流の失敗は hold の解放ではなく、補償 Reversal(別 posting) で戻す。
ZC が Reversal(取消ではなく、反対仕訳を積んで元に戻す組戻し)を第一級の
概念として扱うのと同じ考え方であり、結果として payer の残高は net zero
(差引ゼロ、つまり最初の状態)に戻る。
補償 Reversal は元の debit と同じ txid を持ち、LegacyCoreJournal.txid
で対になっていることが追跡可能。
#5 プル型通知(push 口を作らせない)
creditNotify は AdapterNotifications に格納するだけで、コアへ push しない。
銀行は pullNotifications で取りに来る(取得後 READ 化、二度読みは空)。
#6 バッチ取込(file 志向コア)
ingestBatchCredits が N 件の credit を一括で outbox へ。オフライン中でも
受け、window オープンで一度だけ drain。
18.2 ベンダー接続可否(勘定系ベンダーの目線で)
「監査に耐える実装か」と「勘定系ベンダーとして実際にこれへ接続できるか」は
別の問いである。前者はコードの正しさ、後者は契約の形が現実の勘定系の
制約・組織的現実と噛み合うかを問う。ここでは後者を、LegacyAdapter/LegacyCore の公開契約に即して評価する。
良い点(そのまま RFI に使える設計判断)
- 13 コマンドへの絞り込みと、予約を「別段預金3操作」に分解する発想は、
実在のどの勘定系にも既にある操作(借記・貸記・残高照会)だけを要求して
おり、ベンダー側の新規開発をほぼ要求しない——ここは高く評価できる。 - 能力プロファイル(#1)は、ベンダーごとの差異を「コードで分岐」ではなく
「宣言」で吸収する枠組みとして正しい形。RFI で各ベンダーにsync_reserve/realtime_name_check/batch_ingest/window_*を
埋めさせれば、接続前に地雷が可視化できる。 - プル型通知(#5)は、ベンダー側に新規の受信エンドポイントを要求しない
という点で、現実の接続コストを正しく見積もっている。
コア境界の規範:公開面は不透明な呼出しに限る
規範:アダプタはコアの内部テーブル名を知ってはならない。コアの公開面はpostCredit(bankId, accountId, amount, meta) / postDebit(...) という不透明な呼出しに限り、
戻り値は {applied: boolean, reason?: 'INSUFFICIENT_FUNDS'} のみとする。
資金十分性の判定は postDebit の内部(コア自身のテーブルだけを使う、コア自身の
トランザクション)で完結させる——これは現実のどの勘定系も既にやっていること
(自分の元帳を更新する際に残高チェックを伴うのは当たり前)であり、ベンダーに新しい能力を
要求しない。
この規範が要る理由は、一度破ったことがあるからである。オーバードラフト防止のために
「アダプタがコアのテーブルへ直接 SQL を組み立て、自分の AdapterOutbox 更新と同一
トランザクションに載せる」実装を通したことがあり、これはアダプタとコアが同一 DB・
同一トランザクションを共有することを暗黙の前提にしていた。ベンダーのコアは別システム
(多くはメインフレーム、あるいは別ネットワークのサーバ)であり、外部の協調層と
トランザクションを共有できない——それを要求した時点で「RFI に出せる契約」ではなくなる。
しかも「勘定系には開発を追加させない」という本章の設計思想そのものと矛盾する
(経緯は 30_internal_design.md § 監査で見つかった問題と是正 #8)。
この規範が受け入れる、現実の統合が必ず持つトレードオフ
不透明な API 呼出しである以上、「コア呼出しが成功したが、アダプタ側のoutbox フラグが確定する前にクラッシュした」場合、二つのシステムを
またぐアトミック性は保証できない。これは実装の不備ではなく、
物理的に分離した2システムを統合する際に本質的に避けられない現実
そのものである(分散トランザクションの限界)。
冪等性を一切持たないコア(制約#2)に対しては、この窓をアダプタ側の
工夫だけで完全に閉じることはできない——コア側が「このリクエストはもう
処理済みか」を答えられる手がかり(冪等キーの受理、または受付番号の
照会)を持たない限り。test/bank/legacy/adversarial.test.ts の
「residual risk: crash between core-apply and outbox-flip」テストは、
この窓で実際に二重記帳が起きること、そして照合(#3)だけがそれを
捕まえることを実際に再現して固定している——「ドキュメントに書いただけの
限界」ではなく、動くコードで検証済みの限界である。
RFI で各ベンダーに追加で確認すべき、本サブシステムが未対応の論点
以下は「実装すべきだったのに漏れていたバグ」ではなく、この参照実装の
射程外として明示すべき、実接続で必ず問題になる論点である。
- プロトコル/トランスポート: 本モデルは
LegacyCoreの呼出しを
同一プロセス内の非同期メソッドとして扱っているが、実際のベンダー接続は
MQ、専用線、固定長バッチファイル(EBCDIC/Shift-JIS)、メインフレーム
RPC などになる。文字コード・電文フォーマット・エラーコード体系の
マッピングは、実際の統合コストの大部分を占めることが多いが、本モデルは
これを「DB 接続がその代わり」として意図的に捨象している。RFI では
各ベンダーに実際の接続プロトコルと電文仕様を問う必要がある。 - ベンダー側の冪等性の粒度: 本モデルは「コアの冪等性はゼロ」
(制約#2)と「アダプタのIdempotencyKeysが唯一の防波堤」の2択しか
扱っていない。実際には、一部の中堅ベンダーコアは受付番号を発行し
状態照会 API で追跡できる(冪等キーを受理はしないが、結果照会で
二重処理を検知できる)という中間パターンを持つ。この中間パターンへの
対応は未実装(プロファイルにフィールドを足す余地はあるが、現状は
ゼロ冪等ケースのみ検証している)。 - 手動承認・保留ワークフロー: 本リポジトリのグリーンフィールド側
(bank/ingress/execute.ts)には、フィルタ判定でPENDING_APPROVAL
(テラー承認待ち)に落とす経路が既にあるが、Legacy アダプタ側には
同等の経路が無い。実際の勘定系は、一定額以上や疑わしい取引に対して
人手承認を要求することが珍しくなく、これは「即時 OK/NG」だけを
前提にした現状のアダプタでは表現できない。 - 電文の情報量:
PostingCmd/NotifyCmdは(bank_id, account_id, amount, request_id, txid)という最小限の項目
しか持たない。実際の勘定系への振込指図は、取扱店番・摘要・EDI情報・
目的コードなど、遥かに多くの項目を要求することが多く、ベンダーごとの
拡張フィールドをどう扱うかは未検討。 - 営業日・カットオフ時刻: 休業日・営業日跨ぎの value date 算定は
一切モデル化していない。23:50 の DEBIT と 00:10 の DEBIT で、どちらの
営業日に計上されるかは、実接続では即座に問われる論点である。
本章が達したところと、達していないところ
「勘定系に何を要求すべきか」——13コマンドへの絞り込み、能力プロファイルによる
異機種の吸収、プル型通知——は、そのまま各ベンダーへの質問票の骨格になる形で固定できている。
「そのまま接続できるか」は別問題であり、まだ閉じていない。上記 5 論点
(プロトコル翻訳層、電文の情報量、ベンダー側冪等性の中間パターン、手動承認経路、
営業日ロジック)は統合コストの本体であり、個別の RFI/PoC で詰める領域として残る。
この 2 つの問いを混同しないこと——前者に良い答えが出ていることは、後者の答えにならない。