A principios de este año, superamos los 1000 millones de dólares en volumen total de pagos con stablecoins. Para contextualizar, procesamos nuestra primera transacción con stablecoins hace menos de 12 meses. Todo comenzó cuando un puñado de clientes nos pidieron algo que debería ser sencillo: la posibilidad de recibir pagos con stablecoins de sus clientes de todo el mundo y disponer de esos fondos. liquidar en dólares estadounidenses- De forma rápida y predecible, sin tener que pagar comisiones exorbitantes. Las soluciones de salida existentes implicaban comisiones del 1-3 %, liquidaciones de varios días y procesos de cumplimiento increíblemente largos y frustrantes. Vimos una oportunidad y creamos un MVP en dos semanas. En un mes, ya teníamos un volumen real y significativo.

El reto era escalarlo.

Las stablecoins se liquidan en segundos, en cualquier lugar y en cualquier momento. Los sistemas bancarios tradicionales no están diseñados para eso: funcionan con procesamiento por lotes, cierres en días hábiles y ventanas de liquidación de varios días. Un cliente en Singapur le envía 50 000 dólares en USDC a las 2 de la madrugada de un sábado. En la cadena, se liquida en segundos. La transferencia ACH a su cuenta bancaria no se iniciará hasta el lunes, no llegará hasta el miércoles y es posible que se quede en revisión en algún momento entre medias. Tender un puente entre estos dos mundos significa coordinar sistemas que nunca se diseñaron para comunicarse entre sí, cada uno con su propio estado.

Esta publicación trata sobre las dos primitivas de infraestructura que creamos para que el movimiento de las monedas estables se comporte como el movimiento de dinero de grado bancario:

1. Flujo de fondos: un motor de orquestación declarativo para flujos de trabajo financieros multisistema de larga duración.

2. Nuestro Marco de ejecución en cadena: Un modelo de ciclo de vida para operaciones confiables en cadena.

Todo lo demás (salidas, entradas y nuestras cuentas Global USD sin custodia) se construye combinando estos elementos básicos. Si hay algo que debe quedarse de este artículo, es que las stablecoins son fáciles; las stablecoins banca no lo es.

Flujo de fondos

Cuando se transfieren fondos a través de múltiples sistemas externos, se necesita algo más que una coordinación ad hoc. Se necesita una forma de expresar todo el flujo de manera declarativa: qué debe suceder, en respuesta a qué eventos, con qué garantías. Y se necesita que ese flujo sea auditable, reanudable y correcto, incluso cuando los pasos fallan en medio del proceso. Eso es lo que nos ofrece Flow of Funds.

El problema con el movimiento de dinero

La mayoría de los retos de orquestación en el software tienen que ver con gestionar los fallos con elegancia. La orquestación financiera tiene una restricción más difícil: El dinero ya está en movimiento..

Consideremos un flujo de depósito instantáneo. Un cliente recibe 10 000 USD en USDC. Abonamos el importe en su cuenta inmediatamente (antes de que se liquide la ACH subyacente) para que pueda disponer de ese capital de inmediato. Entre bastidores, hemos concedido un préstamo. Cuando llega la ACH días después, cobramos el reembolso y las comisiones.

Son cuatro operaciones en dos sistemas externos: liquidación criptográfica, desembolso de préstamos, liquidación ACH y cobro de comisiones. Cada paso depende del anterior, y cualquiera de ellos puede fallar. No se puede gestionar esto con un estado disperso y gestores ad hoc.

La abstracción central

Flow of Funds es un sistema de orquestación declarativo y basado en reglas. La abstracción central consta de tres partes:

Eventos Son señales de que ha ocurrido algo: se ha liquidado una transferencia ACH, se han recibido fondos, se ha capturado una autorización de tarjeta, etc. Los eventos pueden provenir de sistemas externos o emitirse internamente.

Reglas Define qué debe suceder en respuesta a los eventos. Cada regla especifica una lista de eventos desencadenantes y una secuencia de efectos secundarios que se deben ejecutar.

Efectos secundarios son las acciones que realizamos en respuesta al evento: iniciar una transferencia, crear una retención, desembolsar un préstamo, cobrar una comisión. Una regla se activa una sola vez. La primera vez que se produce un evento coincidente, los efectos secundarios se ejecutan en orden y la regla se consume. Esto garantiza la idempotencia dentro del contexto del flujo.

