今年前半、当社のステーブルコイン決済総額は10億ドルを突破しました。背景として、最初のステーブルコイン取引を処理してから12ヶ月も経っていません。始まりはごく少数の顧客からの要望でした。それは本来シンプルなはずの機能——世界中のクライアントからステーブルコイン決済を受け取り、その資金を 米ドルで決済する- 手数料に押しつぶされることなく、迅速かつ予測可能な形で実現しました。既存のオフランプソリューションでは1~3%の手数料、複数日にわたる決済、そして非常に長く苛立たしいコンプライアンスプロセスが課題でした。私たちはそこに機会を見出し、2週間でMVPを構築。1ヶ月以内に、実質的かつ大きな取引量を達成しました。

課題はそのスケーリングだった。

ステーブルコインは数秒で決済され、場所や時間を問いません。従来の銀行システムはそうした用途を想定して構築されておらず、バッチ処理、営業日締め、複数日にわたる決済期間を前提としています。例えば土曜日の午前2時にシンガポールの顧客がUSDCで50,000ドルを送金した場合、 オンチェーン上では数秒で決済されます。一方、あなたの銀行口座へのACH送金は月曜まで開始されず、水曜まで到着せず、その間どこかで審査待ちになる可能性があります。この二つの世界を橋渡しするには、相互通信を想定せず、それぞれ独自の状態を持つシステム間の調整が必要です。

この投稿は、ステーブルコインの移動を銀行レベルの資金移動のように動作させるために構築した2つのインフラストラクチャ・プリミティブについてです:

1. 資金の流れ長期実行型・マルチシステム金融ワークフロー向け宣言型オーケストレーションエンジン

2. 私たちの オンチェーン実行フレームワーク信頼性の高いオンチェーン操作のためのライフサイクルモデル

その他のすべて——オフランプ、オンランプ、そして当社の非保管型グローバルUSD口座——は、これらの基本要素を組み合わせて構築されています。この投稿から一つだけ覚えてほしいこと:ステーブルコインは簡単です。ステーブルコイン 銀行業務 そうではない。

資金の流れ

複数の外部システム間で資金を移動させる場合、その場限りの調整以上のものが必要です。イベント発生時にどのような保証のもとで、どのような処理が行われるべきかを宣言的に表現する手法が求められます。さらに、そのフローは監査可能で、中断後の再開が可能であり、途中でステップが失敗しても正しく処理される必要があります。それが資金フロー管理が提供する価値です。

資金移動の問題点

ソフトウェアにおけるオーケストレーションの課題の大半は、障害を適切に処理することにある。金融オーケストレーションにはより厳しい制約がある: お金はすでに動き出している.

即時入金フローを想定します。顧客が10,000米ドル相当のUSDCを受け取ります。当社は直ちに(基盤となるACH決済が完了する前に)その資金を顧客口座に入金し、顧客が即座に資金を活用できるようにします。裏側では、当社は融資を実行しています。数日後にACH決済が到着すると、当社は返済金と手数料を回収します。

これは2つの外部システムにまたがる4つの操作です:暗号資産の清算、ローンの支払い、ACH決済、手数料徴収。各ステップは前のステップに依存し、いずれのステップも失敗する可能性があります。分散した状態とアドホックなハンドラーではこれを管理できません。

中核的抽象化

資金フローは宣言型ルールベースのオーケストレーションシステムである。中核となる抽象化は三つの部分から構成される:

イベント 何かが発生したことを示すシグナルです。ACH決済が完了した、資金が受領された、カード承認が取得されたなど。イベントは外部システムから送信される場合もあれば、内部で発行される場合もあります。

ルール イベント発生時の対応を定義する。各ルールは、トリガーとなるイベントのリストと、実行すべき一連の副作用を指定する。

副作用 イベントに対する対応として行うアクション:送金の開始、保留の作成、ローンの支払い、手数料の徴収。ルールは一度だけ発火する。一致するイベントが最初に到着した時点で、副作用が順序通りに実行され、ルールは消費される。これにより、フローコンテキスト内での冪等性が保証される。

なぜ宣言型なのか?

代替案は命令型オーケストレーションである:ハンドラがハンドラを呼び出し、状態がテーブルに散在し、「フロー」はコード片同士の暗黙の連携にのみ存在する。

単純なフローには有効だ。しかしコンプライアンス保留や部分的な障害が発生する複数日にわたる複数システムの金融業務では、維持が不可能になる。エラー処理は場当たり的で、復旧経路は暗黙的だ。半年後には「ステップ2が成功した後でステップ3が失敗したらどうなるか?」という質問に、誰も確信を持って答えられなくなる。

宣言型ルールはモデルを反転させる。ステートマシンを明示的に定義する: これら イベントトリガー これら アクション。オーケストレーションエンジンは実行、永続化、およびリカバリを処理する。フロー ドキュメント。

保証

FoFは私たちが頼ることができる4つの不変量を与えてくれます:

1. 冪等性 - ルールは、重複イベントや再試行に関わらず、フローコンテキストごとに正確に一度だけ発火する

2. 決定論的調整 - 同じ事象が与えられた場合、フローは同じ状態に収束する

