Eerder dit jaar hebben we de grens van 1 miljard dollar aan totale stablecoin-betalingen overschreden. Ter vergelijking: minder dan 12 maanden geleden hebben we onze eerste stablecoin-transactie verwerkt. Het begon met een handvol klanten die om iets vroegen dat eenvoudig zou moeten zijn: de mogelijkheid om stablecoin-betalingen van hun klanten over de hele wereld te ontvangen en die fondsen te hebben. afrekenen in Amerikaanse dollars- snel en voorspelbaar, zonder hoge kosten. Bestaande off-ramp-oplossingen brachten 1-3% kosten met zich mee, meerdere dagen afwikkeling en ongelooflijk lange en frustrerende complianceprocessen. We zagen een kans en bouwden binnen twee weken een MVP. Binnen een maand hadden we een aanzienlijk volume gerealiseerd.

De uitdaging was om het op te schalen.

Stablecoins worden binnen enkele seconden afgewikkeld, overal en altijd. Traditionele banken zijn daar niet op ingericht: ze werken met batchverwerking, sluitingstijden op werkdagen en afwikkelingsperiodes van meerdere dagen. Een klant in Singapore stuurt u op zaterdag om 2 uur 's nachts 50.000 dollar in USDC. On-chain wordt dit binnen enkele seconden afgewikkeld. De ACH naar uw bankrekening wordt pas op maandag in gang gezet, komt pas op woensdag aan en blijft mogelijk ergens tussendoor in behandeling. Om deze twee werelden met elkaar te verbinden, moeten systemen worden gecoördineerd die nooit zijn ontworpen om met elkaar te communiceren en die elk hun eigen status hebben.

Dit bericht gaat over de twee infrastructuurprimitieven die we hebben gebouwd om stablecoin-transacties te laten functioneren als geldtransacties op bankniveau:

1. Geldstromen: een declaratieve orchestration-engine voor langlopende, multisysteem financiële workflows

2. Onze On-chain uitvoeringsraamwerk: Een levenscyclusmodel voor betrouwbare on-chain-operaties

Al het andere – afritten, opritten en onze niet-bewaarplichtige Global USD-rekeningen – is opgebouwd door deze basiselementen samen te voegen. Als u één ding uit dit bericht onthoudt: stablecoins zijn eenvoudig; stablecoin bankwezen is niet.

Geldstromen

Wanneer u geld via meerdere externe systemen verplaatst, hebt u meer nodig dan ad-hoccoördinatie. U hebt een manier nodig om de volledige stroom declaratief weer te geven: wat er moet gebeuren, in reactie op welke gebeurtenissen, met welke garanties. En u wilt dat die stroom controleerbaar, hervatbaar en correct is, zelfs wanneer stappen halverwege mislukken. Dat is wat Flow of Funds ons biedt.

Het probleem met geldstromen

De meeste uitdagingen op het gebied van orkestratie in software hebben te maken met het op een elegante manier omgaan met storingen. Financiële orkestratie kent een strengere beperking: het geld is al in beweging.

Stel je een directe storting voor. Een klant ontvangt $ 10.000 USDC. We crediteren dit bedrag onmiddellijk op zijn rekening (voordat de onderliggende ACH wordt verwerkt), zodat hij dat kapitaal meteen kan gebruiken. Achter de schermen hebben we een lening verstrekt. Wanneer de ACH enkele dagen later arriveert, innen we de terugbetaling en de kosten.

Dat zijn vier bewerkingen in twee externe systemen: cryptoliquidatie, uitbetaling van leningen, ACH-afwikkeling, inning van vergoedingen. Elke stap is afhankelijk van de vorige en elke stap kan mislukken. Dit kun je niet beheren met verspreide statussen en ad-hoc-handlers.

De kernabstractie

Flow of Funds is een declaratief, op regels gebaseerd orchestratiesysteem. De kernabstractie bestaat uit drie delen:

Evenementen zijn signalen dat er iets is gebeurd: een ACH is verwerkt, er is geld ontvangen, een kaartautorisatie is vastgelegd, enz. Gebeurtenissen kunnen afkomstig zijn van externe systemen of intern worden gegenereerd.