¿Por qué declarativo?

La alternativa es la orquestación imperativa: controladores que llaman a controladores, estado disperso por tablas, el «flujo» existente solo en la coordinación implícita entre fragmentos de código.

Esto funciona para flujos sencillos. Sin embargo, en el caso de operaciones financieras de varios días y múltiples sistemas con retenciones por cumplimiento normativo y fallos parciales, resulta inviable. La gestión de errores es ad hoc. Las rutas de recuperación son implícitas. Seis meses después, nadie puede responder con seguridad a la pregunta «¿qué ocurre si el paso 3 falla después de que el paso 2 haya tenido éxito?».

Las reglas declarativas invierten el modelo. Se define explícitamente la máquina de estados: estos desencadenantes de eventos estos acciones. El motor de orquestación se encarga de la ejecución, la persistencia y la recuperación. El flujo es la documentación.

Garantías

FoF nos ofrece cuatro invariantes en las que podemos confiar:

1. Idempotencia - Una regla se activa exactamente una vez por contexto de flujo, independientemente de los eventos duplicados o los reintentos.

2. Reconciliación determinista - dados los mismos eventos, el flujo resuelve al mismo estado

3. Auditabilidad completa - Cada efecto secundario se rastrea hasta el evento desencadenante.

4. Composabilidad - Los flujos complejos se construyen a partir de reglas simples que se combinan sin convertirse en monolíticas.

Seguimiento de la ejecución

Realizamos un seguimiento de cada ejecución de efectos secundarios a través de registros de nodos, cada uno vinculado a su padre, formando un árbol de ejecución completo. Cuando el cumplimiento normativo requiere un registro de auditoría, podemos rastrear la ruta exacta a través del sistema.

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

Composabilidad: Reglas anidadas

Las reglas pueden generar reglas secundarias. Así es como se componen flujos complejos de varios pasos sin convertirse en monolíticos. Cuando se crea una transacción de salida, la regla inicial no intenta gestionarlo todo. Establece futuro reglas: oyentes que esperan eventos que ocurrirán más adelante:

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

La lógica de liquidación no existe como código muerto a la espera de ser invocado. Existe como una regla, a la espera de su evento. Cuando el webhook del proveedor bancario llega días después, la regla se activa y el flujo continúa. La regla principal se consume y la regla secundaria lleva adelante el contexto.

Esto también significa que los flujos se pueden componer de forma arbitraria. ¿Quieres implementar depósitos instantáneos? Es fácil. Añade una regla que desembolse el préstamo y establezca las normas de reembolso. Cada aspecto está aislado, pero todos ellos se combinan en un flujo coherente.

Contextos de ejecución de efectos secundarios

No todos los efectos secundarios son iguales. Algunos deben ser atómicos con la transacción de la base de datos. Otros llaman a API externas. Otros son del tipo «disparar y olvidar».

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

La recompensa

La mayor ventaja es cómo FoF cambia la forma en que los ingenieros escriben el código de orquestación en Slash. Sin él, cada ingeniero resuelve los mismos problemas de forma diferente. Todos reinventan la rueda, y todas las ruedas tienen formas ligeramente diferentes.

FoF eleva el nivel mínimo. Tú defines qué debería suceder, no cómo para gestionar todos los modos de fallo. El marco gestiona la ejecución, la persistencia y la observabilidad. Los nuevos ingenieros pueden leer una definición de flujo y comprenderla sin tener que rastrear capas de código imperativo. Además, es más difícil escribir código de orquestación defectuoso cuando la abstracción te obliga a ser explícito sobre los eventos, los efectos secundarios y las transiciones de estado.

Rampas: FoF en la práctica

Con FoF como base para componer flujos financieros, la creación de nuestros productos principales se convirtió en una cuestión de definir las reglas adecuadas para cada flujo. La abstracción no se preocupa por qué sistemas hay al otro lado, solo se encarga de la coordinación.