3. 完全な監査可能性 - すべての副作用の実行は、引き金となったイベントまで遡って系譜を追跡される

4. 構成可能性 - 複雑な流れは、単一の塊になることなく組み合わさる単純なルールから構築される

実行追跡

Nodeレコードを通じてすべてのサイドエフェクト実行を追跡します。各レコードは親レコードにリンクされ、完全な実行ツリーを形成します。コンプライアンスで監査証跡が必要な場合、システム内の正確な経路を追跡できます。

Root Node (flow.begin)
├── Node: CreateOffRampTransaction
├── Node: SendNotification
└── Node: CreateSettlementRule
    └── (child rule, fires later)
        ├── Node: CollectLoanRepayment
        ├── Node: CollectFees
        └── Node: ResolveTransaction

構成可能性:ネストされたルール

ルールは子ルールを生成できる。これにより複雑な多段階フローが単一構造にならずに構成される。オフランプ取引が作成されると、初期ルールは全てを処理しようとせず、代わりに 未来 ルール—後で発生するイベントを待つリスナー:

createRule({ events: ['flow.begin'] })
  .addSideEffect(CreateOffRampTransaction)
  .addSideEffect(SendNotification)
  .addSideEffect(
    CreateRuleSideEffect.create({
      rules: createRule({
        events: ['inbound_ach.completed', 'completed'],
      })
        .addSideEffect(CollectLoanRepayment)
        .addSideEffect(CollectFees)
        .addSideEffect(ResolveTransaction)
        .buildRule(),
    })
  )
  .buildRule();

決済ロジックは呼び出されるのを待つデッドコードとして存在するわけではない。ルールとして存在し、そのイベントを待機している。数日後に銀行プロバイダーのウェブフックが到着すると、ルールが発火しフローが継続する。親ルールは消費され、子ルールがコンテキストを引き継ぐ。

これはまた、フローが任意に組み合わされることを意味します。即時入金機能を実装したいですか?簡単です。融資を実行し返済ルールを設定するルールを追加するだけです。各処理は独立していますが、それらがすべて組み合わさって一貫性のあるフローを形成します。

副作用実行コンテキスト

すべての副作用が同じではない。データベーストランザクションとアトミックである必要があるものもあれば、外部APIを呼び出すものもある。実行したら放置できるものもある。

MethodExecutionUse Case
addSideEffectSynchronous, same DB transactionAsync, no transaction
addAsyncSideEffectAsync via TemporalExternal API calls, long-running operations
addAsyncNonTransactionalSideEffectAsync, no transactionNotifications, logging, analytics

見返り

最大の利点は、FoFがSlashにおけるエンジニアのオーケストレーションコードの書き方を変える点です。これがないと、各エンジニアが同じ問題を異なる方法で解決します。皆が車輪を再発明し、その車輪はどれも形がわずかに異なるのです。

FoFは基準を引き上げる。あなたが定義する 起こるべきこと、そうでないこと どのように あらゆる障害モードに対処するため。このフレームワークは実行、永続化、可観測性を処理する。新規エンジニアはフロー定義を読み、命令型コードの層を追跡することなく理解できる。また、抽象化がイベント、副作用、状態遷移について明示的に記述することを強制するため、不適切なオーケストレーションコードを書くことが難しくなる。

ランプス:FoFの実践

FoFを金融フロー構築の基盤とすることで、中核製品の構築は各フローに適したルールを定義する作業へと移行した。この抽象化は、対向システムが何であれ、単にオーケストレーションを行うだけである。

オフランプとオンランプは、このエンジンによって調整される「フロー」であり、新たな外部システムを導入します:ステーブルコイン/法定通貨の変換を扱う暗号資産プロバイダー(またはOTCデスク)です。他の外部システムと同様に、これらは独自の条件で状態更新を配信します。この更新をFoFイベントのトリガーとして利用可能です。そこから先はフローの組み合わせに過ぎません。

オフランプ

オフランプにより、顧客はステーブルコインでの支払いを受け取り、スラッシュアカウント内で米ドルとして決済できます。流れは次の通りです:

  1. お客様は、当社が暗号資産プロバイダーを通じて生成した入金アドレスにUSDCまたはUSDTを受け取ります
  2. プロバイダーは預金を検知し、米ドルに換金し、ACHまたは電信送金を開始する
  3. 当行の銀行サービスプロバイダーが着金を処理します
  4. 当該振替を元の取引と照合し、口座に貸方記入します

即時入金(顧客への即時入金とACH決済時の返済回収)の場合、フローには融資実行、返済回収、手数料徴収が含まれます。各処理は独立したルールとして、自身のイベントを監視し、単一の整合性のあるフローを構成します。FoF定義は以下のような構造になります:

const offRampInstantDepositFlow = createRule({
  name: 'instant_deposit',
  events: ['provider.deposit_received'],
})
  .addSideEffect(
    DisburseLoan.create((ctx) => ({
      accountId: ctx.accountId,
      amount: ctx.providerDeposit.amount,
    }))
  )
  .addSideEffect(
	  // The settlement logic doesn't run immediately
	  // it waits as a rule until the ACH actually settles
	  // which might be days later
    CreateRule.create((ctx) => ({
      traceId: ctx.deposit.ach_trace_number,

      rule: createRule({
        events: ['inbound_ach.settled'],
      })
        .addSideEffect(RecollectLoan.create({ loanId: ctx.loanId }))
        .addSideEffect(
          CollectFees.create({ depositId: ctx.providerDeposit.id })
        ),
    }))
  );

オンランプ

オンランプは逆の流れです:顧客はSlashアカウントから米ドルを送金し、外部ウォレットでステーブルコインを受け取ります。流れは以下の通りです:

  1. 顧客が宛先ウォレットアドレスへの送金を開始する
  2. 当該口座に対し、当該金額に手数料を加えた金額分の保留を設定します
  3. 当社暗号資産プロバイダーの預入指示先へ、ACHまたは電信送金を実行します
  4. プロバイダーは資金を受け取り、ステーブルコインを宛先に送金する
const providerAccountId = '...';
const userAccountId = '...';

const onRampFlow = createRule({
  name: 'on_ramp',
  events: ['flow.begin'],
})
  .addSideEffect(
    InitiateTransfer.create((ctx) => ({
		  type: 'wire',
		  source: userAccountId,
		  destination: providerAccountId;
		  // ...
    }))
  )
  .addSideEffect(
    CreateRule.create((ctx) => ({
      traceId: ctx.transaction.id,
      rule: createRule({
        events: ['outbound_wire.completed'],
      })
        .addSideEffect(CaptureFees.create({ transactionId: ctx.transaction.id }))
    }))
  )
  .addSideEffect(
    CreateRule.create((ctx) => ({
      traceId: ctx.transaction.id,
      rule: createRule({
        events: ['provider.delivery_failed'],
      })
        .addSideEffect(CancelPendingFees.create({ transactionId: ctx.transaction.id }))
        .addSideEffect(InitiateTransfer.create({ 
			     type: 'book',
			     from: operatingAccountId,
			     destination: userAccountId,
			     description: 'Refund',
			     // ...
         })),
    }))
  );

注目すべきは、これに必要な新規インフラがほとんどなかった点だ。オフランプ向けに構築したFoFフレームワークと調整ロジックはそのまま流用できた。オンランプは異なるイベントを監視する異なるルールだが、基盤となる仕組みは同じである。

オンチェーン・ライフサイクル

FoFは法定通貨側の調整問題を解決しました——銀行インフラ、プロバイダー、コンプライアンスです。しかしグローバルUSDの構築を開始した際、新たな課題領域に直面しました:チェーンそのものです。トランザクションをオンチェーンに載せ、実際に着地したことを確認し、障害やチェーン再編成を処理し、結果から正確な状態を導出する——これは別の調整問題です。FoFで得たのと同じ保証を、オンチェーン実行において必要としていました。

パターン:意図 → 実行 → 調整

すべてのブロックチェーン操作において一貫したパターンを使用します:

1. 意図: 何をしようとしているのか宣言する

2. 実行トランザクションを送信し、ブロックへの組み込みを促す

3 調整する: 確認済みブロックを処理し、内部状態を更新し、下流フローをトリガーする

伝統的な金融業界出身の方なら、この比喩はわかりやすいでしょう:

  • 意図 ≈ 支払命令
  • 実行 ≈ 保留中
  • 照合 ≈ 登録済み

各フェーズには固有の責任と障害モードが存在する。本節の残りの部分では、各レイヤーをどのように構築したかを順を追って説明する。

何かを実行する前に、定義する必要があります 我々は実行している。ブロックチェーンのトランザクションは基本的に命令(パラメータ付きで呼び出される関数)であるが、チェーンは人間が読める命令を理解しない。全てがエンコードされる。 calldata - 呼び出す関数と渡す引数を指定する16進バイトの塊。

例えば、単純なUSDC送金——「アドレスXに500 USDCを送る」——は次のようになります:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

このような生のコールデータは不透明であり、何も教えてくれない。 なぜ 資金が移動した。そして、単に資産が移転しただけでなく、これが請求書番号1234の料金徴収であるといったビジネスコンテキストを追跡する必要があるシステムを構築する際には、そのコンテキストを保持しなければならない。

この問題を解決するために、型付き呼び出し定義のレジストリを用意します:

```tsx
export const erc20Transfer = blockchainCallRegistry.define(
  defineBlockchainCall({
    key: 'erc20.transfer',
    abi: 'function transfer(address to, uint256 amount)',
    request: {
      input: tbox.obj({
        contract: tbox.hex(),
        recipient: tbox.hex(),
        amount: tbox.bigIntStr(),
      }),
      categories: [
        'outbound_wire_transfer',
        'outbound_ach_credit',
        'outbound_transfer_fee',
      ],
      tags: [],
      context: tbox.obj({
        relatedAuthorizationId: tbox.opt(tbox.id('authorizationRequest')),
      }),
    },
    encode: ({ input, encodeFunctionData }) => ({
      to: input.contract,
      data: encodeFunctionData([input.recipient, BigInt(input.amount)]),
      value: bigIntStr(0n),
    }),
  })
);

```

