올해 초, 당사의 스테이블코인 결제 총액이 10억 달러를 돌파했습니다. 참고로, 당사가 첫 스테이블코인 거래를 처리한 지 12개월도 채 되지 않았습니다. 이는 소수의 고객들이 당연히 간단해야 할 것을 요청하면서 시작되었습니다: 전 세계 고객들로부터 스테이블코인 결제를 수취하고 해당 자금을 미국 달러로 결제하다- 신속하고 예측 가능하게, 수수료 부담 없이 모두 해결합니다. 기존 오프램프 솔루션은 1~3%의 수수료, 며칠 걸리는 결제 처리, 그리고 엄청나게 길고 번거로운 규정 준수 절차를 의미했습니다. 우리는 기회를 포착하고 2주 만에 MVP를 구축했습니다. 한 달 만에 실질적이고 상당한 거래량을 확보했습니다.

문제는 이를 확장하는 것이었다.

스테이블코인은 어디서든, 언제든 몇 초 만에 결제됩니다. 기존 은행 시스템은 이를 위해 설계되지 않았습니다. 일괄 처리, 영업일 마감, 며칠 걸리는 결제 기간에 의존하죠. 싱가포르 고객이 토요일 새벽 2시에 USDC로 5만 달러를 송금합니다. 온체인에서는 몇 초 만에 결제가 완료됩니다. 반면 귀하의 은행 계좌로 송금되는 ACH는 월요일까지 시작되지 않으며, 수요일까지 도착하지 않을 수 있고, 그 사이에 어딘가에서 검토 대기 상태에 머무를 수도 있습니다. 이 두 세계를 연결한다는 것은 서로 소통하도록 설계되지 않은, 각자 고유한 상태를 가진 시스템들 간의 조정을 의미합니다.

이 글은 스테이블코인의 이동을 은행 수준의 자금 이동처럼 작동하도록 만들기 위해 구축한 두 가지 인프라 기본 요소(infrastructure primitives)에 관한 것입니다:

1. 자금 흐름장기간 실행되는 다중 시스템 금융 워크플로우를 위한 선언적 오케스트레이션 엔진

2. 우리 온체인 실행 프레임워크신뢰할 수 있는 온체인 운영을 위한 라이프사이클 모델

다른 모든 것—오프램프, 온램프, 그리고 비수탁형 글로벌 USD 계좌—은 이러한 기본 요소들을 조합하여 구축됩니다. 이 글에서 한 가지만 기억한다면: 스테이블코인은 쉽습니다; 스테이블코인 은행업 아니다.

자금 흐름

여러 외부 시스템을 통해 자금을 이동할 때는 임시방편적인 조율 이상의 것이 필요합니다. 전체 흐름을 선언적으로 표현할 수 있는 방법이 필요합니다: 어떤 이벤트에 대응하여 어떤 보장을 바탕으로 어떤 일이 발생해야 하는지. 또한 중간 단계에서 오류가 발생하더라도 해당 흐름이 감사 가능하고, 재개 가능하며, 올바르게 작동해야 합니다. 바로 이것이 자금 흐름(Flow of Funds)이 제공하는 것입니다.

자금 이동의 문제점

소프트웨어에서 발생하는 대부분의 오케스트레이션 문제는 실패를 우아하게 처리하는 데 관한 것입니다. 금융 오케스트레이션은 더 까다로운 제약 조건을 가집니다: 돈은 이미 움직이고 있다.

즉시 입금 흐름을 고려해 보십시오. 고객이 10,000 USDC를 수령합니다. 당사는 해당 자금을 즉시 활용할 수 있도록 (기본 ACH 결제 전) 계좌에 즉시 입금합니다. 이 과정에서 당사는 대출을 발행한 것입니다. 며칠 후 ACH가 도착하면 상환금과 수수료를 징수합니다.

두 개의 외부 시스템에 걸쳐 네 가지 작업이 수행됩니다: 암호화폐 청산, 대출 지급, ACH 결제, 수수료 징수. 각 단계는 이전 단계에 의존하며, 어느 단계든 실패할 수 있습니다. 분산된 상태와 임시방편적인 핸들러로는 이를 관리할 수 없습니다.

