All'inizio di quest'anno abbiamo superato il miliardo di dollari di volume totale dei pagamenti in stablecoin. Per contestualizzare, abbiamo elaborato la nostra prima transazione in stablecoin meno di 12 mesi fa. Tutto è iniziato con una manciata di clienti che chiedevano qualcosa che dovrebbe essere semplice: la possibilità di ricevere pagamenti in stablecoin dai propri clienti in tutto il mondo e disporre di tali fondi. regolare in dollari statunitensi- in modo rapido e prevedibile, senza incorrere in commissioni esorbitanti. Le soluzioni off-ramp esistenti comportavano commissioni dell'1-3%, tempi di regolamento di più giorni e processi di conformità incredibilmente lunghi e frustranti. Abbiamo intravisto un'opportunità e abbiamo creato un MVP in due settimane. Nel giro di un mese, abbiamo raggiunto volumi reali e significativi.

La sfida era ridimensionarlo.

Le stablecoin vengono regolate in pochi secondi, ovunque e in qualsiasi momento. I sistemi bancari tradizionali non sono stati progettati per questo: funzionano con elaborazione batch, scadenze nei giorni lavorativi e finestre di regolamento di più giorni. Un cliente a Singapore ti invia 50.000 dollari in USDC alle 2 del mattino di sabato. Sulla catena, il pagamento viene regolato in pochi secondi. Il bonifico ACH sul tuo conto bancario non verrà avviato prima di lunedì, non arriverà prima di mercoledì e potrebbe rimanere in sospeso da qualche parte nel frattempo. Colmare il divario tra questi due mondi significa coordinare sistemi che non sono mai stati progettati per comunicare tra loro, ciascuno con il proprio stato.

Questo post riguarda le due primitive infrastrutturali che abbiamo creato per far sì che il movimento delle stablecoin si comporti come il movimento di denaro di livello bancario:

1. Flusso di fondi: un motore di orchestrazione dichiarativo per flussi di lavoro finanziari multisistema a lunga durata

2. Il nostro Struttura di esecuzione on-chain: Un modello di ciclo di vita per operazioni affidabili sulla catena

Tutto il resto – rampe di uscita, rampe di ingresso e i nostri conti Global USD non custoditi – è costruito componendo questi elementi primitivi. Se dovessi trarre una sola conclusione da questo post, sarebbe questa: le stablecoin sono facili; le stablecoin attività bancaria non lo è.

Flusso di fondi

Quando si trasferiscono fondi attraverso più sistemi esterni, è necessario qualcosa di più di un coordinamento ad hoc. È necessario un modo per esprimere l'intero flusso in modo dichiarativo: cosa dovrebbe accadere, in risposta a quali eventi, con quali garanzie. Inoltre, è necessario che tale flusso sia verificabile, ripristinabile e corretto anche quando alcuni passaggi falliscono durante l'esecuzione. Questo è ciò che ci offre Flow of Funds.

Il problema del movimento di denaro

La maggior parte delle sfide di orchestrazione nel software riguardano la gestione elegante dei guasti. L'orchestrazione finanziaria ha un vincolo più difficile: il denaro è già in movimento.