エンジニアは16進文字列ではなく、契約、受取人、金額といったドメイン用語で作業します。レジストリは入力の検証、エンコーディング処理を行い、後で必要となるメタデータ(カテゴリ、タグ、ビジネスコンテキスト)を保持します。

呼び出しの作成は簡単になります:

const call = erc20Transfer.create({
  walletId: 'wallet_12345',
  chain: 'base',
  request: {
    input: {
      contract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
      recipient: BASE_USDC_FEE_ACCOUNT,
      amount: bigIntStr(25_000_000n), // 25 USD
    },
    category: 'outbound_transfer_fee',
    tags: [],
    description: 'USDC Fee Transfer - SLASH FINANCE',
    context: {}
  },
})

各呼び出しはBlockchainCallレコードとなります:

interface BlockchainCall {
  id: string;
  type: string;                      // 'erc20.transfer'
  chain: Chain;
  walletId: string;
  to: Hex;                           // Target contract
  value: BigIntStr;                  // ETH value (usually 0)
  data: Hex;                         // Encoded calldata
  request: BlockchainCallRequest;    // Original typed request with metadata

  // Intent association
  intentId: string;
  indexInBatch: number // Position in the batched transaction
}

ブロックチェーンコールを作業の基本単位として扱います。トランザクションは複数のコールをバッチ処理する場合がありますが、各コールは単一の責任追跡可能な操作を表します。リクエストフィールドは、エンコードされたバイト列に加えて完全な型付き入力(メタデータや任意のコンテキストを含む)を保持します。このメタデータこそが、チェーン状態を業務操作に照合する際に「この500ドルの送金は何か?」という問いに答えることを可能にする要素です。

実行:トランザクションを最終状態へ導く

トランザクションの送信は単純に聞こえる。実際には、「これを送る」と「着金した」の間には地雷原が広がっている。

トランザクションを送信しても、直接ブロックには入りません。それは mempool- トランザクションがブロック生成者に拾われるまで待機する領域。待機中に、トランザクションは破棄される(mempoolが満杯)、入札で負ける(他者がより高い手数料を支払った)、またはスタックする(現在のネットワーク状況に対してガス代が低すぎる)可能性がある。

ガス イーサリアムベースのネットワークが計算処理に課す価格設定方法です。あらゆる操作にはガス代がかかり、ネットワークのネイティブトークンで支払います。トランザクションを送信する際、支払う意思のある最大ガス価格を指定します。送信後にネットワークの混雑が急増すると、設定したガス価格が競争力を失う可能性があります。その結果、トランザクションはmempoolに滞留し、永遠に待機状態となる恐れがあります。

取引がブロックに確定した後でも、真に最終的な状態とはならない。ブロックチェーンは 再編成(リオーガニゼーション)- ネットワークが直近のブロックを破棄し、別のブロックチェーンで置き換える状況が発生することがあります。確認済みだと思っていた取引が消える可能性があります。成熟したネットワークでは稀ですが、実際の資金を移動させる場合、「稀」は「絶対にない」とは限りません。

これらの障害はそれぞれ異なるレイヤーで発生します:ガス見積もり、署名、送信、確認。そして各障害からの回復には異なる対応策が必要です——スタックしたトランザクションにはより高いガス量での再送信、無効な署名には再署名、再編成(reorg)にはフロー全体の再試行が必要です。

この処理は、実行ライフサイクルを4つのエンティティの階層構造としてモデル化することで実現します。最上位には達成を目指すビジネス成果を配置します。その下には、準備、署名、提出を扱う層が段階的に詳細度を増しながら配置されます。各層は自身の障害領域を所有し、上位層へエスカレーションする前に独立して再試行が可能です:

BlockchainIntent (what we're trying to achieve)
    └── PreparedCall (unsigned transaction, gas estimates locked)
            └── PreparedCallExecution (signing attempt)
                    └── PreparedCallExecutionNode (submission attempt)

ブロックチェーンインテント ビジネス成果を表します:「請求書#1234の支払いとして、このアドレスに500 USDCを送金する」。これは全ライフサイクルを追跡し、必要に応じて再試行を生成できる最上位のオーケストレーターです。

準備済みコール ガス見積もりが固定された不変の符号なしトランザクションです。ガス見積もりが期限切れになった場合(ネットワーク状況が変化した場合)、新しいPreparedCallを作成します。

準備済みコール実行 署名試行を表します。サーバーサイド操作では自動的に署名します。ユーザー向け操作(グローバルUSDなど)では、ユーザーがOTPで承認します。いずれの場合も、署名完了後、送信準備が整います。

準備済みコール実行ノード 単一の送信試行です。トランザクションをネットワークに送信し、包含されるまでポーリングします。再試行可能な理由(ネットワークタイムアウト、mempoolからの削除)で失敗した場合、新しいノードを作成して再試行します。

各レイヤーは自身の障害領域を処理する:

FailureLayerResolution
Network timeoutExecutionNodeRetry with new node
Retry with new nodeRe-org after confirmationRetry with new node
Invalid signaturePreparedCallExecutionRequire new signature
Gas underestimateExecutionNode → IntentEscalate after N retries
Expired gas estimatesPreparedCall → IntentEscalate, create new PreparedCall
Re-org after confirmationBlockchainIntentSpawn child intent

重要な洞察は、あるレイヤーが修復手段を使い果たすと、障害が上位レイヤーへエスカレートする点である。ガス見積もりの持続的過小評価を例に考えてみよう。 ネットワークの混雑が急増し、PreparedCallで固定したガスパラメータが競争力を失う。実行ノードは数回再試行し、混雑が解消される可能性もある。N回の失敗後、それ以上の対応は不可能となる。この失敗は実行層へエスカレートし、実行層は終端状態に達して意図層へエスカレートする。意図層はより高いガス乗数を持つ子意図を生成し、新たなPreparedCallを構築する。こうしてサイクルが再び始まる。

各レイヤーは自身の障害領域を処理するが、エスカレーションは明示的に行われる。親インテントは完全な履歴を保持し、子インテントは調整されたパラメータで新たな試行を行う。コンテキストが失われることは決してない。 なぜ 再試行中です。

調整:連鎖イベントから製品状態へ

トランザクションがブロックに含まれた。さて、どうする?

ブロックチェーンは、1000 USDCの送金が直接発生したことを教えてはくれません。取引が実行され、何らかのデータが発行されたことを伝えているのです。 イベントログそれらのログを解析し、その意味を理解し、それに応じて内部状態を更新する必要があります。

イベントログは、スマートコントラクトが実行中に発生したことを伝える手段です。 移転 USDC契約上で関数が実行されると、契約は 移籍 3つのデータ(送信者、受信者、金額)を含むイベント。このイベントは取引領収書にログエントリとして記録される。

しかしログは、トピックとデータフィールドとしてヘキサエンコード値でエンコードされる。解析にはイベントシグネチャの知識とパラメータの復号が必要だ。生の転送ログは以下のような形式となる:

// A 5 USDC transfer on Base
{
  "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  "topics": [
    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
    "0x0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182",
    "0x000000000000000000000000a6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373"
  ],
  "data": "0x00000000000000000000000000000000000000000000000000000000004c4b40",
  "transactionIndex": "0x34",
  "logIndex": "0x15d",
}

これが転送であることをどう判断すればよいですか?各ログの トピック[0] イベント署名のkeccak256ハッシュ値です。この場合、 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef はハッシュの 転送(送信元アドレス、送信先アドレス、uint256値)- 標準的なERC-20転送イベント。 索引付き 宣言順に、シグネチャハッシュに続いてトピック配列に格納されます。このTransferイベントでは、 fromトピック[1] そして ~へ in トピック[2]インデックス化されていないパラメータのような 価値 ABIでエンコードされている データ.

このログから転送の詳細を抽出する:

  • from: トピック[1] (32バイト、ゼロパディングあり) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • ~へ: トピック[2] (32バイト、ゼロパディングあり) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • 価値: データ uint256としてデコード → 0x4c4b40 = 5,000,000(USDCは小数点以下6桁のため、5 USDCに相当)。

あらゆる種類のログを解析する方法を知るという精神的負荷は悪夢のようだ——だからこそ我々は特定のイベントタイプを解析しドメイン状態へ変換するログプロセッサを構築した:

プロセッサは生のログデータを取り込み、それを16進文字列ではなく型付きフィールドに解析し、ドメインエンティティを生成する。

const transferLogProcessor = createLogProcessor({
  abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
  requires: ({ log, chain }) => ({
    token: TokensDependency.args({ chain, contracts: [log.address] }),
  }),
  process: ({ log, tx, parsed, deps }) => {
    return {
      erc20Transfers: [
        {
          from: parsed.from,
          to: parsed.to,
          amount: parsed.value,
          contract: log.address,
          decimals: deps.token.decimals,
		      txHash: tx.hash,
        },
      ],
    };
  },
});

包含 vs. 確認

当社は二つの異なるライフサイクル段階を扱っています:

  1. インクルージョン - トランザクションは最初にブロックに現れる。状態は暫定的であり、ブロックは再編成される可能性がある。
  2. 確認 - ブロックが十分な深さに達した(再編成の可能性を排除するのに十分な後続ブロックが存在する)。状態は確定した。

この区別は重要です。UIを更新して追加時に保留状態を表示することはあっても、確認が取れるまで下流のFoFフローをトリガーすることはありません。仮の状態に基づいて行動するコストは計り知れません。

ログプロセッサは個々のイベントを処理しますが、それらを相互に調整したりトランザクションレベルの状態を追加したりする必要がよくあります。トランザクションプロセッサはこれを統合します:すべてのログプロセッサからの統合出力を受け取り、それを変換したり追加したり、あるいは追加の下流効果をトリガーしたりできます。ここで二相コミットのライフサイクルも登場します。 トランザクション処理 包含時に実行される - 暫定状態を生成する。 処理確認 ブロックが確定した後に実行される—これは通常、金融オペレーションのライフサイクルを完了する場所である。