핵심 추상화

자금 흐름(Flow of Funds)은 선언적 규칙 기반 오케스트레이션 시스템입니다. 핵심 추상화는 세 부분으로 구성됩니다:

행사 이벤트는 어떤 일이 발생했다는 신호입니다—ACH 결제 완료, 자금 수취, 카드 승인 캡처 등이 해당됩니다. 이벤트는 외부 시스템에서 발생하거나 내부적으로 발생할 수 있습니다.

규칙 이벤트 발생 시 수행해야 할 작업을 정의합니다. 각 규칙은 트리거 이벤트 목록과 실행할 일련의 부수 효과를 지정합니다.

부작용 이벤트에 대한 대응으로 수행하는 작업들입니다: 이체 시작, 보류 생성, 대출 지급, 수수료 징수. 규칙은 한 번만 실행됩니다. 일치하는 이벤트가 처음 도착하면 부수 효과가 순서대로 실행되고 규칙이 소모됩니다. 이는 플로우 컨텍스트 내에서 항등성을 보장합니다.

왜 선언적인가?

대안은 명령형 오케스트레이션이다: 핸들러가 핸들러를 호출하고, 상태가 여러 테이블에 흩어져 있으며, "흐름"은 코드 조각들 사이의 암묵적 조율 속에서만 존재한다.

단순한 흐름에는 이 방법이 통합니다. 그러나 규정 준수 보류 및 부분적 실패가 발생하는 다일, 다중 시스템 금융 운영에서는 유지 관리가 불가능해집니다. 오류 처리는 임시방편적이며 복구 경로는 암묵적입니다. 6개월 후에는 누구도 "2단계 성공 후 3단계가 실패하면 어떻게 되나요?"라는 질문에 확신 있게 답할 수 없습니다.

선언적 규칙은 모델을 뒤집습니다. 상태 머신을 명시적으로 정의합니다: 이것들 이벤트 트리거 이것들 작업. 오케스트레이션 엔진은 실행, 지속성 및 복구를 처리합니다. 흐름 이다 문서.

보증

FoF는 우리가 의지할 수 있는 네 가지 불변량을 제공합니다:

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의 실제 적용

FoF를 금융 흐름 구성의 기반으로 삼음으로써, 핵심 제품 구축은 각 흐름에 적합한 규칙을 정의하는 문제로 귀결되었습니다. 추상화는 상대편 시스템이 무엇인지 신경 쓰지 않고 오로지 조율만 수행합니다.

오프램프와 온램프는 이 엔진이 조율하는 "흐름"으로, 새로운 외부 시스템인 스테이블코인/법정화폐 변환을 처리하는 암호화폐 공급자(또는 OTC 데스크)를 도입합니다. 다른 외부 시스템과 마찬가지로, 이들은 자체 조건에 따라 상태 업데이트를 전달하며, 이를 활용해 FoF 이벤트를 트리거할 수 있습니다. 이후에는 단순히 흐름 조합만 이루어집니다.

출구

오프램프를 통해 고객은 스테이블코인 결제를 수령하고 슬래시 계정에서 USD로 정산할 수 있습니다. 절차는 간단합니다:

  1. 고객은 저희 암호화폐 공급업체를 통해 생성된 입금 주소로 USDC 또는 USDT를 수령합니다.
  2. 제공자는 예금을 감지하고, USD로 환금한 후 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 })
        ),
    }))
  );

진입로

온램프는 그 반대입니다: 고객이 슬래시 계정에서 USD를 보내면 외부 지갑에서 스테이블코인을 수령합니다. 흐름:

  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
}

블록체인 호출(BlockchainCall)을 작업의 원자 단위로 취급합니다. 트랜잭션은 여러 호출을 묶을 수 있지만, 각 호출은 단일 책임성 있는 작업을 나타냅니다. 요청 필드는 인코딩된 바이트 외에도 전체 타입화된 입력값을 보존합니다. 여기에는 메타데이터와 임의의 컨텍스트도 포함됩니다. 이 메타데이터 덕분에 체인 상태를 비즈니스 운영과 재조정할 때 "$500 송금의 목적은 무엇이었는가?"라는 질문에 답할 수 있습니다.

