No início deste ano, ultrapassamos US$ 1 bilhão em volume total de pagamentos com stablecoins. Para contextualizar, processamos nossa primeira transação com stablecoins há menos de 12 meses. Tudo começou com alguns clientes solicitando algo que deveria ser simples: a capacidade de receber pagamentos com stablecoins de seus clientes em todo o mundo e ter esses fundos liquidar em dólares americanos- de forma rápida e previsível, sem sermos esmagados pelas taxas. As soluções off-ramp existentes implicavam taxas de 1 a 3%, liquidação em vários dias e processos de conformidade incrivelmente longos e frustrantes. Vimos uma oportunidade e criámos um MVP em duas semanas. Em um mês, já tínhamos um volume real e significativo.

O desafio era dimensioná-lo.

As stablecoins são liquidadas em segundos, em qualquer lugar, a qualquer hora. Os sistemas bancários tradicionais não foram criados para isso — eles funcionam com processamento em lote, cortes em dias úteis e janelas de liquidação de vários dias. Um cliente em Cingapura envia US$ 50.000 em USDC às 2 da manhã de um sábado. Na cadeia, a liquidação ocorre em segundos. A transferência ACH para sua conta bancária só será iniciada na segunda-feira, não chegará antes de quarta-feira e poderá ficar em análise em algum momento entre esses dias. Conectar esses dois mundos significa coordenar sistemas que nunca foram projetados para se comunicar entre si, cada um com seu próprio estado.

Esta publicação é sobre as duas primitivas de infraestrutura que criamos para fazer com que o movimento das stablecoins se comporte como o movimento de dinheiro bancário:

1. Fluxo de fundos: um mecanismo de orquestração declarativo para fluxos de trabalho financeiros de longa duração e multissistemas

2. Nosso Estrutura de execução na cadeia: Um modelo de ciclo de vida para operações confiáveis na cadeia

Todo o resto — rampas de saída, rampas de entrada e nossas contas globais em USD sem custódia — é construído a partir da composição desses elementos básicos. Se você tirar uma lição deste post: stablecoins são fáceis; stablecoin setor bancário não é.

Fluxo de fundos

Quando você movimenta dinheiro por meio de vários sistemas externos, precisa de algo mais do que uma coordenação ad hoc. Você precisa de uma maneira de expressar todo o fluxo de forma declarativa: o que deve acontecer, em resposta a quais eventos, com quais garantias. E você precisa que esse fluxo seja auditável, retomável e correto, mesmo quando as etapas falham no meio do caminho. É isso que o Fluxo de Fundos nos oferece.

O problema com a movimentação de dinheiro

A maioria dos desafios de orquestração em software diz respeito ao tratamento adequado de falhas. A orquestração financeira tem uma restrição mais difícil: o dinheiro já está em movimento.

Considere um fluxo de depósito instantâneo. Um cliente recebe US$ 10.000 em USDC. Creditamos sua conta imediatamente (antes da liquidação do ACH subjacente) para que ele possa utilizar esse capital imediatamente. Nos bastidores, emitimos um empréstimo. Quando o ACH chega dias depois, cobramos o reembolso e as taxas.

São quatro operações em dois sistemas externos: liquidação de criptomoedas, desembolso de empréstimos, liquidação ACH, cobrança de taxas. Cada etapa depende da anterior, e qualquer uma delas pode falhar. Não é possível gerenciar isso com manipuladores dispersos e ad hoc.

A abstração central

O Flow of Funds é um sistema de orquestração declarativo e baseado em regras. A abstração central tem três partes:

Eventos são sinais de que algo aconteceu — uma ACH foi liquidada, fundos foram recebidos, uma autorização de cartão foi capturada, etc. Os eventos podem vir de sistemas externos ou ser emitidos internamente.

Regras definir o que deve acontecer em resposta a eventos. Cada regra especifica uma lista de eventos desencadeadores e uma sequência de efeitos colaterais a serem executados.