Las salidas y entradas son «flujos» orquestados por este motor, que introduce un nuevo sistema externo: un proveedor de criptomonedas (o mesa OTC) que gestiona las conversiones entre monedas estables y fiduciarias. Al igual que cualquier sistema externo, proporcionan actualizaciones de estado según sus propios términos, que podemos utilizar para activar eventos FoF. A partir de ahí, solo se trata de componer flujos.

Salidas

Las rampas de salida permiten a los clientes recibir pagos en monedas estables y liquidarlos en USD en su cuenta Slash. El proceso es muy sencillo:

  1. El cliente recibe USDC o USDT en una dirección de depósito que generamos a través de nuestro proveedor de criptomonedas.
  2. El proveedor detecta el depósito, lo liquida en USD e inicia una transferencia ACH o electrónica.
  3. Nuestro proveedor bancario recibe la transferencia entrante.
  4. Reconciliamos la transferencia con la transacción original y abonamos la cuenta.

Para los depósitos instantáneos, en los que acreditamos al cliente inmediatamente y cobramos el reembolso cuando se liquida la ACH, el flujo incluye el desembolso del préstamo, el cobro del reembolso y el cobro de comisiones. Cada preocupación es una regla independiente, que escucha su evento y se compone en un único flujo coherente. La definición de FoF es algo así:

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 acceso

Las rampas de entrada son lo contrario: los clientes envían USD desde su cuenta Slash y reciben stablecoins en una cartera externa. El flujo es el siguiente:

  1. El cliente inicia una transferencia a una dirección de monedero de destino.
  2. Creamos retenciones en su cuenta por el importe más las comisiones.
  3. Enviamos una transferencia ACH o bancaria a una instrucción de depósito a nuestro proveedor de criptomonedas.
  4. El proveedor recibe los fondos y entrega las monedas estables al 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',
			     // ...
         })),
    }))
  );

Lo más destacable es lo poco que se necesitó en cuanto a infraestructura nueva. El marco FoF y la lógica de reconciliación que creamos para las salidas se trasladaron directamente. Las entradas son reglas diferentes que escuchan eventos diferentes, pero con la misma maquinaria subyacente.

Ciclo de vida en cadena

FoF resolvió la coordinación en el lado fiduciario: sistemas bancarios, proveedores, cumplimiento normativo. Pero cuando empezamos a crear Global USD, nos topamos con una nueva superficie: la propia cadena. Conseguir que una transacción se realice en la cadena, confirmar que realmente se ha completado, gestionar los fallos y las reorganizaciones de la cadena, y obtener un estado preciso a partir de los resultados: ese es un problema de coordinación diferente. Necesitábamos las mismas garantías que teníamos con FoF, pero para la ejecución en la cadena.

El patrón: intención → ejecución → conciliación

Utilizamos un patrón coherente en todas las operaciones de cadena de bloques:

1. Intención: Declarar lo que estamos tratando de hacer.

2. Ejecutar: Enviar la transacción y guiarla hasta su inclusión en el bloque.

3 ReconciliarProcesar bloques confirmados, actualizar el estado interno, activar flujos descendentes.

Si usted proviene del mundo de las finanzas tradicionales, la analogía es sencilla:

  • Intención ≈ orden de pago
  • Ejecutar ≈ pendiente
  • Reconciliar ≈ contabilizado

Cada fase tiene responsabilidades y modos de fallo distintos. El resto de esta sección explica cómo construimos cada capa.

Antes de poder ejecutar nada, necesitamos definir qué estamos ejecutando. Una transacción de cadena de bloques es fundamentalmente una instrucción (una función invocada con parámetros), pero la cadena no entiende las instrucciones legibles para los humanos. Todo se codifica en calldata - una masa de bytes hexadecimales que especifica la función que se debe llamar y los argumentos que se deben pasar.

Por ejemplo, una simple transferencia de USDC («enviar 500 USDC a la dirección X») se convierte en:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

Los datos de llamadas sin procesar como estos son opacos y no te dicen nada sobre por qué dinero transferido. Y cuando se crean sistemas que deben realizar un seguimiento del contexto empresarial, no solo de los activos transferidos, sino también de que se trataba de un cobro de la factura n.º 1234, es necesario conservar ese contexto.

Resolvemos esto con un registro de definiciones de llamadas tipadas:

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