실행: 거래를 최종 확정으로 이끄는 과정

거래를 제출하는 것은 간단해 보입니다. 실제로는 "이걸 보내"와 "도착했어" 사이에 지뢰밭이 깔려 있습니다.

거래를 제출하면 블록에 바로 들어가지 않습니다. 메모풀- 거래가 블록 생성자가 처리할 때까지 대기하는 공간입니다. 대기 중 거래는 메모리 풀이 가득 차서 삭제되거나, 더 높은 수수료를 지불한 다른 거래에 밀려 처리되지 못하거나, 현재 네트워크 상태에 비해 가스 가격이 너무 낮아 처리되지 못할 수 있습니다.

가스 이더리움 기반 네트워크가 계산에 가격을 매기는 방식입니다. 모든 작업에는 가스 비용이 발생하며, 네트워크의 기본 토큰으로 가스 비용을 지불합니다. 트랜잭션을 제출할 때 지불할 최대 가스 가격을 지정합니다. 제출 후 네트워크 혼잡도가 급증하면 지정한 가스 가격이 더 이상 경쟁력이 없어질 수 있습니다. 이 경우 트랜잭션은 멤풀에 대기 상태로 남아 영원히 처리되지 않을 수도 있습니다.

거래가 블록에 포함된 후에도 진정한 최종 상태가 아닙니다. 블록체인은 재편성(reorgs)- 네트워크가 최근 블록을 폐기하고 다른 블록 체인으로 대체하는 상황입니다. 확정된 것으로 생각했던 거래가 사라질 수 있습니다. 성숙한 네트워크에서는 드문 일이지만, 실제 자금을 이동할 때는 '드물다'가 '절대 없다'를 의미하지 않습니다.

이러한 실패들은 각각 다른 계층에서 발생합니다: 가스 추정, 서명, 제출, 확인. 그리고 각각의 복구에는 다른 조치가 필요합니다—막힌 트랜잭션은 더 높은 가스로 재제출해야 하고, 무효한 서명은 재서명이 필요하며, 리오르그는 전체 흐름을 재시도해야 합니다.

이를 처리하기 위해 실행 라이프사이클을 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를 통해 승인합니다. 어느 쪽이든 서명이 완료되면 제출할 준비가 된 것입니다.

준비된 호출 실행 노드 단일 제출 시도입니다. 네트워크에 트랜잭션을 전송하고 포함 여부를 확인합니다. 재시도 가능한 사유(네트워크 시간 초과, 메모리 풀에서 삭제)로 실패할 경우 새 노드를 생성하여 다시 시도합니다.

각 계층은 자체 장애 영역을 처리합니다:

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번의 실패 후 더 이상 시도할 수 없게 되면, 이 실패는 실행(Execution) 계층으로 에스컬레이션됩니다. 실행 계층은 종단 상태에 도달하여 인텐트(Intent) 계층으로 에스컬레이션됩니다. 인텐트는 더 높은 가스 배율을 가진 자식 인텐트를 생성하고, 새로운 PreparedCall을 구성하며, 이 사이클이 다시 시작됩니다.

각 레이어는 자체 실패 영역을 처리하지만, 에스컬레이션은 명시적으로 이루어집니다. 상위 인텐트는 전체 이력을 보존하며, 하위 인텐트는 조정된 매개변수로 새로운 시도를 수행합니다. 우리는 컨텍스트를 절대 잃지 않습니다. 우리는 다시 시도 중입니다.

조정: 체인 이벤트에서 제품 상태로

트랜잭션이 블록에 포함되었습니다. 이제 어떻게 되나요?

블록체인은 1000 USDC 전송이 발생했다는 사실을 직접 알려주지 않습니다. 거래가 실행되어 일부를 발행했다는 사실을 알려줄 뿐입니다. 이벤트 로그해당 로그를 분석하고 그 의미를 파악한 후, 그에 따라 내부 상태를 업데이트해야 합니다.