const processor = createBlockchainTransactionProcessor()
  .requires(({ transaction }) => ({
    // Transaction-level dependencies
  }))
  .processTransaction(async ({ logResults }) => {
    const transfers = logResults.erc20Transfers ?? [];

    // ...

    return {
 	  // ...
      erc20Transfers:
		transfers.map((t) => ({ type: 'insert', entity: t })),
    };
  })
  .processConfirmation(async ({ logResults }) => {
    // Runs when block reaches finality

    const flowOfFundsRulesToTrigger = await Promise.all([
      findFlowOfFundsRules({
        for: logResults.erc20Transfers.map((t) => t.fromAddress),
        events: ['on_chain_transfer.source.confirmed'],
      }),
      findFlowOfFundsRules({
        for: logResults.erc20Transfers.map((t) => t.toAddress),
        events: ['on_chain_transfer.destination.confirmed'],
      }),
    ]);

    // ...

    return {
      // ...
      flowOfFundsRulesToTrigger: flowOfFundsRulesToTrigger.flat(),
      erc20Transfers: logResults.erc20Transfers.map((t) => ({
        type: 'update',
        entity: t,
        changes: {
          status: 'confirmed',
        },
      })),
    };
  })
  .build();

ログをコールに紐付ける

ログプロセッサが転送レコードを生成する際、元のBlockchainCallにリンクを戻す必要があります。ログは次のことを示しています: 発生した—資産がAからBへ移動した。ブロックチェーンコールは私たちに伝える なぜ—これは料金徴収、ベンダーへの支払い、あるいは返金でした。単一の呼び出しによる単純な取引では、これは明快です。バッチ処理された取引(ガス節約のため複数の操作を単一のオンチェーン取引に束ねる場合)では、状況は複雑になります。領収書は実行中に発行された全ログの平坦なリストを提供するのみで、どの呼び出しがどのログを生成したかの示唆はありません。この問題を解決するのがコールフレームトレースであり、詳細は以下の高度なセクションで説明します。

上級者向け:バッチ処理されたログを個々の通話に紐付ける

このセクションでは、バッチ処理されたトランザクションに関する特定の技術的課題について説明します。ERC-4337やバッチ実行を扱っていない場合は、グローバルUSDのセクションへお進みください。 先に述べたように、単純なトランザクションの場合、ログを元のBlockchainCallに紐付ける作業は容易です。しかしバッチ処理されたトランザクションではそうではありません。

問題

複数の操作を単一のトランザクションにまとめる場合(例えば500ドルの支払いと1ドルの手数料)、両方がアトミックに実行される。トランザクションの領収書には、実行中に発行されたすべてのログが平坦なリストとして記載される:

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "type": "0x2",
        "status": "0x1",
        "logs": [
            {
                "address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
                "topics": [ /* ... */ ],
                "data": "0x",
                "logIndex": "0x3db",
            },
            {
                "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "topics": ["0xddf252ad...", /* ... */ ],
                "data": "0x000000000000000000000000000000000000000000000000000000001dcd6500",
                "logIndex": "0x3dc",
            },
            {
                "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "topics": ["0xddf252ad...", /* ... */ ],
                "data": "0x000000000000000000000000000000000000000000000000000000000010c8e0",
                "logIndex": "0x3dd",
            },
            {
                "address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
                "topics": ["0x49628fd1...", /* ... */],
                "data": "0x000000000000000000000000000000000000000000000001000000000000000400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000009206e5fb300000000000000000000000000000000000000000000000000000000000036f83",
                "logIndex": "0x3de",
            }
        ],
        "logsBloom": "0x...",
        "transactionHash": "0x95aaaa96f3ccefbfcedb1fe5e41d817bc663fcac305ebfc3861f2a189f664cda",
        "transactionIndex": "0x18e",
        "blockHash": "0xfdc6c014e387c5b6f95edc17a25e81f694a5ad46fc75a247aeb0ad3f5dcd8004",
        "blockNumber": "0x25b1de6",
        "gasUsed": "0x28c6d",
        "effectiveGasPrice": "0x1b3ed0",
        "blobGasUsed": "0x24078",
        "from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
        "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
    }
}