```

Los ingenieros trabajan con términos propios del dominio (contrato, destinatario, importe) en lugar de cadenas hexadecimales. El registro valida las entradas, gestiona la codificación y conserva los metadatos que necesitaremos más adelante: categoría, etiquetas y contexto empresarial.

Crear una llamada se vuelve muy sencillo:

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 llamada se convierte en un 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 BlockchainCall como la unidad atómica de trabajo. Una transacción puede agrupar varias llamadas, pero cada llamada representa una única operación contabilizable. El campo de solicitud conserva la entrada completa tipificada, además de los bytes codificados, incluidos los metadatos y el contexto arbitrario. Estos metadatos son los que nos permiten responder a la pregunta «¿para qué fue esta transferencia de 500 dólares?» cuando conciliamos el estado de la cadena con las operaciones comerciales.

Ejecución: acompañamiento de las transacciones hasta su finalización

Enviar una transacción parece sencillo. En la práctica, hay un campo minado entre «enviar esto» y «ha llegado».

Cuando envías una transacción, esta no va directamente a un bloque. Entra en el mempool- Una zona de espera donde se almacenan las transacciones hasta que un productor de bloques las recoge. Mientras esperan, las transacciones pueden descartarse (el mempool está lleno), ser superadas (alguien ha pagado comisiones más altas) o quedarse bloqueadas (el precio del gas es demasiado bajo para las condiciones actuales de la red).

Gas Es así como las redes basadas en Ethereum fijan el precio de los cálculos. Cada operación cuesta gas, y el gas se paga con el token nativo de la red. Cuando envías una transacción, especificas el precio máximo del gas que estás dispuesto a pagar. Si la congestión de la red aumenta después de enviarla, es posible que tu precio del gas ya no sea competitivo y tu transacción quede en la memoria intermedia, esperando, posiblemente para siempre.

Incluso después de que una transacción se incluya en un bloque, no es realmente definitiva. Las cadenas de bloques pueden experimentar reorganizaciones (reorgs) Situaciones en las que la red descarta bloques recientes y los sustituye por una cadena de bloques diferente. Una transacción que creías confirmada puede desaparecer. Esto es poco frecuente en redes maduras, pero «poco frecuente» no es «nunca» cuando se mueve dinero real.

Cada uno de estos fallos se produce en una capa diferente: estimación de gas, firma, envío, confirmación. Y la recuperación de cada uno de ellos requiere una solución diferente: una transacción bloqueada debe volver a enviarse con más gas, una firma no válida debe volver a firmarse, una reorganización requiere que se vuelva a intentar todo el flujo.

Para ello, modelamos el ciclo de vida de la ejecución como una jerarquía de cuatro entidades. En la parte superior se encuentra el resultado empresarial que queremos conseguir. Por debajo, hay capas cada vez más específicas que se encargan de la preparación, la firma y el envío. Cada capa tiene su propio dominio de error y puede reintentar de forma independiente antes de pasar a la capa superior:

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

BlockchainIntento representa el resultado comercial: «transferir 500 USDC a esta dirección como pago de la factura n.º 1234». Es el coordinador de nivel superior que realiza un seguimiento de todo el ciclo de vida y puede generar reintentos si es necesario.

Llamada preparada es una transacción inmutable y sin firma con estimaciones de gas bloqueadas. Si las estimaciones de gas caducan (las condiciones de la red han cambiado), creamos una nueva PreparedCall.

Ejecución de llamada preparada representa un intento de firma. Para las operaciones del lado del servidor, firmamos automáticamente. Para las operaciones orientadas al usuario (como Global USD), el usuario da su aprobación mediante OTP. En cualquier caso, una vez firmadas, estamos listos para enviarlas.

Nodo de ejecución de llamada preparada Es un único intento de envío. Enviamos la transacción a la red y esperamos a que se incluya. Si falla por motivos que permiten reintentarlo (tiempo de espera de la red agotado, eliminado del mempool), creamos un nuevo nodo y lo intentamos de nuevo.

Cada capa gestiona su propio dominio de fallos:

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

La idea clave es que los fallos se escalan a la capa superior cuando una capa agota sus opciones de corrección. Consideremos una subestimación persistente del gas. La congestión de la red se dispara y los parámetros de gas bloqueados en nuestro PreparedCall ya no son competitivos. El nodo de ejecución vuelve a intentarlo varias veces y, tal vez, la congestión se resuelva. Después de N fallos, no puede hacer más. El fallo se eleva a Ejecución, que alcanza un estado terminal y se eleva a Intención. La Intención genera una intención secundaria con multiplicadores de gas más altos, construye un nuevo PreparedCall y el ciclo comienza de nuevo.

Cada capa gestiona su propio dominio de fallos, pero la escalada es explícita. La intención principal conserva el historial completo; la intención secundaria obtiene un nuevo intento con parámetros ajustados. Nunca perdemos el contexto sobre por qué Lo estamos intentando de nuevo.

Reconciliación: de los eventos en cadena al estado del producto

Una transacción se incluye en un bloque. ¿Y ahora qué?

La cadena de bloques no nos dice directamente que se haya producido una transferencia de 1000 USDC. Nos dice que se ha ejecutado una transacción y que se ha emitido algo. registros de eventos. Necesitamos analizar esos registros, averiguar qué significan y actualizar nuestro estado interno en consecuencia.

Los registros de eventos son la forma en que los contratos inteligentes comunican lo que sucedió durante la ejecución. Cuando llamas al transferencia función en un contrato USDC, el contrato emite un Transferencia evento con tres datos: quién lo envió, quién lo recibió y cuánto. Este evento se registra en el recibo de la transacción como una entrada de registro.

Pero los registros se codifican como campos de tema y datos que contienen valores codificados en hexadecimal. Para analizarlos es necesario conocer la firma del evento y decodificar los parámetros. Un registro de transferencia sin procesar tiene un aspecto similar al siguiente:

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

¿Cómo podemos saber que se trata de una transferencia? Cada registro de temas[0] es el hash keccak256 de la firma del evento, en este caso, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef es el hash de Transferencia (dirección indexada desde, dirección indexada a, valor uint256)- el evento de transferencia estándar ERC-20. Los parámetros marcados como indexado se almacenan en la matriz de temas en el orden de declaración, siguiendo el hash de la firma. Para este evento Transfer, desde está en temas[1] y a en temas[2]. Parámetros no indexados como valor están codificados por ABI en datos.

Extracción de los detalles de la transferencia de este registro:

  • desde: temas[1] (32 bytes, rellenados con ceros) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • a: temas[2] (32 bytes, rellenados con ceros) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • valor: datos decodificado como uint256 → 0x4c4b40 = 5 000 000 (5 USDC, ya que el USDC tiene 6 decimales).

La carga mental que supone saber cómo analizar cada tipo de registro parece una pesadilla, por lo que hemos creado procesadores de registros que saben cómo analizar tipos de eventos específicos y transformarlos en estado de dominio:

El procesador ingesta los datos de registro sin procesar, los analiza en campos tipificados (en lugar de cadenas hexadecimales) y produce entidades de dominio.

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

Inclusión frente a confirmación

Nos ocupamos de dos etapas distintas del ciclo de vida:

  1. Inclusión - La transacción aparece primero en un bloque. El estado es provisional: el bloque aún podría ser reorganizado.
  2. Confirmación - El bloque alcanza la profundidad suficiente (suficientes bloques posteriores para eliminar la posibilidad de una reorganización). El estado es definitivo.

Esta distinción es importante. Podríamos actualizar la interfaz de usuario para mostrar un estado pendiente en el momento de la inclusión, pero no activaríamos los flujos FoF posteriores hasta la confirmación. El coste de actuar en un estado provisional es ilimitado.

Los procesadores de registros gestionan eventos individuales, pero a menudo necesitamos coordinarlos entre sí o añadir un estado a nivel de transacción. Los procesadores de transacciones se encargan de ello: reciben la salida combinada de todos los procesadores de registros y pueden transformarla, añadirle elementos o desencadenar efectos adicionales posteriores. Aquí es también donde aparece el ciclo de vida de dos fases. procesoTransacción se ejecuta en inclusión: producimos un estado provisional. Confirmación del proceso Se ejecuta una vez que el bloque es definitivo; aquí es donde normalmente completamos los ciclos de vida de las operaciones financieras.

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

Conectar registros con llamadas

Cuando nuestro procesador de registros genera un registro de transferencia, debe vincularse al BlockchainCall original. El registro nos indica qué sucedió: los activos se trasladaron de A a B. BlockchainCall nos informa de ello. por qué—se trataba de un cobro de cuotas, un pago a un proveedor o un reembolso. En el caso de transacciones sencillas con una sola llamada, esto es muy sencillo. En el caso de transacciones por lotes, en las que agrupamos varias operaciones en una sola transacción en cadena para ahorrar gas, la cosa se complica. El recibo nos proporciona una lista plana de todos los registros emitidos durante la ejecución, sin indicar qué llamada generó cada registro. Resolvemos esto con el rastreo de marcos de llamada, que tratamos en la sección avanzada más adelante.

Avanzado: Atribuir registros por lotes a llamadas individuales

Esta sección trata un problema técnico específico relacionado con las transacciones por lotes. Si no estás trabajando con ERC-4337 o la ejecución por lotes, puedes pasar directamente a Global USD. Anteriormente mencionamos que conectar los registros con su BlockchainCall de origen es sencillo para transacciones simples. Para transacciones por lotes, no lo es.

El problema

Cuando agrupamos varias operaciones en una sola transacción, por ejemplo, un pago de 500 dólares más una comisión de 1 dólar, ambas se ejecutan de forma atómica. El recibo de la transacción nos proporciona una lista plana de todos los registros emitidos durante la ejecución:

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

Obtenemos una matriz plana de todos los registros emitidos durante la ejecución. Al observar este recibo, podemos identificar dos eventos de transferencia en los índices de registro 1 y 2 (ambos comparten el 0xddf252ad... transferir la firma del evento que hemos comentado anteriormente).

Pero, ¿cuál era el pago y cuál era la tarifa? El recibo no nos lo dice: los registros se atribuyen a la transacción de nivel superior, no a las llamadas individuales dentro de un lote. Se podría pensar: basta con hacer coincidir los registros con las llamadas en orden. Pero eso solo funciona si cada llamada emite exactamente un registro. Una simple transferencia emite uno; un intercambio puede emitir cinco. Sin conocer los límites, no se pueden asignar de forma fiable.

Seguimiento de tramas de llamada

La solución resultó ser depuración_rastreoTransacción- Un método RPC del nodo de archivo Geth que la mayoría de la gente utiliza para depurar transacciones fallidas. Pero hace algo más: reproduce la transacción y devuelve el árbol completo de marcos de llamada, con registros adjuntos en la profundidad correcta de la jerarquía de llamadas.

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

El resultado es una estructura recursivamente anidada de marcos de llamada (simplificada para facilitar la lectura).

{
  "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 esta estructura recursiva en un esquema que conserva las relaciones del árbol:

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

Consideremos una operación UserOp por lotes con dos transferencias USDC: un pago de 500 $ y una comisión de 1,10 $, representadas por el rastro de ejecución anterior. El rastro nos da:

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

Ahora toda la transacción se puede representar como un árbol. Esto replantea todo el problema: en lugar de inferir la estructura a partir de una matriz de registros plana, reconstruimos el árbol de ejecución, donde la jerarquía de llamadas es explícita y los registros se adjuntan a los marcos que los emitieron.

A partir de ahí, la atribución es sencilla. Busque el nodo correspondiente al ejecutarLote() llamar, iterar a través de sus hijos en los índices 0..N-1, y recopilar de forma recursiva los registros de cada subárbol. Cada índice secundario 0..N-1 se asigna directamente a su BlockchainCall correspondiente. índiceEnLoteAhora sabemos exactamente qué llamada generó cada registro.

Dado que prácticamente todas las transacciones necesitan esta atribución, la hemos integrado directamente en nuestro procesador de registros. Este reconstruye el árbol de llamadas completo, empareja los registros con sus tramas de origen y resuelve todas las BlockchainCalls del lote. A continuación, cada procesador de registros recibe la llamada específica y el contexto de la trama para el registro que está gestionando:

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

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

La cadena de atribución completa:

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

USD global: El Capstone

Las rampas de salida y entrada resolvieron un problema para nuestros clientes actuales: empresas con cuentas bancarias en EE. UU. que querían pasar de moneda fiduciaria a criptomoneda. Pero seguíamos recibiendo comentarios de un segmento diferente: empresas internacionales que necesitan acceso a los canales de dólares estadounidenses, pero no pueden obtenerlos fácilmente.

Si usted es un contratista de software en Argentina, un comerciante de comercio electrónico en Nigeria o una empresa de SaaS en el sudeste asiático, abrir una cuenta bancaria en los Estados Unidos a menudo requiere la constitución de una entidad estadounidense, lo que implica abogados, agentes registrados y meses de gastos generales. Muchas empresas legítimas se ven efectivamente excluidas de la economía del dólar, no por nada que hayan hecho, sino por el lugar en el que están constituidas.

Las stablecoins cambian esto. Un saldo en USDC es un saldo en dólares. Global USD es nuestro intento de construir una infraestructura bancaria sobre esa premisa.

Sin custodia por diseño

Creamos Global USD como un sistema sin custodia. La decisión se basó en dos factores: la complejidad normativa y la confianza.

La custodia de fondos de clientes conlleva requisitos de licencia que varían según la jurisdicción. Una arquitectura sin custodia simplifica nuestra postura en materia de licencias en muchos de estos mercados. En lo que respecta a la confianza, los clientes controlan sus propias claves: por diseño, Slash no puede iniciar transferencias sin la autorización criptográfica de los firmantes de la cuenta.

La primitiva central es la monedero inteligente: un contrato inteligente que actúa como una cartera, pero con control de acceso programable.

Cada cuenta Global USD es una cartera inteligente gobernada por una firma múltiple. Cada miembro autorizado de la empresa posee una clave. Las transferencias requieren su aprobación antes de ejecutarse. Slash puede preparar una transacción, pero no podemos ejecutar sin la autorización del firmante.

Firma sin custodia

Esto plantea una pregunta sobre la experiencia del usuario: si los usuarios controlan las claves, ¿no tienen que gestionar las frases semilla y firmar las transacciones manualmente?

Utilizamos la infraestructura de monedero integrado de Privy y Alchemy. Cuando un usuario crea una cuenta, se genera una clave privada dentro de una memoria aislada del hardware (un «entorno de ejecución confiable» o TEE). La clave existe, pero está diseñada para que Slash o cualquier otra persona no puedan acceder a ella directamente. Cuando un usuario inicia una transferencia, la aprueba mediante una contraseña de un solo uso (OTP), que autoriza al TEE a firmar en su nombre. A continuación, la transacción firmada se envía a la red.

Desde la perspectiva del usuario, es como aprobar una transferencia bancaria. Desde la perspectiva de la custodia, nunca tocamos las claves privadas.

Lo que esto desbloquea

Ahora, una empresa de Lagos puede mantener dólares, recibir pagos de clientes estadounidenses y pagar a proveedores internacionales, todo ello sin necesidad de tener una cuenta bancaria en Estados Unidos, sin riesgo de custodia y con el mismo registro de auditoría y los mismos flujos de trabajo de cumplimiento que aplicaríamos a cualquier cliente de Slash.

Eso es lo que realmente pueden ser las stablecoins: no solo un método de pago, sino una infraestructura fundamental para un sistema financiero más accesible.

¿Qué sigue?

Las primitivas que hemos creado no solo sirven para transferir dinero entre monedas fiduciarias y criptomonedas. Son la base de todo lo que estamos construyendo en Slash. Estamos ampliando nuestra oferta de cuentas globales, lo que permite a más empresas acceder a los canales de USD independientemente de dónde estén constituidas. Además, estamos desarrollando nuestra tarjeta global: una tarjeta con un alto porcentaje de reembolso, respaldada por monedas estables, que permite a los clientes gastar sus saldos en cualquier lugar. Ambas dependen en gran medida de los mismos marcos de coordinación y ejecución que hemos descrito aquí. Si has llegado hasta aquí y eres un ingeniero que quiere resolver problemas de infraestructura complejos para clientes reales en una empresa en rápido crecimiento, estamos contratando.

Read more from us