Efeitos colaterais são as ações que tomamos em resposta ao evento: iniciar uma transferência, criar uma retenção, desembolsar um empréstimo, cobrar uma taxa. Uma regra é acionada uma vez. Na primeira vez que um evento correspondente chega, os efeitos colaterais são executados em ordem e a regra é consumida. Isso garante a idempotência dentro do contexto do fluxo.

Por que declarativo?

A alternativa é a orquestração imperativa: manipuladores chamando manipuladores, estado espalhado por tabelas, o “fluxo” existindo apenas na coordenação implícita entre partes do código.

Isso funciona para fluxos simples. Para operações financeiras de vários dias e vários sistemas, com retenções de conformidade e falhas parciais, torna-se inviável. O tratamento de erros é ad hoc. Os caminhos de recuperação são implícitos. Seis meses depois, ninguém consegue responder com segurança “o que acontece se a etapa 3 falhar após a etapa 2 ter sido bem-sucedida?”

As regras declarativas invertem o modelo. Você define a máquina de estados explicitamente: estes gatilho de eventos estes ações. O mecanismo de orquestração lida com a execução, persistência e recuperação. O fluxo é a documentação.

Garantias

O FoF nos oferece quatro invariantes nas quais podemos confiar:

1. Idempotência - uma regra é acionada exatamente uma vez por contexto de fluxo, independentemente de eventos duplicados ou novas tentativas

2. Reconciliação determinística - dados os mesmos eventos, o fluxo resolve para o mesmo estado

3. Auditoria completa - cada execução de efeito colateral é rastreada com linhagem até o evento desencadeador

4. Composição - fluxos complexos são construídos a partir de regras simples que se combinam sem se tornarem monolíticas

Acompanhamento da execução

Rastreamos cada execução de efeito colateral por meio de registros Node — cada um vinculado ao seu pai, formando uma árvore de execução completa. Quando a conformidade precisa de uma trilha de auditoria, podemos rastrear o caminho exato pelo sistema.

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

Composição: Regras Aninhadas

As regras podem gerar regras secundárias. É assim que fluxos complexos e com várias etapas são compostos sem se tornarem monolíticos. Quando uma transação de saída é criada, a regra inicial não tenta lidar com tudo. Ela configura futuro regras — ouvintes aguardando eventos que ocorrerão posteriormente:

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();

A lógica de liquidação não existe como código morto à espera de ser chamado. Ela existe como uma regra, aguardando seu evento. Quando o webhook do provedor bancário chega dias depois, a regra é acionada e o fluxo continua. A regra pai é consumida e a regra filha leva o contexto adiante.

Isso também significa que os fluxos são arbitrariamente composíveis. Quer implementar depósitos instantâneos? Fácil. Adicione uma regra que desembolse o empréstimo e defina regras de reembolso. Cada preocupação é isolada, mas todas elas se compõem em um fluxo coerente.

Contextos de execução de efeitos colaterais

Nem todos os efeitos colaterais são iguais. Alguns precisam ser atômicos com a transação do banco de dados. Alguns chamam APIs externas. Alguns são do tipo “disparar e esquecer”.

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

A Recompensa

A maior vantagem é como o FoF muda a forma como os engenheiros escrevem o código de orquestração na Slash. Sem ele, cada engenheiro resolve os mesmos problemas de maneira diferente. Todos reinventam a roda, e as rodas têm formas ligeiramente diferentes.

O FoF eleva o piso. Você define o que deveria acontecer, não como para lidar com todos os modos de falha. A estrutura lida com execução, persistência e observabilidade. Novos engenheiros podem ler uma definição de fluxo e compreendê-la sem precisar rastrear camadas de código imperativo. E é mais difícil escrever código de orquestração ruim quando a abstração obriga você a ser explícito sobre eventos, efeitos colaterais e transições de estado.

Rampas: FoF na prática

Com o FoF como base para compor fluxos financeiros, a criação de nossos principais produtos tornou-se uma questão de definir as regras certas para cada fluxo. A abstração não se importa com os sistemas que estão do outro lado, ela apenas coordena.