Regels bepaal wat er moet gebeuren als reactie op gebeurtenissen. Elke regel specificeert een lijst met triggerende gebeurtenissen en een reeks bijwerkingen die moeten worden uitgevoerd.

Bijwerkingen zijn de acties die we ondernemen als reactie op de gebeurtenis: een overschrijving initiëren, een blokkering aanbrengen, een lening uitbetalen, een vergoeding innen. Een regel wordt één keer geactiveerd. De eerste keer dat een overeenkomende gebeurtenis plaatsvindt, worden de neveneffecten in volgorde uitgevoerd en wordt de regel verbruikt. Dit garandeert idempotentie binnen de stroomcontext.

Waarom declaratief?

Het alternatief is imperatieve orkestratie: handlers die handlers aanroepen, status verspreid over tabellen, de 'flow' die alleen bestaat in de impliciete coördinatie tussen stukjes code.

Dat werkt voor eenvoudige processen. Voor financiële transacties die meerdere dagen en meerdere systemen omvatten, met compliance-blokkades en gedeeltelijke storingen, wordt het onhoudbaar. Foutverwerking gebeurt ad hoc. Herstelpaden zijn impliciet. Zes maanden later kan niemand met zekerheid antwoorden op de vraag: "Wat gebeurt er als stap 3 mislukt nadat stap 2 is geslaagd?"

Declaratieve regels draaien het model om. Je definieert de toestandsmachine expliciet: deze gebeurtenissen activeren deze acties. De orchestration engine zorgt voor de uitvoering, persistentie en herstel. De flow is de documentatie.

Garanties

FoF geeft ons vier invarianten waarop we kunnen vertrouwen:

1. Idempotentie - een regel wordt precies één keer per flowcontext geactiveerd, ongeacht dubbele gebeurtenissen of herhalingen

2. Deterministische afstemming - bij dezelfde gebeurtenissen resulteert de stroom in dezelfde toestand

3. Volledige controleerbaarheid - Elke uitvoering van een bijwerking wordt bijgehouden met een afstamming terug naar de gebeurtenis die deze heeft veroorzaakt.

4. Combineerbaarheid - complexe stromen worden opgebouwd uit eenvoudige regels die samen een geheel vormen zonder monolithisch te worden

Uitvoering volgen

We volgen elke uitvoering van bijwerkingen via Node-records, die elk aan hun bovenliggende record zijn gekoppeld en zo een complete uitvoeringsboom vormen. Wanneer compliance een audittrail vereist, kunnen we het exacte pad door het systeem traceren.

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

Combineerbaarheid: geneste regels

Regels kunnen onderliggende regels genereren. Zo kunnen complexe, meerstapsprocessen worden samengesteld zonder dat ze monolithisch worden. Wanneer een off-ramp-transactie wordt aangemaakt, probeert de oorspronkelijke regel niet alles af te handelen. Deze stelt toekomst regels — luisteraars die wachten op gebeurtenissen die later zullen plaatsvinden:

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

De afwikkelingslogica bestaat niet als dode code die wacht om te worden aangeroepen. Het bestaat als een regel, wachtend op zijn gebeurtenis. Wanneer de webhook van de bankprovider dagen later arriveert, wordt de regel geactiveerd en gaat de stroom verder. De bovenliggende regel wordt verbruikt en de onderliggende regel draagt de context verder.

Dit betekent ook dat stromen willekeurig kunnen worden samengesteld. Wilt u directe stortingen implementeren? Dat is eenvoudig. Voeg een regel toe die de lening uitbetaalt en regels voor terugbetaling instelt. Elk onderdeel staat op zichzelf, maar samen vormen ze een samenhangende stroom.

Bijwerking Uitvoeringscontexten

Niet alle bijwerkingen zijn gelijk. Sommige moeten atomair zijn met de databasetransactie. Sommige roepen externe API's aan. Sommige zijn van het type 'fire-and-forget'.

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

De beloning