Consideriamo un flusso di deposito istantaneo. Un cliente riceve 10.000 USDC. Accreditiamo immediatamente il suo conto (prima che l'ACH sottostante venga regolato) in modo che possa utilizzare subito quel capitale. Dietro le quinte, abbiamo emesso un prestito. Quando l'ACH arriva alcuni giorni dopo, riscuotiamo il rimborso e le commissioni.

Si tratta di quattro operazioni su due sistemi esterni: liquidazione delle criptovalute, erogazione del prestito, regolamento ACH, riscossione delle commissioni. Ogni fase dipende dalla precedente e ognuna di esse può fallire. Non è possibile gestire tutto questo con gestori ad hoc e in condizioni di dispersione.

L'astrazione fondamentale

Flow of Funds è un sistema di orchestrazione dichiarativo e basato su regole. L'astrazione di base è composta da tre parti:

Eventi sono segnali che indicano che è avvenuto qualcosa: un pagamento ACH è stato regolato, sono stati ricevuti dei fondi, è stata acquisita un'autorizzazione per una carta, ecc. Gli eventi possono provenire da sistemi esterni o essere emessi internamente.

Regole definire cosa dovrebbe accadere in risposta agli eventi. Ogni regola specifica un elenco di eventi scatenanti e una sequenza di effetti collaterali da eseguire.

Effetti collaterali sono le azioni che intraprendiamo in risposta all'evento: avviare un trasferimento, creare una sospensione, erogare un prestito, riscuotere una commissione. Una regola viene attivata una sola volta. La prima volta che si verifica un evento corrispondente, gli effetti collaterali vengono eseguiti in ordine e la regola viene consumata. Ciò garantisce l'idempotenza all'interno del contesto del flusso.

Perché dichiarativo?

L'alternativa è l'orchestrazione imperativa: gestori che chiamano gestori, stato disperso tra tabelle, il "flusso" che esiste solo nel coordinamento implicito tra pezzi di codice.

Questo funziona per flussi semplici. Per operazioni finanziarie plurigiornalieri e multisistema con blocchi di conformità e guasti parziali, diventa ingestibile. La gestione degli errori è ad hoc. I percorsi di ripristino sono impliciti. Sei mesi dopo, nessuno è in grado di rispondere con sicurezza alla domanda "cosa succede se il passaggio 3 fallisce dopo che il passaggio 2 è andato a buon fine?".

Le regole dichiarative ribaltano il modello. Si definisce esplicitamente la macchina a stati: questi eventi trigger questi azioni. Il motore di orchestrazione gestisce l'esecuzione, la persistenza e il ripristino. Il flusso è la documentazione.

Garanzie

FoF ci offre quattro invarianti su cui possiamo fare affidamento:

1. Idempotenza - una regola viene attivata esattamente una volta per contesto di flusso, indipendentemente dagli eventi duplicati o dai tentativi di ripetizione

2. Riconciliazione deterministica - a parità di eventi, il flusso si risolve nello stesso stato

3. Piena verificabilità - ogni effetto collaterale viene tracciato con una tracciabilità che risale all'evento scatenante

4. Componibilità - I flussi complessi sono costruiti a partire da regole semplici che si combinano senza diventare monolitiche.

Monitoraggio dell'esecuzione

Tracciamo ogni esecuzione di effetti collaterali tramite i record Node, ciascuno collegato al proprio genitore, formando un albero di esecuzione completo. Quando la conformità richiede una traccia di audit, possiamo tracciare il percorso esatto attraverso il sistema.

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

Componibilità: regole nidificate

Le regole possono generare regole secondarie. È così che si compongono flussi complessi e articolati in più fasi senza diventare monolitici. Quando viene creata una transazione di uscita, la regola iniziale non cerca di gestire tutto. Imposta futuro regole: ascoltatori in attesa di eventi che si verificheranno in seguito:

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 logica di regolamento non esiste come codice inattivo in attesa di essere richiamato. Esiste come regola, in attesa del suo evento. Quando il webhook del fornitore bancario arriva giorni dopo, la regola si attiva e il flusso continua. La regola padre viene consumata e la regola figlio porta avanti il contesto.

Ciò significa anche che i flussi sono arbitrariamente componibili. Vuoi implementare depositi istantanei? Facile. Aggiungi una regola che eroga il prestito e imposta le regole di rimborso. Ogni aspetto è isolato, ma tutti insieme compongono un flusso coerente.

Contesti di esecuzione degli effetti collaterali

Non tutti gli effetti collaterali sono uguali. Alcuni devono essere atomici con la transazione del database. Alcuni richiamano API esterne. Alcuni sono di tipo "fire-and-forget".

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

Il guadagno

Il vantaggio principale è il modo in cui FoF cambia il modo in cui gli ingegneri scrivono il codice di orchestrazione presso Slash. Senza di esso, ogni ingegnere risolve gli stessi problemi in modo diverso. Tutti reinventano la ruota, e le ruote hanno tutte forme leggermente diverse.

FoF alza il livello minimo. Sei tu a definire cosa dovrebbe accadere, non come per gestire ogni modalità di errore. Il framework gestisce l'esecuzione, la persistenza e l'osservabilità. I nuovi ingegneri possono leggere una definizione di flusso e comprenderla senza dover tracciare i livelli di codice imperativo. Inoltre, è più difficile scrivere codice di orchestrazione errato quando l'astrazione ti obbliga a essere esplicito riguardo a eventi, effetti collaterali e transizioni di stato.

Rampe: FoF nella pratica

Con FoF come base per la composizione dei flussi finanziari, la creazione dei nostri prodotti principali è diventata una questione di definizione delle regole giuste per ciascun flusso. L'astrazione non si preoccupa di quali sistemi ci siano dall'altra parte, si limita a orchestrare.

Le rampe di uscita e di ingresso sono "flussi" orchestrati da questo motore, che introduce un nuovo sistema esterno: un fornitore di criptovalute (o desk OTC) che gestisce le conversioni tra stablecoin e valute legali. Come qualsiasi sistema esterno, forniscono aggiornamenti di stato secondo i propri termini, che possiamo utilizzare per attivare eventi FoF. Da lì, si tratta solo di composizione del flusso.

Uscite autostradali

Le rampe di uscita consentono ai clienti di ricevere pagamenti in stablecoin e di regolarli in USD nel proprio conto Slash. Il flusso è semplice:

  1. Il cliente riceve USDC o USDT a un indirizzo di deposito che generiamo tramite il nostro fornitore di criptovalute.
  2. Il fornitore rileva il deposito, lo liquida in USD e avvia un bonifico ACH o un bonifico bancario.
  3. Il nostro fornitore di servizi bancari riceve il bonifico in entrata
  4. Riconciliamo il trasferimento con la transazione originale e accreditiamo il conto.

Per i depositi istantanei, in cui accreditiamo immediatamente il cliente e riscuotiamo il rimborso al momento del regolamento ACH, il flusso include l'erogazione del prestito, la riscossione del rimborso e l'acquisizione delle commissioni. Ogni aspetto è una regola separata, che ascolta il proprio evento e si compone in un unico flusso coerente. La definizione FoF è simile a questa:

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

Rampanti

Le rampe di accesso sono l'opposto: i clienti inviano USD dal proprio conto Slash e ricevono stablecoin su un portafoglio esterno. Il flusso:

  1. Il cliente avvia un trasferimento verso un indirizzo di portafoglio di destinazione
  2. Creiamo dei depositi sul loro conto per l'importo più le commissioni.
  3. Inviamo un bonifico ACH o un bonifico bancario a un ordine di deposito presso il nostro fornitore di criptovalute.
  4. Il fornitore riceve i fondi e consegna le stablecoin alla destinazione.
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',
			     // ...
         })),
    }))
  );