As rampas de saída e de entrada são “fluxos” orquestrados por este mecanismo, introduzindo um novo sistema externo: um provedor de criptomoedas (ou balcão OTC) que lida com conversões de stablecoins/moedas fiduciárias. Como qualquer sistema externo, eles fornecem atualizações de estado em seus próprios termos — que podemos usar para acionar eventos FoF. A partir daí, é apenas uma composição de fluxo.

Saídas

As rampas de saída permitem que os clientes recebam pagamentos em stablecoins e os liquidem em dólares americanos em suas contas Slash. O fluxo é simples:

  1. O cliente recebe USDC ou USDT em um endereço de depósito que geramos por meio de nosso provedor de criptomoedas.
  2. O provedor detecta o depósito, liquida em USD e inicia uma transferência ACH ou eletrônica.
  3. Nosso provedor bancário recebe a transferência recebida.
  4. Reconciliamos a transferência com a transação original e creditamos a conta.

Para depósitos instantâneos — nos quais creditamos o cliente imediatamente e cobramos o reembolso quando a ACH é liquidada —, o fluxo inclui o desembolso do empréstimo, a cobrança do reembolso e a captura da taxa. Cada preocupação é uma regra separada, atenta ao seu evento, compondo um único fluxo coerente. A definição do FoF é mais ou menos assim:

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 })
        ),
    }))
  );

Rampas de acesso

As rampas de entrada são o inverso: os clientes enviam dólares americanos de sua conta Slash e recebem stablecoins em uma carteira externa. O fluxo:

  1. O cliente inicia uma transferência para um endereço de carteira de destino.
  2. Criamos retenções em sua conta pelo valor mais as taxas.
  3. Enviamos uma transferência ACH ou eletrônica para uma instrução de depósito em nosso provedor de criptomoedas.
  4. O provedor recebe os fundos e entrega as stablecoins ao destino.
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',
			     // ...
         })),
    }))
  );

O que é notável é o quão pouca infraestrutura nova isso exigiu. A estrutura FoF e a lógica de reconciliação que criamos para as saídas foram transferidas diretamente. As entradas são regras diferentes que monitoram eventos diferentes, mas com a mesma mecânica subjacente.

Ciclo de vida na cadeia

O FoF resolveu a coordenação no lado fiduciário — bancos, provedores, conformidade. Mas quando começamos a construir o Global USD, nos deparamos com uma nova área: a própria cadeia. Colocar uma transação na cadeia, confirmar que ela realmente foi realizada, lidar com falhas e reorganizações da cadeia e derivar um estado preciso a partir dos resultados — esse é um problema de coordenação diferente. Precisávamos das mesmas garantias que tínhamos com o FoF, mas para a execução na cadeia.

O padrão: Intenção → Execução → Reconciliação

Utilizamos um padrão consistente em todas as operações de blockchain:

1. Intenção: Declarar o que estamos tentando fazer

2. Executar: Envie a transação e acompanhe-a até a inclusão no bloco

3 ReconciliarProcessar blocos confirmados, atualizar o estado interno, acionar fluxos a jusante

Se você vem do mundo das finanças tradicionais, a analogia é simples:

  • Intenção ≈ ordem de pagamento
  • Executar ≈ pendente
  • Reconciliar ≈ lançado

Cada fase tem responsabilidades e modos de falha distintos. O restante desta seção explica como construímos cada camada.

Antes de podermos executar qualquer coisa, precisamos definir o que estamos executando. Uma transação blockchain é fundamentalmente uma instrução (uma função invocada com parâmetros), mas a cadeia não compreende instruções legíveis por humanos. Tudo é codificado em calldata - um blob de bytes hexadecimais que especifica a função a ser chamada e os argumentos a serem passados.

Por exemplo, uma transferência simples de USDC — “enviar 500 USDC para o endereço X” — torna-se:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

Dados brutos como esses são opacos e não dizem nada sobre por que dinheiro movimentado. E quando você está construindo sistemas que precisam rastrear o contexto comercial — não apenas que os ativos foram transferidos, mas que se tratava de uma cobrança de taxa pela fatura nº 1234 —, você precisa preservar esse contexto.