Het grootste voordeel is hoe FoF de manier verandert waarop engineers bij Slash orkestratiecode schrijven. Zonder FoF lost elke engineer dezelfde problemen op een andere manier op. Iedereen vindt het wiel opnieuw uit, en die wielen hebben allemaal een iets andere vorm.

FoF verhoogt de ondergrens. U bepaalt wat zou moeten gebeuren, niet hoe om elke foutmodus af te handelen. Het framework zorgt voor de uitvoering, persistentie en observeerbaarheid. Nieuwe engineers kunnen een stroomdefinitie lezen en begrijpen zonder door lagen van imperatieve code te hoeven spitten. En het is moeilijker om slechte orchestration-code te schrijven wanneer de abstractie je dwingt om expliciet te zijn over gebeurtenissen, neveneffecten en toestandsveranderingen.

Hellingen: FoF in de praktijk

Met FoF als basis voor het samenstellen van financiële stromen, werd het bouwen van onze kernproducten een kwestie van het definiëren van de juiste regels voor elke stroom. De abstractie maakt niet uit welke systemen er aan de andere kant staan, het regisseert alleen maar.

Off-ramps en on-ramps zijn 'stromen' die door deze engine worden georkestreerd, waarbij een nieuw extern systeem wordt geïntroduceerd: een cryptoprovider (of OTC-desk) die stablecoin/fiat-conversies afhandelt. Net als elk extern systeem leveren ze statusupdates op hun eigen voorwaarden, die we kunnen gebruiken om FoF-events te activeren. Vanaf daar is het gewoon een kwestie van stroomcompositie.

Afritten

Via off-ramps kunnen klanten stablecoin-betalingen ontvangen en deze als USD in hun Slash-account verrekenen. De procedure is eenvoudig:

  1. De klant ontvangt USDC of USDT op een stortingsadres dat wij genereren via onze cryptoprovider.
  2. De aanbieder detecteert de storting, liquideert deze in USD en initieert een ACH- of bankoverschrijving.
  3. Onze bank ontvangt de inkomende overschrijving.
  4. We verwerken de overschrijving naar de oorspronkelijke transactie en crediteren de rekening.

Voor directe stortingen – waarbij we het bedrag onmiddellijk bij de klant crediteren en de terugbetaling innen wanneer de ACH wordt verrekend – omvat de stroom de uitbetaling van de lening, de inning van de terugbetaling en de registratie van de kosten. Elk onderdeel is een afzonderlijke regel, die luistert naar zijn gebeurtenis en samen een enkele samenhangende stroom vormt. De FoF-definitie ziet er ongeveer zo uit:

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

Opritten

On-ramps zijn het omgekeerde: klanten sturen USD vanaf hun Slash-account en ontvangen stablecoins in een externe wallet. De stroom:

  1. De klant initieert een overboeking naar een bestemmingsadres van een portemonnee.
  2. We maken een reservering op hun rekening voor het bedrag plus kosten.
  3. We sturen een ACH- of bankoverschrijving naar een stortingsinstructie bij onze cryptoprovider.
  4. De aanbieder ontvangt het geld en levert stablecoins aan de bestemming.
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',
			     // ...
         })),
    }))
  );

Opvallend is hoe weinig nieuwe infrastructuur hiervoor nodig was. Het FoF-framework en de afstemmingslogica die we voor off-ramps hadden gebouwd, konden direct worden overgenomen. On-ramps zijn andere regels die op andere gebeurtenissen reageren, maar de onderliggende mechanismen zijn hetzelfde.

Levenscyclus op de blockchain

FoF loste de coördinatie aan de fiat-kant op: bankrails, providers, compliance. Maar toen we begonnen met het bouwen van Global USD, kwamen we op een nieuw terrein terecht: de keten zelf. Een transactie op de keten krijgen, bevestigen dat deze daadwerkelijk is geland, omgaan met storingen en reorganisaties van de keten, en een nauwkeurige status afleiden uit de resultaten – dat is een heel ander coördinatieprobleem. We hadden dezelfde garanties nodig als bij FoF, maar dan voor uitvoering op de keten.

Het patroon: intentie → uitvoering → afstemming

We gebruiken een consistent patroon voor alle blockchain-bewerkingen:

1. Intentie: Verklaren wat we proberen te doen

2. Uitvoeren: Dien de transactie in en begeleid deze totdat deze in de blok wordt opgenomen.

3 Verzoenen: Bevestigde blokken verwerken, interne status bijwerken, downstream-stromen activeren

Als je uit de traditionele financiële wereld komt, is de analogie eenvoudig:

  • Intentie ≈ betalingsopdracht
  • Uitvoeren ≈ in behandeling
  • Afstemmen ≈ geboekt

Elke fase heeft verschillende verantwoordelijkheden en storingsmodi. In de rest van dit hoofdstuk wordt uitgelegd hoe we elke laag hebben opgebouwd.

Voordat we iets kunnen uitvoeren, moeten we eerst definiëren wat we uitvoeren. Een blockchain-transactie is in wezen een instructie (een functie die met parameters wordt aangeroepen), maar de keten begrijpt geen voor mensen leesbare instructies. Alles wordt gecodeerd in calldata - een blok van hex bytes dat de aan te roepen functie en de door te geven argumenten specificeert.

Een eenvoudige USDC-overboeking – 'stuur 500 USDC naar adres X' – wordt bijvoorbeeld:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

Ruwe call-data zoals deze is ondoorzichtig en zegt niets over waarom geld is verplaatst. En wanneer je systemen bouwt die de zakelijke context moeten bijhouden – niet alleen dat er activa zijn overgedragen, maar ook dat dit een incasso was voor factuur #1234 – moet je die context behouden.

We lossen dit op met een register van getypte oproepdefinities:

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