Ciò che è degno di nota è quanto poco sia stato necessario investire in nuove infrastrutture. Il framework FoF e la logica di riconciliazione che abbiamo creato per gli off-ramp sono stati trasferiti direttamente. Gli on-ramp sono regole diverse che ascoltano eventi diversi, ma con lo stesso meccanismo di base.

Ciclo di vita on-chain

FoF ha risolto il problema del coordinamento dal lato fiat: infrastrutture bancarie, fornitori, conformità. Ma quando abbiamo iniziato a costruire Global USD, ci siamo imbattuti in una nuova superficie: la catena stessa. Ottenere una transazione sulla catena, confermare che sia effettivamente avvenuta, gestire i guasti e le riorganizzazioni della catena e ricavare uno stato accurato dai risultati: questo è un problema di coordinamento diverso. Avevamo bisogno delle stesse garanzie che avevamo con FoF, ma per l'esecuzione sulla catena.

Il modello: Intenzione → Esecuzione → Riconciliazione

Utilizziamo uno schema coerente in tutte le operazioni blockchain:

1. Intento: Dichiarare ciò che stiamo cercando di fare

2. Esegui: Invia la transazione e accompagnala fino all'inclusione nel blocco.

3 Riconciliare: Elaborazione dei blocchi confermati, aggiornamento dello stato interno, attivazione dei flussi a valle