Resolvemos isso com um registro de definições de chamadas digitadas:

```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),
    }),
  })
);

```

Os engenheiros trabalham com termos específicos do domínio — contrato, destinatário, valor — em vez de sequências hexadecimais. O registro valida as entradas, lida com a codificação e preserva os metadados de que precisaremos mais tarde: categoria, tags e contexto comercial.

Criar uma chamada torna-se simples:

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: {}
  },
})

Cada chamada se torna um registro 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
}

Tratamos o BlockchainCall como a unidade atômica de trabalho. Uma transação pode agrupar várias chamadas, mas cada chamada representa uma única operação responsável. O campo de solicitação preserva a entrada digitada completa, além dos bytes codificados, incluindo quaisquer metadados e contexto arbitrário. Esses metadados são o que nos permitem responder “para que serviu essa transferência de US$ 500?” quando reconciliamos o estado da cadeia com as operações comerciais.

Execução: conduzindo as transações até a conclusão

Enviar uma transação parece simples. Na prática, há um campo minado entre “enviar isto” e “chegou ao destino”.

Quando você envia uma transação, ela não vai diretamente para um bloco. Ela entra na mempool- uma área de espera onde as transações ficam até que um produtor de blocos as selecione. Enquanto aguardam, as transações podem ser descartadas (mempool está cheio), superadas (alguém pagou taxas mais altas) ou travadas (preço do gás muito baixo para as condições atuais da rede).

Gás é como as redes baseadas em Ethereum calculam o preço da computação. Cada operação custa gás, e você paga pelo gás com o token nativo da rede. Ao enviar uma transação, você especifica o preço máximo de gás que está disposto a pagar. Se o congestionamento da rede aumentar após o envio, seu preço de gás pode não ser mais competitivo — sua transação fica na mempool, esperando, potencialmente para sempre.

Mesmo depois que uma transação é incluída em um bloco, ela não está realmente finalizada. As cadeias de blocos podem sofrer reorganizações (reorgs) - situações em que a rede descarta blocos recentes e os substitui por uma cadeia de blocos diferente. Uma transação que você pensava estar confirmada pode desaparecer. Isso é raro em redes maduras, mas “raro” não significa “impossível” quando se trata de movimentar dinheiro real.

Cada uma dessas falhas ocorre em uma camada diferente: estimativa de gás, assinatura, envio, confirmação. E a recuperação de cada uma delas requer uma correção diferente — uma transação travada precisa ser reenviada com mais gás, uma assinatura inválida precisa ser reassinada, uma reorganização precisa que todo o fluxo seja repetido.

Lidamos com isso modelando o ciclo de vida da execução como uma hierarquia de quatro entidades. No topo está o resultado comercial que estamos tentando alcançar. Abaixo dele, camadas cada vez mais específicas lidam com a preparação, a assinatura e o envio. Cada camada possui seu próprio domínio de falha e pode tentar novamente de forma independente antes de escalar para a camada acima:

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

BlockchainIntent representa o resultado comercial: “transferir 500 USDC para este endereço como pagamento da fatura nº 1234”. É o orquestrador de nível superior que rastreia todo o ciclo de vida e pode gerar novas tentativas, se necessário.

Chamada preparada é uma transação imutável e sem assinatura com estimativas de gás bloqueadas. Se as estimativas de gás expirarem (condições da rede alteradas), criamos uma nova PreparedCall.

Execução de Chamada Preparada representa uma tentativa de assinatura. Para operações do lado do servidor, assinamos automaticamente. Para operações voltadas para o usuário (como o Global USD), o usuário aprova por meio de OTP. De qualquer forma, uma vez assinado, estamos prontos para enviar.

Nó de execução de chamada preparada é uma única tentativa de envio. Enviamos a transação para a rede e aguardamos a inclusão. Se falhar por motivos que permitam uma nova tentativa (tempo limite da rede, retirada do mempool), criamos um novo nó e tentamos novamente.

Cada camada lida com seu próprio domínio de falha:

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

