暗号資産の送金には「送金人・受取人の情報を、送金と一緒に相手の事業者へ届けろ」という国際ルールがあります。FATF勧告16、いわゆるトラベルルールです。氏名や住所はブロックチェーンに載らないので、この情報交換はチェーンの外側で、VASP(暗号資産サービス事業者)同士の専用プロトコルで行います。
筆者はこの情報交換プロトコルのひとつ TRP (Travel Rule Protocol) 3.2.1 をPython/FastAPIでフル実装しました。TRPはOpenVASP Associationが無償公開しているオープン標準で、素のHTTPS+JSONで動くためPythonスタックと相性が良いのですが、仕様書だけ読んでも気づきにくい落とし穴がいくつもあります。本記事は、これから実装する方のためのレビュー観点のまとめです。
TRPの基本フロー(3手)
ポイントは②で、入金先アドレスは相手が承認して初めて開示される構造です。これが後述の設計判断(承認後の再スクリーニング・コールバックのレース)に効いてきます。
落とし穴① api-version の受理帯は実装ごとに違う
TRPは api-version: 3.2.1 ヘッダでバージョンを表明しますが、世の中の実装が受理するバージョン帯はまちまちです(>=2.1.0 <3.2.0 のような semver レンジで検証する実装が実在します)。しかも帯域外のときの応答が本文なしの400だったりするので、原因究明が地味に辛い。
対策: 対向ごとにバージョンをネゴシエートできる設計にしておく(カウンターパーティ台帳に api_version を持たせ、送信時に差し替え可能に)。エラー時は「どのヘッダで拒否されたか」を自分のログに残す。
落とし穴② amount は文字列か数値か
金額の精度を守るなら JSON では文字列表現が定石ですが、JSON数値(float64)を要求する実装が存在します。float64 は 18 桁トークンの精度を落とすので、内部では絶対に float にしたくない。
対策: 内部は Decimal + 最小単位の整数(wei等)で持ち、ワイヤ形式への変換は送信直前のアダプタに隔離する。なお最小単位の整数は PostgreSQL の bigint では溢れます(9.2 ETH ≒ 9.2×10^18 wei で int64 上限を超える)。numeric(78,0) が安全です。
落とし穴③ コールバックURLは「使い切り」にする
②の承認/拒否は、Inquiry に添えたコールバックURLへ相手が折り返す形で届きます。仕様上、無効・期限切れURLは404を返すことになっており、これを素直に実装するなら送金ごとに一回限りのトークン付きURLを払い出し、使用済みは404が正解です(同じ承認通知を2回再生する再送攻撃への防御にもなる)。
見落とされやすいレースもあります: 相手が同期応答より先にコールバックを叩いてくることがある。「Inquiry送信のHTTP応答を受けてから状態をINQUIRY_SENTにする」実装だと、先に届いたコールバックが404になります。状態遷移は送信の前に行うのが安全です(タイムアウトした送信は「届いていない」とは限らない、という意味でも)。
落とし穴④ 資産識別子はDTI単独で届く
TRPの asset は DTI (Digital Token Identifier, ISO 24165) で指定します。ここで重要なのは、仕様上 chain というフィールドは存在しないこと。DTIは台帳ごとに一意なので、DTI単独でチェーンも資産も確定する建て付けです。
「chainフィールドがあるだろう」という思い込みで実装すると、仕様に忠実な相手からのInquiryほど処理に失敗する構造になりえます。自作テストは自分の想定と同じ形式でリクエストを作りがちなので、この種のずれは単体テストでは表面化しにくい点にも注意が必要です。
対策: DTI→カタログ(台帳・decimals)解決を受信経路に必ず入れる。DTIの実値は DTIFの公式レジストリで確認できます(無料アカウントで検索可能。コントラクトアドレスやUnit Multiplierまで突合できるので、decimalsの検算にも使えます)。未知のDTIは推測で通さず拒否(fail-closed)に。
落とし穴⑤ IVMS101の必須条件は「いずれか必須」が多い
送金人・受取人情報の標準様式 IVMS 101.2023 は、単純な required/optional ではなく「AとBとCのいずれか少なくとも1つ」という条件付き必須が多い様式です(例: 自然人の送金人は住所・顧客番号・国民ID・生年月日と出生地のいずれか、法人は LEGL 種別の名称が最低1つ、等)。
対策: Pydantic でモデル化してバリデータに条件を書き切る。ワイヤ上のキー名の揺れ(実装によって微妙に違う)も受信側は寛容に受けて内部表現に正規化する。ここを型で固めておくと、後続の全処理が楽になります。
落とし穴⑥ fail-closed を全層で貫く
トラベルルール実装では「スクリーニングできなかったので通した」は許されません。リスク評価系に到達できない・評価が返らない・設定が欠けている——どのケースでも止める側に倒す必要があります。
これはエンドポイント1箇所の話ではなく、設定の読み込みから資産カタログまで全層の姿勢です。例えば「保存期間が未設定ならパージ無効」ではなく起動時エラーにする、「未知の資産は18桁扱いで通す」ではなく受け付けない、など。「設定したつもりが素通し」を構造的に作らないことが肝です。
落とし穴⑦ mTLSは「身元保証」ではない
VASP間で個人情報を送り合う以上、通信はhttps必須で、クライアント証明書(mTLS)による接続時の相互確認も検討することになります。ただしTRP仕様も明言しているとおり、mTLSは「登録済みの証明書を持つ相手か」の確認であって、その相手が信頼できる事業者かの審査(vetting)は別の仕事です。証明書の指紋を台帳にピン留めする層と、カウンターパーティを審査する層は分けて設計しましょう。
テスト戦略: 鏡合わせ+対向実装
単体テストを積んでも、②や④のような「実装間の解釈差」は自分のテストでは出てきません。効いたのは次の2段でした。
- 自己対向E2E: 自分の実装を2インスタンス起動し、送付側と受取側として全往復(承認・拒否・失効まで)を通す。状態遷移・監査ログ・アドレスの一致を機械検証
- 他人の実装を対向に立てる: OSSのトラベルルールノードをdocker composeで立て、生のリクエストを1フィールドずつ変えながら突き合わせる。①②のような解釈差は、この方法でないと炙り出せません
まとめ
- ワイヤ形式の細部(バージョン帯・数値型・識別子)は対向ごとに違う前提でアダプタ層を切る
- 状態遷移とコールバックはレースを前提に設計する
- 迷ったらfail-closed
- 解釈差は他人の実装と突き合わせて初めて見つかる
筆者はこの実装を、オンチェーンのAMLスクリーニングと統合した形で ChainAnalyzer のEnterprise向け機能として開発しています(承認後に開示されたアドレスを送金実行前にもう一度スクリーニングする二段構成 — この方式は特許出願済み)。詳細は開発発表をどうぞ。
本記事は仕様の解説を目的としたもので、法的助言ではありません。トラベルルールの適用範囲・要件は法域ごとに異なります。実際に見込み顧客の法域によっては真逆のこともあります。