Se provieni dal mondo della finanza tradizionale, l'analogia è semplice:

  • Intenzione ≈ ordine di pagamento
  • Esegui ≈ in sospeso
  • Riconcilia ≈ registrato

Ogni fase ha responsabilità e modalità di errore distinte. Il resto di questa sezione illustra come abbiamo costruito ogni livello.

Prima di poter eseguire qualsiasi operazione, è necessario definire cosa stiamo eseguendo. Una transazione blockchain è fondamentalmente un'istruzione (una funzione richiamata con parametri), ma la catena non comprende le istruzioni leggibili dall'uomo. Tutto viene codificato in calldata - un blocco di byte esadecimali che specifica la funzione da chiamare e gli argomenti da passare.

Ad esempio, un semplice trasferimento di USDC — "invia 500 USDC all'indirizzo X" — diventa:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

I dati grezzi delle chiamate come questi sono poco chiari e non dicono nulla su perché denaro trasferito. E quando si creano sistemi che devono tracciare il contesto aziendale, non solo il trasferimento di beni, ma anche la riscossione di una commissione per la fattura n. 1234, è necessario preservare tale contesto.

Risolviamo questo problema con un registro di definizioni di chiamate digitate:

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

```

Gli ingegneri lavorano in termini di dominio (contratto, destinatario, importo) anziché stringhe esadecimali. Il registro convalida gli input, gestisce la codifica e conserva i metadati di cui avremo bisogno in seguito: categoria, tag e contesto aziendale.

Creare una chiamata diventa semplicissimo:

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

Ogni chiamata diventa un record 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
}

Consideriamo BlockchainCall come l'unità atomica di lavoro. Una transazione può raggruppare più chiamate, ma ogni chiamata rappresenta una singola operazione verificabile. Il campo della richiesta conserva l'input digitato completo oltre ai byte codificati, inclusi eventuali metadati e contesti arbitrari. Questi metadati ci consentono di rispondere alla domanda "a cosa serviva questo trasferimento di 500 dollari?" quando riconciliamo lo stato della catena con le operazioni aziendali.

Esecuzione: accompagnare le transazioni fino alla loro conclusione definitiva

Inviare una transazione sembra semplice. In pratica, però, tra "invia questo" e "è arrivato" c'è un campo minato.

Quando invii una transazione, questa non viene inserita direttamente in un blocco. Entra nel mempool- un'area di attesa in cui le transazioni rimangono in sospeso fino a quando un produttore di blocchi non le preleva. Durante l'attesa, le transazioni possono essere eliminate (mempool pieno), superate (qualcuno ha pagato commissioni più elevate) o bloccate (prezzo del gas troppo basso per le attuali condizioni di rete).

Gas è il modo in cui le reti basate su Ethereum determinano il prezzo delle operazioni di calcolo. Ogni operazione ha un costo in gas, che viene pagato con il token nativo della rete. Quando si invia una transazione, si specifica il prezzo massimo del gas che si è disposti a pagare. Se dopo l'invio la congestione della rete aumenta, il prezzo del gas potrebbe non essere più competitivo e la transazione rimarrebbe in attesa nel mempool, potenzialmente per sempre.

Anche dopo che una transazione è stata inserita in un blocco, non è ancora definitiva. Le blockchain possono subire riorganizzazioni (reorgs) - situazioni in cui la rete scarta i blocchi recenti e li sostituisce con una catena di blocchi diversa. Una transazione che pensavi fosse confermata può scomparire. Questo è raro sulle reti mature, ma "raro" non significa "mai" quando si movimentano soldi veri.

Ciascuno di questi errori si verifica a un livello diverso: stima del gas, firma, invio, conferma. E il ripristino di ciascuno richiede una soluzione diversa: una transazione bloccata deve essere reinviata con un gas più elevato, una firma non valida deve essere rifirmata, una riorganizzazione richiede il riprovare l'intero flusso.

Gestiamo questo aspetto modellando il ciclo di vita dell'esecuzione come una gerarchia di 4 entità. Al vertice si trova il risultato aziendale che stiamo cercando di ottenere. Al di sotto di esso, livelli sempre più specifici gestiscono la preparazione, la firma e l'invio. Ogni livello possiede il proprio dominio di errore e può riprovare in modo indipendente prima di passare al livello superiore:

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

BlockchainIntent rappresenta il risultato aziendale: "trasferire 500 USDC a questo indirizzo come pagamento della fattura n. 1234". È l'orchestratore di livello superiore che tiene traccia dell'intero ciclo di vita e può generare nuovi tentativi, se necessario.

Chiamata preparata è una transazione immutabile e senza segno con stime di gas bloccate. Se le stime di gas scadono (condizioni di rete modificate), creiamo una nuova PreparedCall.

Esecuzione chiamata preparata rappresenta un tentativo di firma. Per le operazioni lato server, la firma viene apposta automaticamente. Per le operazioni rivolte agli utenti (come Global USD), l'utente approva tramite OTP. In entrambi i casi, una volta apposta la firma, siamo pronti per l'invio.

Nodo di esecuzione chiamata preparata è un singolo tentativo di invio. Inviamo la transazione alla rete e verifichiamo l'inclusione. Se fallisce per motivi che consentono un nuovo tentativo (timeout di rete, eliminazione dal mempool), creiamo un nuovo nodo e riproviamo.

Ogni livello gestisce il proprio dominio di errore:

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

L'intuizione chiave è che i guasti si propagano al livello superiore quando un livello esaurisce le sue opzioni di riparazione. Consideriamo una sottovalutazione persistente del gas. La congestione della rete raggiunge picchi elevati e i parametri del gas bloccati nel nostro PreparedCall non sono più competitivi. Il nodo di esecuzione riprova alcune volte e forse la congestione si risolve. Dopo N errori, non può fare altro. L'errore viene escalato a Execution, che raggiunge uno stato terminale e viene escalato a Intent. Intent genera un intent figlio con moltiplicatori di gas più elevati, costruisce un nuovo PreparedCall e il ciclo ricomincia.

Ogni livello gestisce il proprio dominio di errore, ma l'escalation è esplicita. L'intento padre conserva la cronologia completa; l'intento figlio ottiene un nuovo tentativo con parametri modificati. Non perdiamo mai il contesto relativo a perché Ci riproviamo.

Riconciliazione: dagli eventi a catena allo stato del prodotto

Una transazione è inclusa in un blocco. E adesso?

La blockchain non ci dice direttamente che è avvenuto un trasferimento di 1000 USDC. Ci dice che è stata eseguita una transazione e che sono stati emessi alcuni registri eventi. Dobbiamo analizzare quei log, capire cosa significano e aggiornare il nostro stato interno di conseguenza.

I registri degli eventi sono il modo in cui gli smart contract comunicano ciò che è accaduto durante l'esecuzione. Quando si chiama il trasferimento funzione su un contratto USDC, il contratto emette un Trasferimento evento con tre dati: chi lo ha inviato, chi lo ha ricevuto e per quale importo. Questo evento viene registrato nella ricevuta della transazione come voce di registro.

Ma i log sono codificati come campi argomento e dati contenenti valori codificati in esadecimale. Per analizzarli è necessario conoscere la firma dell'evento e decodificare i parametri. Un log di trasferimento grezzo ha un aspetto simile al seguente:

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

Come possiamo capire che si tratta di un trasferimento? Ogni registro argomenti[0] è l'hash keccak256 della firma dell'evento, in questo caso 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef è l'hash di Trasferimento (indirizzo indicizzato da, indirizzo indicizzato a, valore uint256)- l'evento di trasferimento standard ERC-20. I parametri contrassegnati come indicizzato sono memorizzati nell'array degli argomenti nell'ordine di dichiarazione, seguendo l'hash della firma. Per questo evento Transfer, da è in argomenti[1] e a in argomenti[2]. Parametri non indicizzati come valore sono codificati in ABI in dati.

Estrazione dei dettagli del trasferimento da questo registro:

  • da: argomenti[1] (32 byte, riempiti con zeri) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • a: argomenti[2] (32 byte, riempiti con zeri) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • valore: dati decodificato come uint256 → 0x4c4b40 = 5.000.000 (5 USDC, poiché l'USDC ha 6 decimali).

Il carico mentale necessario per sapere come analizzare ogni tipo di log sembra un incubo: ecco perché abbiamo creato dei processori di log in grado di analizzare tipi di eventi specifici e trasformarli in stato di dominio:

Il processore acquisisce i dati di log grezzi, li analizza in campi tipizzati (anziché stringhe esadecimali) e produce entità di 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,
        },
      ],
    };
  },
});

Inclusione contro conferma

Gestiamo due fasi distinte del ciclo di vita:

  1. Inclusione - La transazione appare per la prima volta in un blocco. Lo stato è provvisorio: il blocco potrebbe ancora essere riorganizzato.
  2. Conferma - Il blocco raggiunge una profondità sufficiente (un numero di blocchi successivi sufficiente a eliminare la possibilità di una riorganizzazione). Lo stato è definitivo.

Questa distinzione è importante. Potremmo aggiornare l'interfaccia utente per mostrare uno stato in sospeso al momento dell'inclusione, ma non attiveremmo i flussi FoF a valle fino alla conferma. Il costo di agire su uno stato provvisorio è illimitato.

I processori di log gestiscono singoli eventi, ma spesso è necessario coordinarli tra loro o aggiungere uno stato a livello di transazione. I processori di transazione racchiudono tutto questo: ricevono l'output unificato da tutti i processori di log e possono trasformarlo, aggiungervi elementi o attivare ulteriori effetti a valle. È qui che entra in gioco il ciclo di vita in due fasi. processTransaction funziona all'inclusione - produciamo uno stato provvisorio. processo di conferma viene eseguito una volta che il blocco è definitivo: in genere è qui che completiamo i cicli di vita delle operazioni finanziarie.

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

Collegamento dei registri alle chiamate

Quando il nostro processore di log produce un record di trasferimento, deve ricollegarsi al BlockchainCall di origine. Il log ci dice cosa è successo: le risorse sono state spostate da A a B. BlockchainCall ci dice perché—si trattava di una riscossione di commissioni, di un pagamento a un fornitore o di un rimborso. Per transazioni semplici con una sola chiamata, è tutto molto lineare. Per le transazioni in batch, in cui raggruppiamo più operazioni in un'unica transazione on-chain per risparmiare gas, diventa più difficile. La ricevuta ci fornisce un elenco piatto di tutti i log emessi durante l'esecuzione, senza alcuna indicazione di quale chiamata abbia prodotto quale log. Risolviamo questo problema con il tracciamento del frame di chiamata, che tratteremo nella sezione avanzata qui sotto.

Avanzato: attribuzione dei log in batch alle singole chiamate

Questa sezione tratta una sfida tecnica specifica relativa alle transazioni in batch. Se non stai lavorando con ERC-4337 o con l'esecuzione in batch, puoi passare direttamente alla sezione Global USD. In precedenza abbiamo detto che ricollegare i log alla BlockchainCall di origine è semplice per le transazioni semplici. Per le transazioni in batch, invece, non lo è.

Il problema

Quando raggruppiamo più operazioni in un'unica transazione, ad esempio un pagamento di 500 dollari più una commissione di 1 dollaro, entrambe vengono eseguite in modo atomico. La ricevuta della transazione ci fornisce un elenco piatto di tutti i log emessi durante l'esecuzione:

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

Otteniamo un array piatto di ogni log emesso durante l'esecuzione. Osservando questa ricevuta, possiamo identificare due eventi di trasferimento agli indici di log 1 e 2 (entrambi condividono il 0xddf252ad... trasferimento della firma dell'evento di cui abbiamo discusso in precedenza).

Ma quale era il pagamento e quale la commissione? La ricevuta non ce lo dice: i registri sono attribuiti alla transazione di livello superiore, non alle singole chiamate all'interno di un batch. Si potrebbe pensare: basta abbinare i registri alle chiamate in ordine. Ma questo funziona solo se ogni chiamata emette esattamente un registro. Un semplice trasferimento ne emette uno, uno scambio potrebbe emetterne cinque. Senza conoscere i confini, non è possibile mapparli in modo affidabile.

Tracciamento dei frame di chiamata

La soluzione si è rivelata essere debug_traceTransaction- un metodo RPC del nodo archivio Geth che la maggior parte delle persone utilizza per il debug delle transazioni non riuscite. Ma fa anche qualcos'altro: riproduce la transazione e restituisce l'albero completo dei frame di chiamata, con i log allegati alla profondità corretta nella gerarchia delle chiamate.

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

Il risultato è una struttura ricorsivamente annidata di frame di chiamata (semplificata per facilità di lettura)

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

Appiattiamo questa struttura ricorsiva in uno schema che preserva le relazioni ad albero:

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

Consideriamo un'operazione UserOp in batch con due trasferimenti USDC: un pagamento di 500 $ e una commissione di 1,10 $, rappresentati dalla traccia di esecuzione sopra riportata. La traccia ci fornisce:

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

L'intera transazione può ora essere rappresentata come un albero. Ciò ridefinisce l'intero problema: invece di dedurre la struttura da un array di log piatto, ricostruiamo l'albero di esecuzione, dove la gerarchia delle chiamate è esplicita e i log sono allegati ai frame che li hanno emessi.

Da lì, l'attribuzione è semplice. Trova il nodo corrispondente al eseguiBatch() chiama, itera attraverso i suoi figli agli indici 0..N-1e raccogliere ricorsivamente i log da ogni sottoalbero. Ogni indice figlio 0..N-1 mappa direttamente alla corrispondente BlockchainCall indiceInBatchOra sappiamo esattamente quale chiamata ha generato quali registri.

Poiché praticamente ogni transazione necessita di questa attribuzione, l'abbiamo integrata direttamente nel nostro processore di log. Esso ricostruisce l'intero albero delle chiamate, abbina i log ai frame di origine e risolve tutte le BlockchainCall nel batch. Ciascun processore di log riceve quindi la chiamata specifica e il contesto del frame per il log che sta gestendo:

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

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

La catena di attribuzione completa:

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

USD globale: il Capstone

Le rampe di uscita e di ingresso hanno risolto un problema per i nostri clienti esistenti, ovvero le aziende con conti bancari statunitensi che desideravano passare dalla valuta fiat alla criptovaluta. Tuttavia, continuavamo a ricevere segnalazioni da un altro segmento: le aziende internazionali che necessitano di accedere ai canali di pagamento in dollari statunitensi, ma non riescono a ottenerli facilmente.

Se sei un appaltatore di software in Argentina, un commerciante di e-commerce in Nigeria o un'azienda SaaS nel Sud-Est asiatico, aprire un conto bancario negli Stati Uniti richiede spesso la costituzione di un'entità statunitense, con avvocati, agenti registrati e mesi di spese generali. Molte aziende legittime sono di fatto escluse dall'economia del dollaro, non per qualcosa che hanno fatto, ma per il luogo in cui sono state costituite.

Le stablecoin cambiano questa situazione. Un saldo in USDC è un saldo in dollari. Global USD è il nostro tentativo di costruire un'infrastruttura bancaria basata su questa premessa.

Non custodito per definizione

Abbiamo creato Global USD come sistema non custodito. La decisione è stata dettata da due fattori: la complessità normativa e la fiducia.

La detenzione dei fondi dei clienti comporta requisiti di licenza che variano a seconda della giurisdizione. Un'architettura non custodita semplifica la nostra posizione in materia di licenze in molti di questi mercati. Dal punto di vista della fiducia, i clienti controllano le proprie chiavi: per come è progettato, Slash non può avviare trasferimenti senza l'autorizzazione crittografica dei firmatari del conto.

Il primitivo di base è il portafoglio intelligente: uno smart contract che funge da portafoglio ma con controllo degli accessi programmabile.

Ogni conto Global USD è un portafoglio intelligente gestito da una firma multipla. Ogni membro autorizzato dell'azienda possiede una chiave. I trasferimenti richiedono la loro approvazione prima di essere eseguiti. Slash può preparare una transazione, ma non possiamo eseguire senza l'autorizzazione del firmatario.

Firma senza custodia

Questo solleva una questione relativa all'esperienza utente: se gli utenti controllano le chiavi, non devono gestire manualmente le frasi seed e firmare le transazioni?

Utilizziamo l'infrastruttura di portafoglio integrata di Privy e Alchemy. Quando un utente crea un account, viene generata una chiave privata all'interno di una memoria isolata dall'hardware (un "ambiente di esecuzione affidabile" o TEE). La chiave esiste, ma è progettata per essere inaccessibile a Slash o a chiunque altro direttamente. Quando un utente avvia un trasferimento, lo approva tramite OTP, che autorizza il TEE a firmare per suo conto. La transazione firmata viene quindi inviata alla rete.

Dal punto di vista dell'utente, è come approvare un bonifico bancario. Dal punto di vista della custodia, non tocchiamo mai le chiavi private.

Cosa sblocca

Un'azienda con sede a Lagos può ora detenere dollari, ricevere pagamenti da clienti statunitensi e pagare fornitori internazionali, il tutto senza un conto bancario negli Stati Uniti, senza rischi di custodia e con la stessa tracciabilità dei controlli e gli stessi flussi di lavoro di conformità che applichiamo a qualsiasi cliente Slash.

Questo è ciò che le stablecoin possono effettivamente essere: non solo un metodo di pagamento, ma un'infrastruttura fondamentale per un sistema finanziario più accessibile.

Cosa succederà dopo

Le primitive che abbiamo creato non servono solo a trasferire denaro tra valute legali e criptovalute. Sono la base di tutto ciò che stiamo costruendo in Slash. Stiamo ampliando la nostra offerta di conti globali, consentendo a più aziende di accedere ai canali USD indipendentemente dal luogo in cui sono registrate. Inoltre, stiamo sviluppando la nostra carta globale: una carta con un elevato cashback, supportata da stablecoin, che consente ai clienti di spendere il proprio saldo ovunque. Entrambe si basano in larga misura sugli stessi framework di orchestrazione ed esecuzione che abbiamo descritto qui. Se sei arrivato fin qui e sei un ingegnere che desidera risolvere difficili problemi infrastrutturali per clienti reali in un'azienda in rapida crescita, stiamo assumendo.

Read more from us