A principal conclusão é que as falhas são escaladas para a camada pai quando uma camada esgota suas opções de correção. Considere a subestimação persistente do gás. O congestionamento da rede aumenta e os parâmetros de gás bloqueados em nosso PreparedCall não são mais competitivos. O nó de execução tenta novamente algumas vezes e talvez o congestionamento seja resolvido. Após N falhas, ele não pode fazer mais nada. A falha é escalada para Execution, que atinge um estado terminal e é escalada para Intent. O Intent gera um intent filho com multiplicadores de gás mais altos, constrói um novo PreparedCall e o ciclo recomeça.

Cada camada lida com seu próprio domínio de falha, mas a escalação é explícita. A intenção pai preserva o histórico completo; a intenção filho obtém uma nova tentativa com parâmetros ajustados. Nunca perdemos o contexto sobre por que Estamos tentando novamente.

Reconciliação: dos eventos em cadeia ao estado do produto

Uma transação é incluída em um bloco. E agora?

A blockchain não nos informa diretamente que ocorreu uma transferência de 1000 USDC. Ela nos informa que uma transação foi executada e emitiu alguns registros de eventosPrecisamos analisar esses registros, descobrir o que eles significam e atualizar nosso estado interno de acordo com isso.

Os registros de eventos são a forma como os contratos inteligentes comunicam o que aconteceu durante a execução. Quando você chama o transferência função em um contrato USDC, o contrato emite um Transferência evento com três dados: quem o enviou, quem o recebeu e quanto. Esse evento é registrado no recibo da transação como uma entrada de log.

Mas os registros são codificados como campos de tópicos e dados contendo valores codificados em hexadecimal. Para analisá-los, é necessário conhecer a assinatura do evento e decodificar os parâmetros. Um registro de transferência bruto se parece com isto:

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

Como podemos saber que se trata de uma transferência? Cada registro de log tópicos[0] é o hash keccak256 da assinatura do evento - neste caso, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef é o hash de Transferência (endereço de origem, endereço de destino, valor uint256)- o evento de transferência ERC-20 padrão. Parâmetros marcados como indexado são armazenados na matriz de tópicos na ordem de declaração, seguindo o hash da assinatura. Para este evento Transfer, de está em tópicos[1] e para em tópicos[2]. Parâmetros não indexados, como valor são codificados por ABI em dados.

Extraindo os detalhes da transferência deste registro:

  • de: tópicos[1] (32 bytes, preenchidos com zeros) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • para: tópicos[2] (32 bytes, preenchidos com zeros) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • valor: dados decodificado como uint256 → 0x4c4b40 = 5.000.000 (5 USDC, uma vez que o USDC tem 6 casas decimais).

O esforço mental necessário para saber como analisar cada tipo de registro parece um pesadelo — e é por isso que criamos processadores de registros que sabem como analisar tipos específicos de eventos e transformá-los em estado de domínio:

O processador ingere os dados brutos do log, analisa-os em campos tipados (em vez de strings hexadecimais) e produz entidades de domínio.

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,
        },
      ],
    };
  },
});

Inclusão versus confirmação

Lidamos com duas etapas distintas do ciclo de vida:

  1. Inclusão - A transação aparece pela primeira vez em um bloco. O estado é provisório — o bloco ainda pode ser reorganizado.
  2. Confirmação - O bloco atinge profundidade suficiente (blocos subsequentes suficientes para eliminar a possibilidade de uma reorganização). O estado é definitivo.

Essa distinção é importante. Podemos atualizar a interface do usuário para mostrar um status pendente na inclusão, mas não acionaríamos fluxos FoF a jusante até a confirmação. O custo de agir em um estado provisório é ilimitado.

Os processadores de log lidam com eventos individuais, mas muitas vezes precisamos coordená-los ou adicionar um estado no nível da transação. Os processadores de transação resumem isso: eles recebem a saída combinada de todos os processadores de log e podem transformá-la, adicionar algo a ela ou acionar efeitos adicionais a jusante. É aqui também que o ciclo de vida de duas fases aparece. processarTransação é executado na inclusão - produzimos um estado provisório. confirmação do processo é executado assim que o bloco é finalizado — normalmente é aqui que concluímos os ciclos de vida das operações financeiras.

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();