이벤트 로그는 스마트 계약이 실행 중 발생한 내용을 전달하는 방식입니다. 호출 시 이체 USDC 계약에서 함수가 실행되면, 해당 계약은 이체 세 가지 데이터(발신자, 수신자, 금액)를 포함한 이벤트입니다. 이 이벤트는 거래 영수증에 로그 항목으로 기록됩니다.

그러나 로그들은 16진수 인코딩된 값을 포함하는 주제 및 데이터 필드로 인코딩됩니다. 이를 파싱하려면 이벤트 시그니처를 알고 매개변수를 디코딩해야 합니다. 원시 전송 로그의 예시는 다음과 같습니다:

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

이것이 전송(Transfer)임을 어떻게 알 수 있나요? 각 로그의 주제[0] 이벤트 서명의 keccak256 해시입니다. 이 경우, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef 의 해시입니다. 이체(발신 주소 인덱스, 수신 주소 인덱스, uint256 값)- 표준 ERC-20 전송 이벤트. 색인된 서명 해시 다음으로 선언 순서대로 topics 배열에 저장됩니다. 이 Transfer 이벤트의 경우, from 에 있다 주제[1] 그리고 ~에 in 주제[2]. 인덱싱되지 않은 매개변수들처럼 가치 ABI로 인코딩되어 있습니다 데이터.

이 로그에서 전송 세부 정보를 추출합니다:

  • from: 주제[1] (32바이트, 0으로 채움) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • ~로: 주제[2] (32바이트, 0으로 채움) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • 가치: 데이터 uint256로 디코딩됨 → 0x4c4b40 = 5,000,000 (5 USDC, USDC는 소수점 이하 6자리이기 때문에).

모든 유형의 로그를 파싱하는 방법을 알아야 하는 정신적 부담은 악몽과 같습니다. 그래서 우리는 특정 이벤트 유형을 파싱하고 이를 도메인 상태로 변환하는 방법을 아는 로그 프로세서를 구축했습니다:

프로세서는 원시 로그 데이터를 입력받아, 이를 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,
        },
      ],
    };
  },
});

포용 대 확증

우리는 두 가지 별개의 라이프사이클 단계를 다룹니다:

  1. 포용 - 트랜잭션이 블록에 처음 등장합니다. 상태는 잠정적입니다—블록이 재구성되어 사라질 수 있습니다.
  2. 확인 - 블록이 충분한 깊이에 도달함(재구성 가능성을 제거할 만큼 충분한 후속 블록이 존재함). 상태는 최종 상태입니다.

이 구분이 중요합니다. 포함 시점에 보류 상태를 표시하도록 UI를 업데이트할 수는 있지만, 확인 전까지는 하류 FoF 흐름을 트리거하지 않습니다. 잠정 상태에 기반한 조치의 비용은 무제한입니다.

로그 프로세서는 개별 이벤트를 처리하지만, 종종 여러 프로세서 간 조정이 필요하거나 트랜잭션 수준의 상태를 추가해야 합니다. 트랜잭션 프로세서는 이를 통합합니다: 모든 로그 프로세서의 병합된 출력을 수신하여 변환하거나 추가하거나, 추가적인 다운스트림 효과를 트리거할 수 있습니다. 여기서 2단계 라이프사이클이 등장합니다. 거래 처리 포함 시 실행됨 - 우리는 잠정적 상태를 생성한다. 프로세스 확인 블록이 최종 확정되면 실행됩니다. 일반적으로 여기서 금융 운영의 라이프사이클을 완료합니다.

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... 이전 논의한 전송 이벤트 서명).

그런데 어느 것이 결제이고 어느 것이 수수료였을까? 영수증은 알려주지 않는다—로그는 일괄 처리 내 개별 호출이 아닌 최상위 거래에 귀속된다. 로그를 호출 순서대로 매칭하면 되지 않을까 생각할 수 있다. 하지만 이는 각 호출이 정확히 하나의 로그만 생성할 때만 가능하다. 단순 이체는 하나를 생성하지만, 스왑은 다섯 개를 생성할 수도 있다. 경계를 알지 못하면 이를 안정적으로 매핑할 수 없다.