```

Ingenieurs werken met domeintermen – contract, ontvanger, bedrag – in plaats van hexadecimale strings. Het register valideert invoer, verzorgt de codering en bewaart de metadata die we later nodig hebben: categorie, tags en zakelijke context.

Het maken van een oproep wordt heel eenvoudig:

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

Elk gesprek wordt een BlockchainCall-record:

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
}

We behandelen de BlockchainCall als de atomaire werkeenheid. Een transactie kan meerdere oproepen omvatten, maar elke oproep vertegenwoordigt een enkele verantwoordelijke bewerking. Het verzoekveld bewaart de volledige getypte invoer naast de gecodeerde bytes, inclusief alle metadata en willekeurige context. Deze metadata stelt ons in staat om de vraag "waarvoor was deze overboeking van $ 500 bedoeld?" te beantwoorden wanneer we de ketenstatus afstemmen op de bedrijfsactiviteiten.

Uitvoering: transacties begeleiden tot voltooiing

Het indienen van een transactie klinkt eenvoudig. In de praktijk ligt er echter een mijnenveld tussen 'dit verzenden' en 'het is aangekomen'.

Wanneer u een transactie indient, wordt deze niet direct in een blok geplaatst. Deze komt terecht in de mempool- een wachtruimte waar transacties blijven staan totdat een blokproducent ze oppikt. Tijdens het wachten kunnen transacties worden verwijderd (mempool is vol), overboden (iemand heeft hogere kosten betaald) of vastlopen (gasprijs te laag voor de huidige netwerkomstandigheden).

Gas is hoe op Ethereum gebaseerde netwerken berekeningen prijzen. Elke bewerking kost gas, en je betaalt voor gas met het native token van het netwerk. Wanneer je een transactie indient, geef je de maximale gasprijs op die je bereid bent te betalen. Als het netwerk na het indienen van je transactie overbelast raakt, is je gasprijs mogelijk niet langer concurrerend en blijft je transactie in de mempool staan, waar ze mogelijk voor altijd blijft wachten.

Zelfs nadat een transactie in een blok is opgenomen, is deze nog niet definitief. Blockchains kunnen te maken krijgen met reorganisaties (reorgs) situaties waarin het netwerk recente blokken verwijdert en vervangt door een andere reeks blokken. Een transactie waarvan u dacht dat deze bevestigd was, kan verdwijnen. Dit komt zelden voor op volwassen netwerken, maar 'zeldzaam' is niet hetzelfde als 'nooit' wanneer u met echt geld werkt.

Elk van deze storingen vindt plaats op een ander niveau: gasraming, ondertekening, indiening, bevestiging. En voor elk daarvan is een andere oplossing nodig: een vastgelopen transactie moet opnieuw worden ingediend met meer gas, een ongeldige handtekening moet opnieuw worden ondertekend, een reorganisatie vereist dat de hele stroom opnieuw wordt uitgevoerd.

We pakken dit aan door de uitvoeringscyclus te modelleren als een hiërarchie van 4 entiteiten. Bovenaan staat het bedrijfsresultaat dat we willen bereiken. Daaronder zijn steeds specifiekere lagen die zich bezighouden met de voorbereiding, ondertekening en indiening. Elke laag heeft zijn eigen foutdomein en kan zelfstandig opnieuw proberen voordat het naar de bovenliggende laag wordt geëscaleerd:

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

BlockchainIntent vertegenwoordigt het bedrijfsresultaat: "maak 500 USDC over naar dit adres als betaling voor factuur #1234." Het is de hoogste orchestrator die de volledige levenscyclus bijhoudt en indien nodig nieuwe pogingen kan starten.

Voorbereide oproep is een onveranderlijke, niet-ondertekende transactie met vastgelegde gasramingen. Als gasramingen verlopen (netwerkcondities zijn gewijzigd), maken we een nieuwe PreparedCall aan.

Voorbereide oproepuitvoering vertegenwoordigt een poging tot ondertekening. Voor server-side bewerkingen ondertekenen we automatisch. Voor gebruikersgerichte bewerkingen (zoals Global USD) keurt de gebruiker goed via OTP. Hoe dan ook, zodra ondertekend, zijn we klaar om in te dienen.

VoorbereideOproepUitvoeringsknooppunt is een enkele poging tot verzending. We sturen de transactie naar het netwerk en vragen om opname. Als dit mislukt om redenen die opnieuw kunnen worden geprobeerd (time-out van het netwerk, verwijderd uit mempool), maken we een nieuw knooppunt aan en proberen we het opnieuw.

Elke laag behandelt zijn eigen foutdomein:

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

Het belangrijkste inzicht is dat storingen escaleren naar de bovenliggende laag wanneer een laag zijn herstelopties heeft uitgeput. Denk bijvoorbeeld aan een aanhoudende onderschatting van gas. De netwerkcongestie piekt en de gasparameters die in onze PreparedCall zijn vastgelegd, zijn niet langer concurrerend. Het uitvoeringsknooppunt doet een paar pogingen en misschien verdwijnt de congestie. Na N storingen kan het niets meer doen. De storing escaleert naar Execution, dat een eindtoestand bereikt en escaleert naar Intent. De Intent genereert een child intent met hogere gasmultiplicators, construeert een nieuwe PreparedCall en de cyclus begint opnieuw.

Elke laag behandelt zijn eigen foutdomein, maar escalatie is expliciet. De bovenliggende intent behoudt de volledige geschiedenis; de onderliggende intent krijgt een nieuwe poging met aangepaste parameters. We verliezen nooit de context over waarom We proberen het opnieuw.

Verzoening: van ketengebeurtenissen naar productstatus

Een transactie is opgenomen in een blok. Wat nu?

De blockchain vertelt ons niet direct dat er een overboeking van 1000 USDC heeft plaatsgevonden. Het vertelt ons dat er een transactie is uitgevoerd en dat er een bepaald bedrag is uitgegeven. gebeurtenislogboekenWe moeten die logbestanden analyseren, uitzoeken wat ze betekenen en onze interne status dienovereenkomstig bijwerken.

Gebeurtenislogboeken zijn de manier waarop slimme contracten communiceren wat er tijdens de uitvoering is gebeurd. Wanneer u de overdracht functie op een USDC-contract, het contract geeft een Overdracht gebeurtenis met drie gegevens: wie het heeft verzonden, wie het heeft ontvangen en hoeveel. Deze gebeurtenis wordt als logboekvermelding in het transactiebewijs vastgelegd.

Maar logbestanden worden gecodeerd als onderwerp- en gegevensvelden met hexadecimaal gecodeerde waarden. Om ze te parseren, moet je de gebeurtenissignatuur kennen en de parameters decoderen. Een onbewerkt overdrachtslogbestand ziet er ongeveer zo uit:

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

Hoe kunnen we zien dat dit een overdracht is? Elk logboek onderwerpen[0] is de keccak256-hash van de handtekening van de gebeurtenis - in dit geval 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef is de hash van Overdracht (adres geïndexeerd van, adres geïndexeerd naar, uint256-waarde)- de standaard ERC-20-overdracht. Parameters gemarkeerd als geïndexeerd worden opgeslagen in de topics-array in de volgorde van declaratie, na de signature hash. Voor deze Transfer-gebeurtenis, van is in onderwerpen[1] en naar in onderwerpen[2]. Niet-geïndexeerde parameters zoals waarde zijn ABI-gecodeerd in gegevens.

De overdrachtsgegevens uit dit logboek halen:

  • van: onderwerpen[1] (32 bytes, aangevuld met nullen) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • naar: onderwerpen[2] (32 bytes, aangevuld met nullen) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • waarde: gegevens gedecodeerd als uint256 → 0x4c4b40 = 5.000.000 (5 USDC, aangezien USDC 6 decimalen heeft).

De mentale overhead van het weten hoe je elk type logbestand moet parseren, lijkt een nachtmerrie. Daarom hebben we logprocessors ontwikkeld die specifieke gebeurtenistypen kunnen parseren en omzetten in domeintoestand:

De processor neemt de ruwe loggegevens op, parseert deze in getypte velden (in plaats van hexadecimale strings) en produceert domeinentiteiten.

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

Inclusie versus bevestiging

Wij behandelen twee verschillende levenscyclusfasen:

  1. Inclusie - De transactie verschijnt eerst in een blok. De status is voorlopig: het blok kan nog steeds worden gereorganiseerd.
  2. Bevestiging - Het blok bereikt voldoende diepte (voldoende volgende blokken om de mogelijkheid van een reorganisatie uit te sluiten). De status is definitief.

Dit onderscheid is belangrijk. We kunnen de gebruikersinterface bijwerken om een status 'in behandeling' weer te geven bij opname, maar we zouden pas na bevestiging downstream FoF-stromen activeren. De kosten van handelen op basis van een voorlopige status zijn onbeperkt.

Logprocessors verwerken individuele gebeurtenissen, maar vaak moeten we deze onderling coördineren of een status op transactieniveau toevoegen. Transactieprocessors bundelen dit: ze ontvangen de samengevoegde output van alle logprocessors en kunnen deze transformeren, aanvullen of aanvullende downstream-effecten activeren. Hier komt ook de tweefasige levenscyclus om de hoek kijken. transactie verwerken loopt bij opname - we produceren een voorlopige toestand. procesbevestiging wordt uitgevoerd zodra het blok definitief is - dit is doorgaans waar we de levenscyclus van financiële transacties voltooien.

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

Logs terugkoppelen naar gesprekken

Wanneer onze logverwerker een overdrachtsrecord produceert, moet deze terugkoppelen naar de oorspronkelijke BlockchainCall. Het logboek vertelt ons wat gebeurd – activa zijn van A naar B verplaatst. De BlockchainCall vertelt ons waarom—dit was een incasso, een betaling aan een leverancier of een terugbetaling. Voor eenvoudige transacties met één oproep is dit eenvoudig. Voor batchtransacties, waarbij we meerdere bewerkingen bundelen in één on-chain transactie om gas te besparen, wordt het moeilijker. Het ontvangstbewijs geeft ons een platte lijst van alle logs die tijdens de uitvoering zijn gegenereerd, zonder aan te geven welke oproep welke log heeft geproduceerd. We lossen dit op met call-frame tracing, dat we in het gedeelte voor gevorderden hieronder behandelen.

Geavanceerd: batchlogboeken toewijzen aan individuele oproepen

Dit gedeelte behandelt een specifieke technische uitdaging met batchtransacties. Als u niet met ERC-4337 of batchuitvoering werkt, kunt u doorgaan naar Global USD. Eerder hebben we vermeld dat het koppelen van logboeken aan hun oorspronkelijke BlockchainCall eenvoudig is voor eenvoudige transacties. Voor batchtransacties is dat niet het geval.

Het probleem

Wanneer we meerdere bewerkingen in één transactie bundelen, bijvoorbeeld een betaling van $ 500 plus $ 1 aan kosten, worden beide atomair uitgevoerd. Het transactiebewijs geeft ons een platte lijst van alle logboeken die tijdens de uitvoering zijn gegenereerd:

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

We krijgen een platte array van elk logboek dat tijdens de uitvoering wordt gegenereerd. Als we naar dit overzicht kijken, kunnen we twee Transfer-gebeurtenissen identificeren bij logboekindexen 1 en 2 (die beide de 0xddf252ad... overdracht van handtekening van gebeurtenis die we eerder hebben besproken).

Maar welke was de betaling en welke was de vergoeding? Dat staat niet op het ontvangstbewijs vermeld. Logboeken worden toegewezen aan de transactie op het hoogste niveau, niet aan individuele oproepen binnen een batch. Je zou kunnen denken: koppel de logboeken gewoon in volgorde aan de oproepen. Maar dat werkt alleen als elke oproep precies één logboek genereert. Een eenvoudige overdracht genereert er één, een ruil kan er vijf genereren. Zonder de grenzen te kennen, kun je ze niet betrouwbaar in kaart brengen.

Call Frame Tracing

De oplossing bleek te zijn debug_traceTransactie- een Geth-archiefknooppunt-RPC-methode die de meeste mensen gebruiken voor het debuggen van mislukte transacties. Maar het doet nog iets anders: het speelt de transactie opnieuw af en retourneert de volledige call frame tree, met logs die op de juiste diepte in de call-hiërarchie zijn bijgevoegd.

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

Het resultaat is een recursief geneste structuur van call frames (vereenvoudigd voor de leesbaarheid).

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

We maken deze recursieve structuur plat tot een schema dat de boomstructuur behoudt:

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

Neem een batch UserOp met twee USDC-overboekingen: een betaling van $ 500 en een vergoeding van $ 1,10, weergegeven in het bovenstaande uitvoeringstrace. Het trace geeft ons:

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

De volledige transactie kan nu worden weergegeven als een boomstructuur. Dit geeft een nieuwe invalshoek op het hele probleem: in plaats van de structuur af te leiden uit een platte logarray, reconstrueren we de uitvoeringsboomstructuur, waarin de hiërarchie van de oproepen expliciet is en de logs zijn gekoppeld aan de frames die ze hebben gegenereerd.

Vanaf daar is de toewijzing eenvoudig. Zoek het knooppunt dat overeenkomt met de executeBatch() roep aan, doorloop de onderliggende elementen op indices 0..N-1, en verzamel recursief logbestanden van elke subboom. Elke onderliggende index 0..N-1 kaarten direct naar de bijbehorende BlockchainCall indexInBatchWe weten nu precies welke oproep welke logbestanden heeft gegenereerd.

Aangezien vrijwel elke transactie deze attributie nodig heeft, hebben we deze rechtstreeks in onze logverwerker ingebouwd. Deze reconstrueert de volledige call tree, koppelt logs aan hun oorspronkelijke frames en lost alle BlockchainCalls in de batch op. Elke logverwerker ontvangt vervolgens de specifieke call- en frame-context voor de log die hij verwerkt:

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

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

De volledige attributieketen:

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

Wereldwijde USD: De Capstone

Off-ramps en on-ramps losten een probleem op voor onze bestaande klanten: bedrijven met Amerikaanse bankrekeningen die wilden schakelen tussen fiat en crypto. Maar we bleven horen van een ander segment: internationale bedrijven die toegang nodig hebben tot Amerikaanse dollarrails, maar die niet gemakkelijk kunnen krijgen.

Als u een softwareontwikkelaar in Argentinië, een e-commercehandelaar in Nigeria of een SaaS-bedrijf in Zuidoost-Azië bent, vereist het openen van een Amerikaanse bankrekening vaak de oprichting van een Amerikaanse entiteit – advocaten, geregistreerde agenten en maandenlange overheadkosten. Veel legitieme bedrijven worden in feite buitengesloten van de dollareconomie, niet vanwege iets wat ze hebben gedaan, maar vanwege de plaats waar ze zijn gevestigd.

Stablecoins brengen hier verandering in. Een USDC-saldo is een dollarsaldo. Global USD is onze poging om op basis van dat uitgangspunt een bankinfrastructuur op te bouwen.

Non-Custodial by Design

We hebben Global USD ontwikkeld als een niet-bewarend systeem. Deze beslissing werd ingegeven door twee factoren: de complexiteit van de regelgeving en vertrouwen.

Het beheren van klantgelden brengt licentievereisten met zich mee die per rechtsgebied verschillen. Een niet-bewarende architectuur vereenvoudigt onze licentiepositie in veel van deze markten. Wat het vertrouwen betreft, hebben klanten controle over hun eigen sleutels: Slash kan geen overboekingen initiëren zonder cryptografische autorisatie van de ondertekenaars van de rekening.

De kernprimitief is de slimme portemonnee: een slim contract dat fungeert als een portemonnee, maar met programmeerbare toegangscontrole.

Elke Global USD-rekening is een slimme portemonnee die wordt beheerd door een multi-sig. Elk geautoriseerd lid van het bedrijf heeft een sleutel. Overboekingen moeten door hen worden goedgekeurd voordat ze worden uitgevoerd. Slash kan voorbereiden een transactie, maar we kunnen niet uitvoeren zonder toestemming van de ondertekenaar.

Ondertekenen zonder voogdij

Dit roept een UX-vraag op: als gebruikers de sleutels beheren, moeten ze dan niet handmatig seed phrases beheren en transacties ondertekenen?

We gebruiken de ingebouwde wallet-infrastructuur van Privy en Alchemy. Wanneer een gebruiker een account aanmaakt, wordt er een privésleutel gegenereerd in een hardware-geïsoleerd geheugen (een 'trusted execution environment' of TEE). De sleutel bestaat, maar is zo ontworpen dat deze niet rechtstreeks toegankelijk is voor Slash of iemand anders. Wanneer een gebruiker een overboeking initieert, keurt hij deze goed via OTP, waardoor de TEE gemachtigd wordt om namens hem te ondertekenen. De ondertekende transactie wordt vervolgens naar het netwerk verzonden.

Vanuit het perspectief van de gebruiker voelt het alsof je een bankoverschrijving goedkeurt. Vanuit het perspectief van bewaring raken we nooit privésleutels aan.

Wat dit mogelijk maakt

Een bedrijf in Lagos kan nu dollars aanhouden, betalingen van Amerikaanse klanten ontvangen en internationale leveranciers betalen – allemaal zonder een Amerikaanse bankrekening, zonder bewaarrisico en met dezelfde audit trail en compliance-workflows die we voor elke Slash-klant zouden toepassen.

Dat is wat stablecoins eigenlijk kunnen zijn: niet alleen een betaalmethode, maar ook een fundamentele infrastructuur voor een toegankelijker financieel systeem.

Wat nu?

De basiselementen die we hebben ontwikkeld, zijn niet alleen bedoeld om geld tussen fiat en crypto te verplaatsen. Ze vormen de basis voor alles wat we bij Slash bouwen. We breiden ons wereldwijde accountaanbod uit, waardoor meer bedrijven toegang krijgen tot USD-rails, ongeacht waar ze zijn gevestigd. En we zijn bezig met de ontwikkeling van onze wereldwijde kaart: een kaart met hoge cashback, ondersteund door stablecoins, waarmee klanten hun saldo overal kunnen uitgeven. Beide zijn sterk afhankelijk van dezelfde orchestration- en execution-frameworks die we hier hebben beschreven. Als je tot hier bent gekomen en je bent een engineer die moeilijke infrastructuurproblemen wil oplossen voor echte klanten bij een snelgroeiend bedrijf, dan zijn we op zoek naar jou.

Read more from us