Conectando registros de volta às chamadas

Quando nosso processador de log produz um registro de transferência, ele precisa se conectar ao BlockchainCall original. O log nos informa o que aconteceu — os ativos foram transferidos de A para B. O BlockchainCall nos informa por que— tratava-se de uma cobrança de taxa, um pagamento a um fornecedor ou um reembolso. Para transações simples com uma única chamada, isso é fácil. Para transações em lote — nas quais agrupamos várias operações em uma única transação na cadeia para economizar gás —, fica mais difícil. O recibo nos fornece uma lista simples de todos os registros emitidos durante a execução, sem indicação de qual chamada produziu qual registro. Resolvemos isso com o rastreamento de estrutura de chamada, que abordamos na seção avançada abaixo.

Avançado: Atribuir registros em lote a chamadas individuais

Esta seção aborda um desafio técnico específico com transações em lote. Se você não estiver trabalhando com ERC-4337 ou execução em lote, sinta-se à vontade para pular para Global USD. Anteriormente, mencionamos que conectar registros de volta à sua BlockchainCall de origem é simples para transações simples. Para transações em lote, não é.

O Problema

Quando agrupamos várias operações em uma única transação — por exemplo, um pagamento de $500 mais uma taxa de $1 — ambas são executadas atomicamente. O recibo da transação nos fornece uma lista simples de todos os registros emitidos durante a execução:

{
    "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",
    }
}

Recebemos uma matriz plana de todos os registros emitidos durante a execução. Observando esse recibo, podemos identificar dois eventos de transferência nos índices de registro 1 e 2 (ambos compartilhando o 0xddf252ad... assinatura do evento de transferência que discutimos anteriormente).

Mas qual era o pagamento e qual era a taxa? O recibo não nos diz — os registros são atribuídos à transação de nível superior, não às chamadas individuais dentro de um lote. Você pode pensar: basta combinar os registros com as chamadas em ordem. Mas isso só funciona se cada chamada emitir exatamente um registro. Uma transferência simples emite um; uma troca pode emitir cinco. Sem conhecer os limites, você não pode mapeá-los de forma confiável.

Rastreamento de quadros de chamadas

A solução acabou sendo debug_traceTransaction- um método RPC do nó de arquivo Geth que a maioria das pessoas usa para depurar transações com falha. Mas ele faz outra coisa: reproduz a transação e retorna a árvore completa do quadro de chamadas, com logs anexados na profundidade correta na hierarquia de chamadas.

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

O resultado é uma estrutura recursivamente aninhada de quadros de chamada (simplificada para facilitar a leitura).

{
  "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"
  }
}

Aplanamos essa estrutura recursiva em um esquema que preserva as relações da árvore:

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,
});

Considere uma UserOp em lote com duas transferências USDC: um pagamento de US$ 500 e uma taxa de US$ 1,10, representadas pelo rastreamento de execução acima. O rastreamento nos fornece:

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)
    └── ...

Agora, toda a transação pode ser representada como uma árvore. Isso reformula todo o problema: em vez de inferir a estrutura a partir de uma matriz de registros plana, reconstruímos a árvore de execução, onde a hierarquia de chamadas é explícita e os registros são anexados aos quadros que os emitiram.

A partir daí, a atribuição é simples. Encontre o nó correspondente ao executarLote() chamar, iterar através de seus filhos nos índices 0..N-1e recolher recursivamente os registos de cada subárvore. Cada índice filho 0..N-1 mapeia diretamente para sua BlockchainCall correspondente índiceNoLoteAgora sabemos exatamente qual chamada gerou quais registros.

Como praticamente todas as transações precisam dessa atribuição, nós a incorporamos diretamente em nosso processador de registros. Ele reconstrói a árvore de chamadas completa, associa os registros aos seus quadros de origem e resolve todas as BlockchainCalls no lote. Cada processador de registros recebe então a chamada específica e o contexto do quadro para o registro que está tratando:

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

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

A cadeia completa de atribuição:

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

