Transaction Event Journal
The transaction-event/v1 contract gives Base components a common way to record business-level transaction events. Each producer appends newline-delimited JSON to a dedicated journal file rather than mixing these records into ordinary application output. The usual stdout/stderr path (and its Kubernetes-to-Datadog pipeline) is reserved for logs and must not double as this journal.
A Vector agent follows those JSONL files and forwards each record to audit-archiver. Because the audit ingest endpoint is collector-facing, it reads exactly one event object per line and does not accept a wrapped JSON batch.
Postgres Retention
Section titled “Postgres Retention”audit-archiver keeps the events it receives in Postgres so operators can query them. That copy is working storage, not the durable archive. A background worker prunes rows on a schedule, and the window it applies depends on what kind of event the row holds:
| Default window | Event types |
|---|---|
| 3 days | Proxy and builder-decision events, which dominate the journal by volume |
| 7 days | Ingress and forwarding events |
| 30 days | Failures, drops, inclusion, and flashblock events |
Two placements are worth spelling out. TXPOOL_SEND_RAW_TRANSACTION_VALIDITY ages out on the same schedule as TXPOOL_SEND_RAW_TRANSACTION, so both halves of the admission path leave Postgres together rather than one outliving the other. BUILDER_DEFERRED and BUILDER_EXPIRED sit with the rest of the per-attempt builder decisions on the shortest window. That grouping matters more than it looks: a parked validity transaction can draw one deferral per flashblock, so those rows pile up far faster than the transaction count would suggest.
Deleting at that rate leaves the tables carrying dead tuples, so autovacuum follows the worker and reclaims the space.
Configuration Fields
Section titled “Configuration Fields”Rust producers are expected to surface the following settings, either at the top level or behind a producer-specific prefix:
| Field | Type | Meaning |
|---|---|---|
enabled | boolean | Enables transaction event journal writes. |
file_path | string | Dedicated JSONL file path tailed by Vector. |
queue_capacity | integer | Upper bound on the in-process event queue. Under backpressure a producer discards events rather than stalling its transaction-serving path. |
max_file_bytes | integer | Size ceiling for the live JSONL segment. Once it is crossed, the writer rolls over: the current file is renamed and a fresh segment takes its place. |
max_files | integer | How many JSONL segments are kept on disk at any one time, counting the segment currently being written. |
required | boolean | When true, startup fails if the writer cannot open the file. A failure to write at runtime stays non-fatal and is still surfaced for observability. |
producer | string | One of the producer identities below. |
network | string | Network label, for example base-mainnet or base-sepolia. |
Go and proxyd builds carry the identical field names in TOML:
[transaction_events]enabled = truefile_path = "/var/log/base/transaction-events.jsonl"queue_capacity = 16384max_file_bytes = 134217728max_files = 8required = falseproducer = "base-routing/proxyd"network = "base-mainnet"Envelope
Section titled “Envelope”Every line is a single JSON object:
{ "schema_version": "transaction-event/v1", "event_id": "0x7d5c4f...", "event_time": "2026-06-02T00:00:00.000000000Z", "producer": "base-reth-node", "event_type": "TXPOOL_PENDING", "network": "base-mainnet", "tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111", "block_hash": null, "block_number": null, "payload_id": null, "request_id": null, "data": { "pool": "pending" }}These fields are mandatory:
schema_versionevent_idevent_timeproducerevent_type
In normal operation at least one join key should be set — either tx_hash, the block_hash/block_number pair, or payload_id. The request_id field is not required but helps correlate records across proxies and ingress. Producers should avoid emitting journal records for aggregate operational states that cannot be attributed to one of those join keys: broadcast lag, for instance, stays in logs and metrics because the receiver only sees a skipped count, whereas INGRESS_METERING_SEND_DROPPED fires once per dropped transaction precisely because ingress still holds the original tx_hash.
Anything producer-specific goes inside data. Never journal raw transaction bytes, calldata, full request bodies, API keys, secrets, private keys, bearer tokens, authorization headers, raw forwarding headers, or raw client IP forwarding chains.
A collector sidecar may attach deployment-specific origin metadata under data.observability_source before handing events off to audit-archiver. The archiver persists that object alongside the rest of data, but the shared contract places no schema constraints on it.
On the Rust side, the TransactionEvent::validate helper rejects a mismatched schema version, an empty event_id, and a short, exact denylist of unsafe data keys — among them raw_tx, calldata, request_body, authorization, api_key, headers, and x-forwarded-for. Vector collector pipelines are expected to catch wider case and delimiter variants — such as rawTransaction, requestBody, secret_key, and privateKey — before anything is ingested.
Local Devnet Verification
Section titled “Local Devnet Verification”Journaling is split across two devnet layers. The core devnet — just devnet up or just devnet up-single — switches on durable node journals for base-client and base-builder, which write their JSONL under .devnet/transaction-events/. The ingress overlay then layers the collection side on top: a Vector shipper, a local Postgres, the audit-archiver ingest path, and the ingress/proxyd producers. That overlay never touches the node journal configuration — it only consumes what the core layer already emits.
just devnet ingressjust devnet tx-observability-smokeTo exercise proxyd transaction events before that code ships in the default proxyd image, point BASE_ROUTING_CONTEXT at a local protocols/base-routing checkout:
BASE_ROUTING_CONTEXT=/path/to/base-routing just devnet ingressjust devnet tx-observability-smokeThe smoke test pushes a single transaction through ingress, waits for Vector to deliver the JSONL records from the ingress, proxyd, txpool-tracing, and builder producers, and then confirms audit-archiver can read the stored events back out of Postgres by transaction hash.
To keep an eye on the local shipper, alert on or inspect the component_discarded_events_total metric. Two transforms account for what it counts: parse_transaction_events sheds malformed JSONL lines, while validate_transaction_events discards already-parsed records whose data keys are flagged unsafe.
Producer Values
Section titled “Producer Values”base-reth-nodebase-builderingress-rpcbase-routing/proxyd
Txpool Tracing Example
Section titled “Txpool Tracing Example”When --enable-transaction-event-journal and --transaction-event-journal-path are supplied, base-reth-node txpool tracing can route its existing live LRU events into the durable journal:
{"schema_version":"transaction-event/v1","event_id":"0x4d6d...","event_time":"2026-06-02T00:00:00Z","producer":"base-reth-node","event_type":"TXPOOL_PENDING","network":"base-mainnet","tx_hash":"0x1111111111111111111111111111111111111111111111111111111111111111","block_hash":null,"block_number":null,"payload_id":null,"request_id":null,"data":{"event_source":"txpool-tracing","txpool_event":"pending","event_index":0,"node_role":"mempool","pool":"pending"}}Event Vocabulary
Section titled “Event Vocabulary”Edge/proxy:
PROXY_RECEIVEDPROXY_REJECTEDPROXY_VALIDATION_ACCEPTEDPROXY_VALIDATION_REJECTEDPROXY_ROUTED_TO_BACKENDPROXY_BACKEND_SUCCESSPROXY_BACKEND_FAILUREPROXY_INGRESS_RPC_ATTEMPTPROXY_INGRESS_RPC_SUCCESSPROXY_INGRESS_RPC_FAILURE
Ingress/audit:
INGRESS_RECEIVEDSIMULATION_STARTEDSIMULATION_SUCCEEDEDSIMULATION_FAILEDINGRESS_METERING_SEND_ATTEMPTINGRESS_METERING_SEND_SUCCESSINGRESS_METERING_SEND_FAILUREINGRESS_METERING_SEND_DROPPED
Mempool/node:
TXPOOL_PENDINGTXPOOL_QUEUEDTXPOOL_PENDING_TO_QUEUEDTXPOOL_QUEUED_TO_PENDINGTXPOOL_DROPPEDTXPOOL_REPLACEDTXPOOL_TRACKING_OVERFLOWEDTXPOOL_SEND_RAW_TRANSACTIONTXPOOL_SEND_RAW_TRANSACTION_VALIDITY
Admission caveat: TXPOOL_SEND_RAW_TRANSACTION and TXPOOL_SEND_RAW_TRANSACTION_VALIDITY mark entry at the RPC boundary. Each distinct submit path emits its own event exactly once, written after the transaction is decoded but ahead of pool insertion or sequencer forwarding. That places both earlier in the lifecycle than TXPOOL_PENDING and TXPOOL_QUEUED, which instead report which subpool the transaction landed in. Only TXPOOL_SEND_RAW_TRANSACTION_VALIDITY carries data.validity_predicates — the serialized list of balance, storage, block_number, and flashblock_index entries. Producers further down the chain are required not to echo it, and that rule binds BUILDER_DEFERRED, BUILDER_EXPIRED, BUILDER_ACCEPTED, and BUILDER_INCLUDED alike, so recover the predicates by joining on tx_hash. Treat a replacement as an admission in its own right: it arrives with a separate tx_hash, a separate predicate list, or both, and TXPOOL_REPLACED.replacement_hash is what ties the departing transaction to the one taking its place. Submissions made through base_insertValidatedTransaction are journaled as TXPOOL_VALIDATED_INSERT_ACCEPTED / TXPOOL_VALIDATED_INSERT_REJECTED instead.
Forwarding:
TXPOOL_BUILDER_FORWARD_ATTEMPTTXPOOL_BUILDER_FORWARD_SUCCESSTXPOOL_BUILDER_FORWARD_FAILURETXPOOL_BUILDER_FORWARD_DROPPEDTXPOOL_VALIDATED_INSERT_ACCEPTEDTXPOOL_VALIDATED_INSERT_REJECTED
TXPOOL_BUILDER_FORWARD_DROPPED is reserved for transaction-scoped drops where the forwarding task still has the tx_hash in hand, such as a final RPC failure after the retries are exhausted. Broadcast lag is deliberately kept out of the journal and stays visible only through logs and metrics.
Builder:
BUILDER_CONSIDEREDBUILDER_ACCEPTEDBUILDER_REJECTEDBUILDER_DEFERREDBUILDER_EXPIREDBUILDER_INCLUDEDBUILDER_PAYLOAD_FINALIZEDBUILDER_FLASHBLOCK_STARTEDBUILDER_FLASHBLOCK_PUBLISHEDBUILDER_FLASHBLOCK_BUILD_STOPPED
Builder caveat: BUILDER_CONSIDERED, BUILDER_ACCEPTED, BUILDER_REJECTED, BUILDER_DEFERRED, and BUILDER_EXPIRED fire once per payload-building attempt and, where relevant, carry payload_id, block_number, and flashblock_index. As a result, one transaction can generate several decision events across consecutive flashblocks. A BUILDER_DEFERRED marks each move out of the selection queue and into the parking lot, and it repeats when a transaction is promoted and then parked again inside the same flashblock. Re-indexing a transaction that is already parked, because whatever blocks it changed, writes no second BUILDER_DEFERRED. Where a deferral says “not yet,” BUILDER_EXPIRED says “never” — it is the terminal discard the builder writes once a window has closed for good, as when a bundle’s validity window or a position predicate has run out and no later attempt can satisfy it. Neither BUILDER_ACCEPTED nor BUILDER_INCLUDED changed shape, so pairing a deferral with the accept or inclusion that follows means joining on tx_hash inside the same payload_id and flashblock window. Running out of parking capacity is not a deferral at all — that path still reports BUILDER_REJECTED carrying validity_predicate_not_satisfied. BUILDER_INCLUDED is recorded once the builder finalizes the payload it can serve through engine_getPayload, and it sets data.inclusion_signal = "builder_finalized_payload". Nonce and validation skips are recorded by the payload loop as BUILDER_REJECTED rather than fabricating replacement relationships. BUILDER_PAYLOAD_FINALIZED is written once for every built payload — even an empty one with no user transactions — and ties the payload_id to the builder’s block hash and number; it includes data.parent_hash, data.transaction_count, data.gas_used, data.gas_limit, and data.timestamp. BUILDER_FLASHBLOCK_STARTED, BUILDER_FLASHBLOCK_PUBLISHED, and BUILDER_FLASHBLOCK_BUILD_STOPPED are scoped to a payload/flashblock and carry top-level payload_id and block_number plus data.parent_hash, data.flashblock_index, and data.target_flashblock_count. Published events additionally set top-level block_hash and data.transaction_count, data.byte_size, and data.build_duration_ms. Build-stopped events use data.reason to label control-flow stops, for example payload resolution winning the race before publish.
Canonicality caveat: builder events describe local payload construction, not canonical-chain or consensus-finality state. A builder event that carries block_hash means the builder computed or published that payload shape — it is not, on its own, evidence that the block became canonical. Tie records back to canonical history through canonical-state observers such as txpool tracing by matching block_hash, block_number, and transaction hashes.
Event ID Guidance
Section titled “Event ID Guidance”Prefer deterministic event_id values whenever the source has stable inputs. Reasonable components to combine include:
producerevent_type- source timestamp bucket or source sequence
tx_hashrequest_id- backend/node identifier when applicable
- attempt index when applicable
When a source genuinely cannot produce a deterministic ID, record the reason in the producer implementation and pack enough fields into data for audit-archiver to enforce uniqueness on the database side.
proxyd Examples
Section titled “proxyd Examples”Received raw transaction request:
{ "schema_version": "transaction-event/v1", "event_id": "0x1f3f...", "event_time": "2026-06-02T00:00:00.000000000Z", "producer": "base-routing/proxyd", "event_type": "PROXY_RECEIVED", "network": "base-mainnet", "tx_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", "block_hash": null, "block_number": null, "payload_id": null, "request_id": "req-abc", "data": { "rpc_method": "eth_sendRawTransaction" }}Validation rejection:
{ "schema_version": "transaction-event/v1", "event_id": "0x2a4b...", "event_time": "2026-06-02T00:00:00.000000000Z", "producer": "base-routing/proxyd", "event_type": "PROXY_VALIDATION_REJECTED", "network": "base-mainnet", "tx_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", "block_hash": null, "block_number": null, "payload_id": null, "request_id": "req-abc", "data": { "rpc_method": "eth_sendRawTransaction", "validation_service": "tx-validation", "fail_open": false }}Routed to node:
{ "schema_version": "transaction-event/v1", "event_id": "0x3b5c...", "event_time": "2026-06-02T00:00:00.000000000Z", "producer": "base-routing/proxyd", "event_type": "PROXY_ROUTED_TO_BACKEND", "network": "base-mainnet", "tx_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", "block_hash": null, "block_number": null, "payload_id": null, "request_id": "req-abc", "data": { "backend": "reth-mainnet-0", "attempt_index": 0 }}Mempool and Builder Examples
Section titled “Mempool and Builder Examples”These three events trace one validity transaction from admission through to a builder-side discard.
The admission event is the one place the predicate list appears in full. Every park, expiry, accept, and inclusion that follows must be tied back to it through tx_hash:
{ "schema_version": "transaction-event/v1", "event_id": "0x4c6d...", "event_time": "2026-06-02T00:00:00.000000000Z", "producer": "base-reth-node", "event_type": "TXPOOL_SEND_RAW_TRANSACTION_VALIDITY", "network": "base-mainnet", "tx_hash": "0x3333333333333333333333333333333333333333333333333333333333333333", "block_hash": null, "block_number": null, "payload_id": null, "request_id": null, "data": { "rpc_method": "base_sendRawTransactionValidity", "validity_predicates": [ { "type": "storage", "params": { "address": "0xabababababababababababababababababababab", "slot": "0x1", "mask": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "op": "=", "value": "0x1" } } ] }}A recoverable predicate parks the transaction rather than dropping it, holding it for a later ordering position or a later flashblock. Note what is absent — the predicate list is not restated here:
{ "schema_version": "transaction-event/v1", "event_id": "0x5d7e...", "event_time": "2026-06-02T00:00:00.200000000Z", "producer": "base-builder", "event_type": "BUILDER_DEFERRED", "network": "base-mainnet", "tx_hash": "0x3333333333333333333333333333333333333333333333333333333333333333", "block_hash": null, "block_number": 123, "payload_id": "0x0102030405060708", "request_id": null, "data": { "builder_mode": "flashblocks", "flashblock_index": 2, "ordering_position": 4, "defer_reason": "validity_predicate_not_satisfied", "defer_detail": "a validity predicate is not satisfied by the current build state" }}Expiry is the builder’s terminal discard, written once no later attempt can satisfy the predicate. Exhausting parking capacity is a different outcome and still surfaces as BUILDER_REJECTED with validity_predicate_not_satisfied:
{ "schema_version": "transaction-event/v1", "event_id": "0x6e8f...", "event_time": "2026-06-02T00:00:00.400000000Z", "producer": "base-builder", "event_type": "BUILDER_EXPIRED", "network": "base-mainnet", "tx_hash": "0x4444444444444444444444444444444444444444444444444444444444444444", "block_hash": null, "block_number": 123, "payload_id": "0x0102030405060708", "request_id": null, "data": { "builder_mode": "flashblocks", "flashblock_index": 0, "ordering_position": 1, "expire_reason": "validity_predicate_expired", "expire_detail": "a validity predicate can no longer be satisfied at or after the current build position" }}TIPS S3/Postgres Parity Checklist
Section titled “TIPS S3/Postgres Parity Checklist”The journal feeds the audit-archiver/Postgres query path that backs the TIPS UI. Before an environment flips TIPS_UI_QUERY_BACKEND=audit, the Postgres route should return the same answers the existing S3-backed route does. Keep S3 serving traffic until the samples below match against audit-archiver, then cut over.
Sample inputs. Pick a representative set and reuse it across both backends:
- A recent block hash that includes at least one non-system transaction, plus that same block addressed by number.
- A transaction hash from that block carrying a
SIMULATION_SUCCEEDEDevent. - A bundle hash or bundle id drawn from that transaction’s event data.
- A rejected transaction from the trailing 31 days, if one exists.
Route comparisons. Run each sample through both backends and check that they line up:
- On
GET /api/block/<block-hash>, the two paths should agree on block hash, number, gas limits, the ordering of transactions, and the transaction hashes themselves. - Addressing the block by number via
GET /api/block/<block-number>should land on that same canonical block. - Per sampled non-system transaction, both backends should concur on whether simulation data is present; where it is, the bundle hash/id, state block number, total gas used, total execution time, and the per-transaction gas/time summaries should all match.
GET /api/txn/<tx-hash>should hand back a matching transaction hash along with at least one bundle id/hash join key.- Events from
GET /api/bundle/<bundle-id-or-hash>should arrive in event-time order, with the accepted simulation event for the sample among them. - Where a rejected sample exists,
GET /api/rejectedshould echo its transaction hash, block number, rejection reason, timestamp, and metering summary.
Operational checks:
- On
audit-archiver,TIPS_AUDIT_POSTGRES_URLis configured, and the audit service port answers both JSON-RPC and transaction-event HTTP ingest — that same port is where Vector delivers NDJSON, at/v1/transaction-events/batch. ingress-rpccarriesTIPS_INGRESS_TRANSACTION_EVENTS_ENABLED=truein the test environment alone, its JSONL path aligned with the Vector sidecar mount.- On the UI side,
TIPS_UI_QUERY_BACKEND=auditis set withTIPS_UI_AUDIT_RPC_URLaimed at the audit service. - Writes to S3 keep flowing across the whole comparison window.
- Nothing in the rollout drops a Kafka, S3, or existing RPC persistence path.