実行中に発行されたすべてのログのフラット配列を取得します。このレシートを見ると、ログインデックス1と2(どちらも 0xddf252ad... (先程議論した転送イベント署名)

しかし、どちらが支払いであり、どちらが手数料だったのか?領収書には記載されていない——ログはバッチ内の個々の呼び出しではなく、最上位のトランザクションに紐付けられるからだ。単純にログを呼び出し順に照合すればいいと思うかもしれない。しかし、それは各呼び出しが正確に1つのログを出力する場合にのみ有効だ。単純な送金は1つ出力するが、交換操作では5つ出力するかもしれない。境界がわからない限り、確実にマッピングすることはできない。

コールフレームトレース

解決策は結局 デバッグ_トレーストランザクション- 失敗したトランザクションのデバッグに多くの人が使用する、GethアーカイブノードのRPCメソッド。しかしそれ以外にも機能があり、トランザクションを再生し、コール階層の適切な深さにログを添付した完全なコールフレームツリーを返す。

{
  "jsonrpc": "2.0",
  "method": "debug_traceTransaction",
  "params": [
    "0x...",
    {
        "tracer": "callTracer",
        "tracerConfig": {
            "withLog": true
        }
    }
  ],
  "id": 1
}

その結果、再帰的に入れ子になったコールフレーム構造が生成される(可読性のために簡略化)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
    "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
    "input": "0x765e827f...",
    "calls": [
      { /* ... */ },
      { /* ... */ },
      {
        "from": "0x0000000071727de22e5e9d8baf0edac6f37da032",
        "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
        "input": "0x0042dc53...",
        "calls": [
          {
			// ...
            "calls": [
              {
                "from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "to": "0xe1452ff880739d26f341c2510222e808def38d10",
                "input": "0xa9059cbb...",
                "logs": [
                  {
                    "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                    "topics": [ /* ... */ ],
                    "data": "0x...",
                    "position": "0x0",
                    "index": "0x1"
                  }
                ],
                "value": "0x0",
                "type": "DELEGATECALL"
              }
            ]
          },
          {
			// ...
            "calls": [
              {
                "from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "to": "0xe1452ff880739d26f341c2510222e808def38d10",
                "input": "0xa9059cbb...",
                "logs": [
                  {
                    "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                    "topics": [ /* ... */ ],
                    "data": "0x...",
                    "position": "0x0",
                    "index": "0x2"
                  }
                ],
                "value": "0x0",
                "type": "DELEGATECALL"
              }
            ]
          }
        ]
      }
    ],
    "logs": [],
    "value": "0x0",
    "type": "CALL"
  }
}

この再帰構造を、木構造の関係性を保持するスキーマに平坦化します:

export type EvmCallType =
  | ('CALL' | 'DELEGATECALL' | 'STATICCALL' | 'CREATE' | 'CREATE2')
  | (string & {});

/**
 * Represents a single frame in an EVM transaction's call stack trace.
 *
 * Each call frame captures the execution of one contract call, including:
 * - Its position in the call tree (parent, depth, index)
 * - Call details (type, from, to, value, input, output)
 * - Gas metrics (gas available, gas used)
 *
 * Call frames form a tree structure via the parentId relationship, where:
 * - Root frame (depth 0): The initial transaction call
 * - Child frames: Calls made by the parent contract (CALL, DELEGATECALL, etc.)
 */
export interface IEvmCallFrameSchema {
  id: Id<'evmCallFrame'>;
  transactionHash: string;
  parentId: Id<'evmCallFrame'> | undefined;
  depth: number;
  index: number;
  type: EvmCallType;
  from: `0x${string}`;
  to: `0x${string}` | undefined;
  value: string;
  input: `0x${string}`;
  output: `0x${string}` | undefined;
  gas: string | undefined;
  gasUsed: string | undefined;
  logs: {
    address: `0x${string}`;
    topics: `0x${string}`[];
    data: `0x${string}`;
    position: number;
    index: number;
  }[];
}
const root = debugTraceTransactionResp.result;

const recursivelyBuildSchemas = (
  frame: typeof root,
  index: number,
  parent?: InsertSchema<IEvmCallFrameSchema>
): InsertSchema<IEvmCallFrameSchema>[] => {
  const schema: InsertSchema<IEvmCallFrameSchema> = {
    id: generateId('evmCallFrame'),
    transactionHash,
    parentId: parent?.id,
    depth: (parent?.depth ?? -1) + 1,
    index,
    type: frame.type,
    from: frame.from,
    to: frame.to,
    value: (frame.value ? BigInt(frame.value) : BigInt(0)).toString(),
    input: frame.input,
    output: frame.output,
    gas: frame.gas ? BigInt(frame.gas).toString() : undefined,
    gasUsed: frame.gasUsed ? BigInt(frame.gasUsed).toString() : undefined,
    logs: (frame.logs ?? []).map((log) => ({
      ...log,
      position: Number(log.position),
      index: Number(log.index),
    })),
  };

  return [
    schema,
    ...(frame.calls ?? []).flatMap((call, index) =>
      recursivelyBuildSchemas(call, index, schema)
    ),
  ];
};

const schemas = recursivelyBuildSchemas(root, 0);

const trace: InsertSchema<IEvmTransactionTraceSchema> = {
  transactionHash,
  chain,
  rootFrameId: schemas[0].id,
};

await persistTransactionTrace({
  trace,
  callFrames: schemas,
});

バッチ処理されたUserOpを例に考えてみましょう。2つのUSDC送金(500ドルの支払いと1.10ドルの手数料)が含まれており、上記の実行トレースで表されています。このトレースからは以下の情報が得られます:

EntryPoint.handleOps()
...
└── SmartWallet.executeBatch()
    ├── Call 0: USDC.transfer(500.00 USDC to 0x...)
   └── Transfer(from, to, 500000000)
    ├── Call 1: USDC.transfer(1.10 USDC to Slash)
   └── Transfer(from, to, 1100000)
    └── ...

取引全体を木構造として表現できるようになった。これにより問題全体が再定義される:平坦なログ配列から構造を推測する代わりに、実行ツリーを再構築するのだ。ここでは呼び出し階層が明示され、ログはそれを発行したフレームに紐付けられる。