Global USD: O Capstone

As rampas de saída e de entrada resolveram um problema para nossos clientes existentes — empresas com contas bancárias nos EUA que queriam alternar entre moedas fiduciárias e criptomoedas. Mas continuamos ouvindo de um segmento diferente: empresas internacionais que precisam de acesso a canais de dólares americanos, mas não conseguem obtê-los facilmente.

Se você é um prestador de serviços de software na Argentina, um comerciante de comércio eletrônico na Nigéria ou uma empresa de SaaS no Sudeste Asiático, abrir uma conta bancária nos Estados Unidos geralmente requer a constituição de uma entidade nos Estados Unidos — advogados, agentes registrados e meses de despesas gerais. Muitas empresas legítimas estão efetivamente excluídas da economia do dólar, não por causa de algo que tenham feito, mas por causa do local onde estão constituídas.

As stablecoins mudam isso. Um saldo em USDC é um saldo em dólares. O Global USD é a nossa tentativa de construir uma infraestrutura bancária com base nessa premissa.

Sem custódia por padrão

Criamos o Global USD como um sistema sem custódia. A decisão foi motivada por dois fatores: complexidade regulatória e confiança.

A retenção de fundos de clientes introduz requisitos de licenciamento que variam de acordo com a jurisdição. Uma arquitetura sem custódia simplifica nossa postura de licenciamento em muitos desses mercados. No lado da confiança, os clientes controlam suas próprias chaves — por padrão, o Slash não pode iniciar transferências sem a autorização criptográfica dos signatários da conta.

A primitiva central é a carteira inteligente: um contrato inteligente que funciona como uma carteira, mas com controle de acesso programável.

Cada conta Global USD é uma carteira inteligente governada por uma assinatura múltipla. Todos os membros autorizados da empresa possuem uma chave. As transferências requerem a aprovação deles antes de serem executadas. Slash pode preparar uma transação, mas não podemos executar sem a autorização do signatário.

Assinatura sem custódia

Isso levanta uma questão relacionada à experiência do usuário: se os usuários controlam as chaves, eles não precisam gerenciar frases-semente e assinar transações manualmente?

Utilizamos a infraestrutura de carteira incorporada da Privy e da Alchemy. Quando um usuário cria uma conta, uma chave privada é gerada dentro de uma memória isolada do hardware (um “ambiente de execução confiável” ou TEE). A chave existe, mas foi projetada para ser inacessível ao Slash ou a qualquer outra pessoa diretamente. Quando um usuário inicia uma transferência, ele aprova por meio de OTP, que autoriza o TEE a assinar em seu nome. A transação assinada é então enviada à rede.

Do ponto de vista do usuário, é como aprovar uma transferência bancária. Do ponto de vista da custódia, nunca tocamos nas chaves privadas.

O que isso desbloqueia

Uma empresa em Lagos agora pode manter dólares, receber pagamentos de clientes dos EUA e pagar fornecedores internacionais — tudo isso sem uma conta bancária nos EUA, sem risco de custódia e com a mesma trilha de auditoria e fluxos de trabalho de conformidade que aplicamos a qualquer cliente da Slash.

É isso que as stablecoins podem realmente ser: não apenas um método de pagamento, mas uma infraestrutura fundamental para um sistema financeiro mais acessível.

O que vem a seguir

Os primitivos que criamos não servem apenas para transferir dinheiro entre moedas fiduciárias e criptomoedas. Eles são a base de tudo o que estamos construindo na Slash. Estamos expandindo nossas ofertas de contas globais, dando a mais empresas acesso a trilhos em dólares americanos, independentemente de onde elas estejam incorporadas. E estamos desenvolvendo nosso cartão global: um cartão com alto reembolso, lastreado em stablecoin, que permite aos clientes gastar seus saldos em qualquer lugar. Ambos dependem fortemente das mesmas estruturas de orquestração e execução que descrevemos aqui. Se você chegou até aqui e é um engenheiro que deseja resolver problemas complexos de infraestrutura para clientes reais em uma empresa em rápido crescimento, estamos contratando.

Read more from us