@usehaia/trace-core. The
runtime code uses only platform globals (Web Crypto, TextEncoder, atob), so
it also runs on edge runtimes.
Install
Receive a delivery
createWebhookHandler runs the whole pipeline and returns the HTTP status your
route should answer with:
notification_type, not
by the URL it arrived on. One handler is also one capture session: it owns the
seq counter, so everything it writes is one consistently ordered stream.
Point the sink at the directory haia-trace build reads —
.trace/events unless --dir says otherwise.
The HTTP decision
handle returns a discriminated HandleResult — the status, plus why:
A
400/500 result carries a reason string for your logs. Verification runs
before parsing: nothing about an unauthenticated body is trusted, not even that
it is JSON. A record_failed result also un-records the notification_id, so
Circle’s retry is processed as a fresh delivery instead of being acknowledged as
a duplicate that never reached the sink.
What it records
Every event carriesadapter: "trace-circle".
Direction is part of a transaction’s name because a direction-less “transaction
completed” cannot witness a milestone: money arriving from an escrow contract and
money sent toward one are different facts.
CONFIRMED and COMPLETE fold into
one event because chains with instant finality can skip CONFIRMED.
Anything else — an intermediate transaction state, a contract event outside the
three above, an unknown notification type — is acknowledged with 200 and records
nothing. Non-terminal states are delivery noise for a receipt; the terminal state
arrives on its own.
The contract events are those of Circle’s
arc-escrow sample and its
RefundProtocol contract, which is what the shipped escrow-arc template
reads. Pass your own
normalize for a different vocabulary.context_id is the escrow contract’s address, lowercased. A contract event
takes it from contractAddress. A wallet transaction prefers its
contractAddress too and otherwise uses the counterparty — the sourceAddress
of an inbound, the destinationAddress of an outbound. A transaction with
neither is recorded without a context_id rather than guessed into an
operation.
occurred_at is the fact’s own time, never the delivery time: a
transaction’s updateDate, a contract event’s firstConfirmDate, falling back
to the envelope timestamp and then to the recorder’s clock. Values are
re-canonicalized to ISO-8601 UTC, the form the assembler’s ordering relies on.
Payloads are allowlisted, never copied wholesale — a key Circle adds later is
dropped by default. Every event carries notification_id and
notification_type, plus:
- a transaction:
transaction_id,tx_hash,blockchain,state,wallet_id,source_address,destination_address,amounts,token_id,transaction_type,error_reason,contract_address; - a contract event:
contract_address,blockchain,tx_hash,user_op_hash,event_signature,block_height.
trace.attached
attestation, so “no events” can never be mistaken for “the receiver was never
wired up”. It attests the session, not an operation, so it carries no
context_id and the assembler reports it as unassigned.
Options
The pieces
The handler composes these; use them directly if you need a different pipeline.createVerifier({ resolveKey }) → verify(rawBody, signature, keyId).
Circle signs every v2 notification with ECDSA (P-256 / SHA-256) over the raw
request body and sends X-Circle-Signature (base64 ASN.1 DER) and
X-Circle-Key-Id; both header names are exported as SIGNATURE_HEADER and
KEY_ID_HEADER. A malformed signature, key id or body is an invalid signature
(false), not an error. Only a failing resolveKey throws — an unknown key id
should return null. Imported keys are cached per key id.
parseNotificationEnvelope(rawBody, source?) validates a raw body into a
NotificationEnvelope (subscription_id, notification_id,
notification_type, notification, timestamp, version), throwing a sourced
Error on malformed JSON or a bad envelope. Only version: 2 is accepted — a v1
message has a different envelope and signature scheme.
assertNotificationEnvelope does the same for an already-parsed value.
createMemoryDedupeStore({ capacity }) implements DedupeStore
(firstSeen, forget), keyed on notification_id, which Circle reuses across
retries. The event sink is append-only and the assembler does not dedupe, so
exactly-once has to be enforced before anything is written.
normalizeNotification(envelope) is the default mapping, exported so you can
wrap or replace it.
The package also exports ADAPTER_ID and its types: WebhookHandler,
WebhookHandlerOptions, HandleResult, HeaderBag, NormalizeNotification,
NotificationVerifier, VerifierOptions, PublicKeyResolver,
NotificationEnvelope, DedupeStore, MemoryDedupeOptions.
From deliveries to receipts
The events are the input tohaia-trace build, which groups them
by context_id and assembles one Operation
Receipt per escrow contract:
escrow-arc reads two witnesses. Circle’s events close the money half — the
PaymentCreated deposit, the withdrawal or refund disposition — and a failed
wallet transaction surfaces as a fault. The obligation half (agreement, delivery,
criteria) has no webhook witness, and agreement is one of the template’s
required stages, so a webhook-only run assembles as partial until your own
service records its escrow.* events into the same run directory.