そこから、帰属は単純明快です。対応するノードを見つけます。 executeBatch() 呼び出し、その子要素をインデックスで反復処理する 0..N-1各サブツリーから再帰的にログを収集する。各子インデックス 0..N-1 対応するBlockchainCallに直接マッピングされる indexInBatchどのコールがどのログを生成したのか、今や正確に把握しています。

事実上すべてのトランザクションがこの帰属情報が必要であるため、我々はこれをログプロセッサに直接組み込みました。この処理は完全なコールツリーを再構築し、ログをその発生元フレームに照合し、バッチ内のすべてのBlockchainCallを解決します。各ログプロセッサは、処理対象のログに対して特定のコールとフレームのコンテキストを受け取ります:

const transferLogProcessor = createLogProcessor({
  abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
  requires: ({ call, callFrame }) => ({
    // ...
  }),
  process: ({ call, callFrame }) => {

    return {
     // ...
    };
  },
});

完全な帰属の連鎖:

```
Transfer log
 EvmCallFrame (index 0 under executeBatch)
 BlockchainCall (indexInBatch: 0)
 type: 'erc20.transfer'
 category: 'outbound_ach_credit'
 context: { recipientName: 'Acme Corp' }
```

グローバルUSD:集大成

オフランプとオンランプは、既存顧客(米ドル口座を持つ企業で法定通貨と暗号資産の間の移動を希望する)の問題を解決しました。しかし、別のセグメントからの声も絶えませんでした。米ドル決済網へのアクセスを必要とするものの、容易に入手できない国際企業からの声です。

アルゼンチンのソフトウェア請負業者、ナイジェリアの電子商取引事業者、東南アジアのSaaS企業にとって、米国銀行口座の開設には米国法人設立が必須となる場合が多い。弁護士、登録代理人、数ヶ月に及ぶ諸経費が必要だ。多くの正当な事業者がドル経済から事実上締め出されている。それは彼らの行為によるものではなく、単に設立された場所が原因である。

ステーブルコインはこの状況を変える。USDC残高はドル残高である。グローバルUSDは、その前提の上に銀行インフラを構築しようとする我々の試みだ。

設計上、非管理型

グローバルUSDは非保管型システムとして構築されました。この決定は、規制の複雑さと信頼性という二つの要因に基づいています。

顧客資金の保管には、管轄区域ごとに異なるライセンス要件が生じます。非保管型アーキテクチャは、こうした市場の多くにおいて当社のライセンス取得態勢を簡素化します。信託面では、顧客が自身の鍵を管理します——設計上、Slashはアカウント署名者からの暗号認証なしに送金を開始できません。

中核となる基本要素は スマートウォレットプログラム可能なアクセス制御を備えたウォレットとして機能するスマートコントラクト。

各グローバルUSD口座はマルチシグで管理されるスマートウォレットです。事業体の承認メンバー全員が鍵を保持します。送金には実行前に全員の承認が必要です。スラッシュは 準備する 取引ですが、私たちはできません 実行する 署名者の承認なしにそれを行う。

親権なしでの署名

これはUX上の疑問を提起します:ユーザーが鍵を管理する場合、シードフレーズを管理し、取引を手動で署名する必要はないのでしょうか?

当社はPrivyとAlchemyの組み込みウォレット基盤を利用しています。ユーザーがアカウントを作成すると、ハードウェアで隔離されたメモリ(「信頼実行環境」、TEE)内で秘密鍵が生成されます。 この鍵は存在しますが、Slashや他の誰にも直接アクセスできないように設計されています。ユーザーが送金を開始すると、OTPによる承認が行われ、これによりTEEがユーザーに代わって署名する権限が与えられます。署名されたトランザクションはその後ネットワークに送信されます。

ユーザー視点では、銀行振込を承認するような感覚です。カストディの観点では、私たちが秘密鍵に触れることは一切ありません。

これが解き放つもの

ラゴスの企業は、米国銀行口座なしで、保管リスクなしに、ドルを保有し、米国クライアントからの支払いを受け取り、国際ベンダーへ支払うことが可能になりました。これは、Slashの全顧客に適用される監査証跡とコンプライアンスワークフローをそのまま適用したものです。

ステーブルコインが実際に担う役割とは、単なる決済手段ではなく、よりアクセスしやすい金融システムを支える基盤的インフラである。

次に何をするか

私たちが構築した基盤技術は、法定通貨と暗号資産間の資金移動だけのためのものではありません。これらはSlashが構築するあらゆるものの基盤です。グローバルな口座提供を拡大し、設立場所に関わらずより多くの企業が米ドル決済網を利用できるようにしています。 さらにグローバルカードの開発も進めています。高還元率でステーブルコイン担保型のこのカードを使えば、顧客はどこでも残高を消費できます。両サービスとも、ここで説明したオーケストレーションと実行フレームワークを基盤としています。ここまで読み進めてくれた方の中で、急成長中の企業で実際の顧客の難解なインフラ課題を解決したいエンジニアの方、私たちは採用中です。

Read more from us