콜 프레임 추적

해결책은 결국 디버그_트레이스_트랜잭션- 대부분의 사람들이 실패한 트랜잭션 디버깅에 사용하는 게스 아카이브 노드 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,
});

두 건의 USDC 이체(500달러 결제와 1.10달러 수수료)로 구성된 배치된 UserOp를 고려해 보자. 이는 위 실행 추적으로 표현된다. 이 추적은 다음과 같은 정보를 제공한다:

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에 직접 매핑됩니다 배치 내 인덱스이제 우리는 어떤 호출이 어떤 로그를 생성했는지 정확히 알고 있습니다.

거의 모든 트랜잭션이 이 속성을 필요로 하기 때문에, 우리는 이를 로그 프로세서에 직접 구축했습니다. 이 프로세서는 전체 호출 트리를 재구성하고, 로그를 해당 발생 프레임과 매칭하며, 배치 내 모든 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 계정은 다중 서명(multi-sig)으로 관리되는 스마트 지갑입니다. 해당 사업체의 모든 승인된 구성원이 키를 보유합니다. 이체는 실행 전에 그들의 승인이 필요합니다. Slash는 준비하다 거래이지만, 우리는 할 수 없습니다 실행하다 서명자의 승인 없이

양육권 없이 서명하기

이것은 UX 측면에서 의문을 제기합니다: 사용자가 키를 통제한다면, 시드 문구를 관리하고 거래를 수동으로 서명해야 하지 않을까요?

우리는 Privy와 Alchemy의 임베디드 지갑 인프라를 사용합니다. 사용자가 계정을 생성할 때, 하드웨어 격리 메모리(신뢰 실행 환경, TEE) 내에서 개인 키가 생성됩니다. 이 키는 존재하지만 Slash나 다른 누구도 직접 접근할 수 없도록 설계되었습니다. 사용자가 전송을 시작하면 OTP를 통해 승인하며, 이로써 TEE가 사용자를 대신해 서명할 권한을 부여받습니다. 이후 서명된 거래가 네트워크에 제출됩니다.

사용자 관점에서는 은행 송금을 승인하는 것과 같은 느낌입니다. 수탁 관점에서는 개인 키를 절대 건드리지 않습니다.

이것이 열어주는 것

라고스의 기업은 이제 미국 은행 계좌 없이도 달러를 보유할 수 있으며, 미국 고객으로부터 대금을 수취하고 해외 공급업체에 지급할 수 있습니다. 이는 수탁 위험 없이, 모든 Slash 고객에게 적용되는 것과 동일한 감사 추적 및 규정 준수 워크플로우를 통해 가능합니다.

스테이블코인이 실제로 될 수 있는 것은 바로 이것입니다: 단순한 결제 수단이 아닌, 보다 접근성 높은 금융 시스템을 위한 기초 인프라입니다.

다음은 무엇인가요?

우리가 구축한 기본 기능들은 단순히 법정화폐와 암호화폐 간 자금 이동을 위한 것이 아닙니다. 이는 Slash에서 구축 중인 모든 것의 기반이 됩니다. 우리는 글로벌 계좌 서비스를 확장 중이며, 설립 지역에 관계없이 더 많은 기업이 USD 결제 인프라를 이용할 수 있도록 지원하고 있습니다. 또한 글로벌 카드를 구축 중입니다: 높은 캐시백과 스테이블코인으로 뒷받침되는 이 카드로 고객은 어디서든 잔액을 사용할 수 있습니다. 두 서비스 모두 여기서 설명한 동일한 오케스트레이션 및 실행 프레임워크에 크게 의존합니다. 여기까지 읽으셨고, 빠르게 성장하는 기업에서 실제 고객을 위한 어려운 인프라 문제를 해결하고자 하는 엔지니어라면, 저희가 채용 중입니다.

Read more from us