Skip to content

feat(api)!: redesign node log retrieval API around tag-based queries#23625

Merged
spalladino merged 21 commits into
merge-train/spartanfrom
spl/new-get-log-apis
May 29, 2026
Merged

feat(api)!: redesign node log retrieval API around tag-based queries#23625
spalladino merged 21 commits into
merge-train/spartanfrom
spl/new-get-log-apis

Conversation

@spalladino

@spalladino spalladino commented May 28, 2026

Copy link
Copy Markdown
Contributor

Motivation

The node exposed four log-retrieval methods with three filter shapes and two return shapes, while the private index was only tag-keyed — so a (tag, narrow block range) query loaded the entire per-tag history into memory. Pagination was a single global page counter shared across all tags, and the public path was split between a LogFilter method and a tag-based method. v5 is already a breaking release, so this collapses everything to a single, fast, tag-based surface with no back-compat.

Fixes A-1111
Fixes A-1031

Approach

Two methods, getPrivateLogsByTags(query) and getPublicLogsByTags(query), replace the four old ones; getContractClassLogs and getPublicLogs(LogFilter) are gone. The archiver stores each log under a fixed-width composite hex-string key [contractHex] - tagHex - blockHex8 - txIndexHex8 - logIndexHex8 in an LMDB map, so every supported filter (tag, block range, txHash, per-tag afterLog cursor, referenceBlock reorg cap) reduces to a single ordered range scan. Note hashes and nullifiers are never copied into the log index — they're fetched on demand from the block store via a partial deserializer that reads only the relevant prefix of the stored IndexedTxEffect. ARCHIVER_DB_VERSION bumps 6 → 7, so the archiver self-wipes and re-syncs from L1 on first start.

API

Two node methods replace the previous four. Each returns one inner array per element of query.tags, in input order; an empty inner array means that tag matched nothing.

getPrivateLogsByTags(query: PrivateLogsQuery): Promise<LogResult[][]>;
getPublicLogsByTags(query: PublicLogsQuery): Promise<LogResult[][]>;

Input

// Filters shared by both queries.
type LogsQueryBase = {
  fromBlock?: BlockNumber;        // inclusive lower bound
  toBlock?: BlockNumber;          // exclusive upper bound
  txHash?: TxHash;                // restrict to one tx; mutually exclusive with fromBlock/toBlock
  referenceBlock?: BlockHash;     // reorg anchor: throws if that block is no longer present
  includeEffects?: boolean;       // also attach each log's tx noteHashes + nullifiers
  limitPerTag?: number;           // page size, 1..MAX_LOGS_PER_TAG (default & max = 20)
};

// A tag to query, optionally resuming strictly after a previously-seen log.
// The bare `T` form starts from the beginning.
type TagQuery<T> = T | { tag: T; afterLog?: LogCursor };

type PrivateLogsQuery = LogsQueryBase & {
  tags: TagQuery<SiloedTag>[];    // 1..MAX_RPC_LEN (100) entries
};

type PublicLogsQuery = LogsQueryBase & {
  contractAddress: AztecAddress;  // required for public queries
  tags: TagQuery<Tag>[];          // 1..MAX_RPC_LEN (100) entries
};

Output

type LogResult<Opts = { includeEffects?: boolean }> = {
  logData: Fr[];                  // log fields; the tag is logData[0]
  blockNumber: BlockNumber;
  blockHash: BlockHash;
  blockTimestamp: UInt64;
  txHash: TxHash;
  txIndexWithinBlock: number;     // 0-based index of the tx within its block
  logIndexWithinTx: number;       // 0-based index of the log within its tx
} & (Opts extends { includeEffects: true }
  ? { noteHashes: Fr[]; nullifiers: Fr[] } // present only when includeEffects: true
  : {});

// Opaque per-tag pagination cursor.
// String form: `<blockNumber>-<txIndexWithinBlock>-<logIndexWithinTx>`.
class LogCursor {
  blockNumber: BlockNumber;
  txIndexWithinBlock: number;
  logIndexWithinTx: number;
  static fromLog(log: LogResult): LogCursor;
}

Pagination is per-tag: feed a tag's last LogResult back as the next query's afterLog ({ tag, afterLog: LogCursor.fromLog(last) }). A tag is exhausted once it returns fewer than limitPerTag results. The stdlib helpers queryAllPrivateLogsByTags / queryAllPublicLogsByTags drive this loop and return the fully-drained results.

Changes

  • stdlib: new LogResult, LogCursor, PrivateLogsQuery / PublicLogsQuery types with zod schemas; txHashfromBlock/toBlock enforced via .refine (but txHash + afterLog is allowed, to paginate within a tx's logs). L2LogsSource / AztecNode / Archiver interfaces and schemas reduced to the two new methods. Deleted LogFilter, LogId, TxScopedL2Log, ExtendedPublicLog, ExtendedContractClassLog, GetPublicLogsResponse, GetContractClassLogsResponse, and the dead Tx.getPublicLogs(logsSource).
  • archiver: full LogStore rewrite — two hex-string-keyed AztecAsyncMap primary maps (keys are fixed-width zero-padded lowercase hex, so ordered-binary's string ordering matches the canonical (contract, tag, block, txIndex, logIndex) tuple and every filter is a single ordered range scan) plus two blockNumber → string[] secondary indices driving deleteLogs (replaces the buggy per-block tag-union list). All reads, including the referenceBlock existence check, run inside one db.transactionAsync across BlockStore + LogStore; a referenceBlock equal to the (synthetic, unindexed) genesis block hash resolves to the genesis block number rather than throwing. New BlockStore.getNoteHashesAndNullifiers(txHashes) is a batched partial deserializer for includeEffects, and getTxLocation reads only the 40-byte header instead of the full TxEffect. Contract-class-log storage removed entirely. ARCHIVER_DB_VERSION 6 → 7. OutOfOrderLogInsertionError and the ARCHIVER_MAX_LOGS env var dropped.
  • aztec-node: four RPC handlers collapsed to two thin forwarders; referenceBlock resolution moved into the store so it shares the transaction.
  • pxe: getAllPages rewritten from a global page counter to per-tag afterLog cursors — each round re-queries only tags that returned a full page, and tags drop out as soon as they return a short page. fromBlock / toBlock are pushed down into the node, eliminating the in-memory #extractLogs range filter.
  • aztec.js: getPublicEvents migrated to the new query shape; PublicEventFilter.contractAddress is now required; EventFilterBase.afterLog: LogId → LogCursor.
  • cli: get-logs requires --contract-address and --tag; --after-log parses a LogCursor string <blockNumber>-<txIndexWithinBlock>-<logIndexWithinTx>.
  • end-to-end: e2e_ordering rewritten to read getBlock().body.txEffects[*].publicLogs directly (the new API drops the tag-less, contract-less query shape).
  • docs: migration notes for client consumers; operator changelog covering the DB version bump and one-time resync.

@spalladino spalladino added the ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure label May 28, 2026
@spalladino spalladino changed the title feat: redesign node log retrieval API around tag-based queries feat!(api): redesign node log retrieval API around tag-based queries May 28, 2026
@spalladino spalladino force-pushed the spl/new-get-log-apis branch from bc31270 to b0b1896 Compare May 28, 2026 15:52
@spalladino spalladino changed the title feat!(api): redesign node log retrieval API around tag-based queries feat(api)!: redesign node log retrieval API around tag-based queries May 28, 2026
@spalladino spalladino requested a review from a team as a code owner May 28, 2026 20:55

@PhilWindle PhilWindle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node changes look fine

spalladino added 20 commits May 29, 2026 12:48
…retrieval interfaces

Phase 1 of the log retrieval API redesign. Adds LogResult, LogCursor, and
PrivateLogsQuery/PublicLogsQuery (with TagQuery and zod refines) under stdlib/src/logs.
Flips L2LogsSource, AztecNode, and Archiver interfaces (+ zod schema maps) to expose
only getPrivateLogsByTags / getPublicLogsByTags, dropping getContractClassLogs and
getPublicLogs(LogFilter). Deletes the dead Tx.getPublicLogs(logsSource) method.

Old types (LogFilter, LogId, TxScopedL2Log, Extended*Log, Get*LogsResponse) are kept
for now and deleted in a later cleanup phase once Stages 2/3 stop referencing them.
The tree will not compile green until Stages 1+2+3 land together (expected for a
coordinated breaking change).
…ased API

Phase 2 of the log retrieval API redesign. Migrates every client-side consumer of the
log retrieval methods to the new LogResult / LogCursor / PrivateLogsQuery / PublicLogsQuery
shape introduced in Phase 1.

- pxe/tagging/get_all_logs_by_tags: rewrite getAllPages from a global page counter to
  per-tag afterLog cursors; tags drop out as soon as they return a short page.
- pxe/logs/log_service: push fromBlock/toBlock into the query (deletes the in-memory
  #extractLogs filter and TODO(F-650)); set includeEffects: true; consume nullifiers[0]
  with the defensive non-zero check.
- pxe sender/recipient sync: consume LogResult; sender sync leaves includeEffects off.
- aztec.js getPublicEvents: switch to getPublicLogsByTags, decode from logData.slice(1),
  source contractAddress from the (now required) filter.
- aztec.js wallet: EventFilterBase.afterLog: LogId -> LogCursor; PublicEventFilter
  contractAddress required; shared refineEventFilter enforces txHash xor range.
- aztec.js api/log: drop LogFilter/LogId re-exports, add LogCursor/LogResult.
- cli get-logs: require --contract-address and --tag; new --after-log parses LogCursor;
  paginate per-tag until a short page; print via LogResult.toHumanReadable().
- e2e_ordering: read block.body.txEffects[*].publicLogs directly via getBlock since the
  new API drops the tag-less, contract-less query path.
… with binary-key map

Phase 3 of the log retrieval API redesign. Lands the archiver storage and node
implementation behind the new API surface introduced in Phase 1 and consumed in Phase 2.

kv-store
- New AztecAsyncBinaryMap interface + LMDB v2 implementation: raw Uint8Array keys and
  values that bypass ordered-binary, with a length-prefixed namespace so adjacent map
  names cannot alias. Native lmdblib (C++) is untouched - keys are already raw bytes
  with default memcmp ordering. Other backends throw on openBinaryMap.

archiver
- log_store.ts: full rewrite. Two binary-key primary maps keyed by
  [contract] ++ tag ++ blockNumberBE ++ txIndexInBlockBE ++ logIndexWithinTxBE, plus
  two blockNumber -> Buffer[] secondary deletion indices. Composite-key codec and
  inc()/endOfTagRange/endOfTxRange helpers isolated as private static methods.
  getPrivateLogsByTags / getPublicLogsByTags implement the read pseudocode end to end
  inside one db.transactionAsync: txHash xor range validation, referenceBlock reorg
  check, cursor exclusive-start via byte-buffer increment, tx-strict end via
  inc(key(prefix, txBlk, txIdx, MAX_U32)), and a batched includeEffects fetch deduped
  by txHash. Contract-class-log storage is removed entirely.
- block_store.ts: new getNoteHashesAndNullifiers(txHashes) - a batched partial
  deserializer that skips the IndexedTxEffect header + revertCode/txHash/transactionFee
  and reads only the noteHashes and full nullifiers vectors, stopping before the large
  tail.
- data_stores.ts: ARCHIVER_DB_VERSION 6 -> 7 (store self-wipes on schema mismatch).
- data_source_base.ts: 4 forwarders -> 2; getContractClassLogs dropped.

aztec-node
- server.ts: 4 RPC handlers replaced with 2 thin forwarders; referenceBlock resolution
  moved into the store (in-transaction).

stdlib
- tests/factories.ts: added randomPrivateLogResult / randomPublicLogResult factories.
- interfaces/{archiver,aztec-node}.test.ts: mocks migrated to the new methods.

end-to-end and pxe test files: migrated the few consumers that still referenced the
old method names. Required contractAddress added to PublicEventFilter in e2e events.

11/20 of the new log_store.test.ts cases pass; 9 fail on test-setup helper ergonomics
(multi-block sequencing) and will be fixed in the final verification step.
Phase 4 of the log retrieval API redesign.

- migration_notes.md: document the 4 -> 2 method collapse on AztecNode, removal of
  getContractClassLogs and getPublicLogs(LogFilter), new PrivateLogsQuery /
  PublicLogsQuery / LogResult / LogCursor shapes, required contractAddress on
  PublicEventFilter, afterLog: LogId -> LogCursor, dropped re-exports, CLI get-logs
  flag changes, and the txHash xor block-range rule. Links LOG_API_REDESIGN.md as the
  canonical reference.
- operator changelog v5: ARCHIVER_DB_VERSION 6 -> 7, archiver self-wipes and re-syncs
  from L1 on first start; no config change required.
Final-verification cleanups after Stages 1-4:

- archiver/log_store, archiver/block_store: drop async on methods that only return a
  promise from a function call (require-await lint).
- aztec.js/wallet: extract EventFilterMutexShape so the refine generic constraint fits
  on one line (formatter was re-introducing malformed inline JSDoc comments otherwise).
- kv-store/binary_map.test: switch from @jest/globals to vitest (the package uses
  vitest); drop async on the carry-boundary test.
- pxe/logs/log_service.test: pass includeEffects: true to randomPrivateLogResult so
  the mocks match log_service's actual query; rewrite the block-range tests to mock
  the node returning only in-range logs (range filtering moved into the node) and
  assert the range was forwarded.
- archiver/store/log_store.test: rewrite multi-block test setup to seed a sequential
  previousArchive chain via blockStore.addCheckpoints (BlockStore enforces a
  contiguous chain starting from block 1); switch the synchronous-validation tests
  to expect-throws-sync; tighten the cross-tx no-overlap assertion in the
  page-boundary test.

Final state: yarn build green, yarn lint clean, all modified unit tests pass.
Two real bugs and one perf miss caught by post-implementation review:

archiver/log_store
- #validateQuery: when txHash is set alongside any tag's afterLog, the cursor's
  txHash must equal query.txHash. Previously, a mismatched cursor placed `start`
  before the tx range while `end` stayed tx-strict, causing the scan to leak logs
  from intervening txs sharing the same tag. Cursors always come from a prior
  in-tx result, so well-behaved callers are unaffected; this check rejects the
  edge case loudly.
- #resolveCursor: throw when the cursor's tx is unknown instead of silently
  falling back to (cursor.blockNumber, 0), which would have re-yielded earlier
  logs in that block. A missing cursor tx means the chain reorged under the
  client and they should re-sync.

archiver/block_store
- getTxLocation: read only the IndexedTxEffect header (blockHash + blockNumber +
  txIndexInBlock = 40 bytes) instead of fully deserializing the entire TxEffect.
  Matches the plan's O(1) intent and meaningfully shrinks the hot path under
  txHash and afterLog queries.

cli/aztec_node
- get-logs action: the command option is --node-url (-> nodeUrl in commander),
  not aztecNodeRpcUrl. The destructure was receiving undefined and passing it to
  createAztecNodeClient.
Removes types and tests that became unreferenced after the tag-based log
retrieval API landed: LogFilter, LogId, TxScopedL2Log, ExtendedPublicLog,
ExtendedContractClassLog, GetPublicLogsResponse, GetContractClassLogsResponse,
their barrel re-exports, and the matching TxScopedL2Log random factories.

Also drops the now-unused logsMaxPageSize / maxLogs plumbing through the
archiver store, factory, config, env var, and snapshot-sync types, and the
OutOfOrderLogInsertionError that no code throws. Updates two PXE comments
that still referenced TxScopedL2Log to point at LogResult.
…ents

- delete the operator v5.x changelog entry and its index link
- remove the LOG_API_REDESIGN.md link from migration notes
- drop section-separator banner comments in archiver log_store
- drop the "removed in v7" notes from archiver-sync.test.ts
…rsor and results

LogResult now carries txIndexWithinBlock alongside txHash, matching the
archiver's composite-key ordering. LogCursor drops txHash in favor of
txIndexWithinBlock so resuming pagination no longer needs a tx-hash lookup;
the cursor's (blockNumber, txIndexWithinBlock, logIndexWithinTx) slot
directly into the composite-key start computation in LogStore.

Other adjustments along the way:
- move parseOptionalLogCursor into stdlib as LogCursor.parseOptional; the
  CLI wraps it to surface InvalidArgumentError
- add LogsQueryBase.limitPerTag (capped at MAX_LOGS_PER_TAG) so tests can
  force pagination without hitting the prod cap
- add LogStore.getAllPrivate/PublicLogsForBlock for tests that need a
  per-block view; data_store_updater.test.ts uses these in place of the
  removed (contract, tag) sweep helper
- introduce queryAllPrivate/PublicLogsByTags in stdlib that drives the
  per-tag afterLog loop, and refactor PXE's getAllPages* to delegate
… refine

- add archiver block_store unit tests for getTxLocation and
  getNoteHashesAndNullifiers, including a partial-vs-full deserializer
  agreement check
- add log_store tests for the per-block view helpers and the new stdlib
  queryAll*ByTags drivers (limitPerTag forces multi-round pagination)
- export refineTxHashAndRange from stdlib and reuse it in aztec.js wallet
  event-filter schemas in place of the local copy
… of AztecBinaryMap

Replace the AztecAsyncBinaryMap abstraction with hex-string keys over the
existing AztecAsyncMap. Composite log keys are now
`[contract-]tag-blockHex8-txIdxHex8-logIdxHex8` (separator '-', each
numeric segment zero-padded to 8 lowercase hex chars). Fixed-width
zero-padded hex preserves lexicographic order under ordered-binary's
string encoding, so range scans still answer in canonical
(contract, tag, blockNumber, txIndexWithinBlock, logIndexWithinTx) order.

Deletes the AztecAsyncBinaryMap interface, the LMDB v2 implementation,
`openBinaryMap` on AztecAsyncKVStore (and the stubs on v1/IDB/OPFS),
the raw-key helpers in lmdb-v2/utils.ts, and the binary_map test file.
Adds a 24th log_store test that writes 1000 logs across 10 sequential
blocks and confirms entriesAsync returns them in canonical composite
order, exercising the hex-string key encoding directly.
…pts narrowing

Downgrades LogResult from a class to a plain type modeled on BlockResponse:
`LogResultBase + IfFlag<LogIncludeOptions, Opts, 'includeEffects', ...>`.
`toBuffer`/`fromBuffer` are removed (no production callers — the archiver's
log_store has its own StoredLogValue codec). `equals` is removed (tests now
compare via `toEqual`). `schema` becomes the `LogResultSchema` constant,
`toHumanReadable` becomes the `logResultToHumanReadable` free function, and
`random` becomes `randomLogResult`.

The L2LogsSource / AztecNode interfaces stay non-generic (return the widest
LogResult shape) — JSON-RPC schema validation can't preserve a stricter
narrowing across the wire. The pxe `getAllPrivateLogsByTags` /
`getAllPublicLogsByTagsFromContract` wrappers are generic over Opts so
call-site code can pass `{ includeEffects: true }` and receive a narrowed
`LogResult<{ includeEffects: true }>[][]` (no more lying non-null
assertions on `log.nullifiers!`).
Threads `limitPerTag` through both wrappers (`getAllPrivateLogsByTags`,
`getAllPublicLogsByTagsFromContract`) into the underlying stdlib query so
callers can force pagination at a small page size — primarily useful for
tests, but also lets the recipient-sync path scope an upper bound.
…sync

Recipient sync now passes `toBlock: anchorBlockNumber + 1` to the node
instead of filtering post-anchor logs in memory. The node enforces the
upper bound during the range scan, avoiding the extra round-trip cost of
fetching logs the client immediately drops. Updates the corresponding test
to mock the node honoring `toBlock` and asserts the bound is propagated.
…e anchor

The PXE anchors to the genesis block during early sync and passes its hash as the referenceBlock of a tagged-log query. Genesis is synthetic and never indexed in the block store, so the reorg check threw "Reference block not found" and broke e2e sync. LogStore now requires the genesis block hash and resolves a matching referenceBlock to the genesis block number rather than treating it as a reorg; the hash is threaded from the archiver factory through createArchiverStore/createArchiverDataStores.

Also includes related log store refactors from the same branch session: extract the key/value codec into log_store_codec.ts free functions with a unit suite, scope logIndexWithinTx independently for private and public logs within a tx, and rename getAllPrivate/PublicLogsForBlock to getPrivate/PublicLogsForBlock.
The log retrieval API redesign removed aztecNode.getPublicLogs and made PublicEventFilter.contractAddress required. Update the aztecjs_advanced sample and how_to_read_data prose accordingly so the docs build type-checks.
Replace the hand-rolled per-tag afterLog pagination loop in the get-logs command with stdlib queryAllPublicLogsByTags, which drains all pages internally. --follow keeps polling indefinitely by draining all currently-available logs each round and carrying the last cursor forward.
PrivateLogsQuerySchema/PublicLogsQuerySchema constrained tags with .min(1) but no upper bound, so the node accepted an unbounded tags array even though the PXE chunks at MAX_RPC_LEN. Add .max(MAX_RPC_LEN) to both schemas so the server enforces the same bound clients assume.
Doubles the per-tag log page size from 10 to 20. Updates the archiver log store pagination tests whose fixtures were sized for the old limit (the multi-page cases now generate enough logs to fill a 20-entry page, with the single-tx case sized off MAX_LOGS_PER_TAG so it survives future bumps).
The events_and_logs guide used the removed node.getPublicLogs(LogFilter); read raw public logs from getBlock(n, { includeTransactions: true }).body.txEffects[*].publicLogs instead, matching the migrated aztec.js examples.
@spalladino spalladino enabled auto-merge (squash) May 29, 2026 15:56
Rebase onto merge-train/spartan merged a base-branch test that still used the
old (tags) callback signature; align it with the query-based mock used by the
other cases in the file.
@spalladino spalladino force-pushed the spl/new-get-log-apis branch from 55749bb to fe3fe4c Compare May 29, 2026 16:00
@spalladino spalladino merged commit 1aa8f10 into merge-train/spartan May 29, 2026
14 checks passed
@spalladino spalladino deleted the spl/new-get-log-apis branch May 29, 2026 16:27
danielntmd pushed a commit to danielntmd/aztec-packages that referenced this pull request Jun 4, 2026
BEGIN_COMMIT_OVERRIDE
test(e2e): unskip pipelining related e2e tests (AztecProtocol#23642)
fix(archiver): prune blocks without proposed checkpoint by end of build
slot (AztecProtocol#23606)
test: migrate benchmarks to pipelining setup (AztecProtocol#23647)
fix(p2p): fall back to archiver in BLOCK_TXS response validation
(AztecProtocol#23624)
docs(slashing): align operator and slasher docs with AZIP-7 (AztecProtocol#23494)
fix(p2p): do not penalize peers that signal a missing block with Fr.ZERO
(AztecProtocol#23672)
chore: adjust metrics deployment (AztecProtocol#23676)
fix(cheat-codes): warpL2TimeAtLeastBy advances relative to leading clock
(AztecProtocol#23675)
chore: tighten node pool sizes (AztecProtocol#23678)
chore: remove archival nodes (AztecProtocol#23630)
chore: merge blob sink duties into RPC node (AztecProtocol#23631)
fix: sync avm-transpiler Cargo.lock with noir submodule (AztecProtocol#23683)
fix(spartan): set validator lag env vars in tps-scenario (AztecProtocol#23684)
fix: make world-state hash queries reorg-aware to close getWorldState
race (AztecProtocol#23677)
fix: pin noir submodule to next's version on merge-train/spartan
(AztecProtocol#23690)
fix: ensure image ref is used by bench runner (AztecProtocol#23682)
fix(ci): retry aztec-nr nargo dependency clone on transient network
flake (AztecProtocol#23653)
chore: run one-off jobs on network nodes (AztecProtocol#23701)
fix: simulate proposals inside target slot (AztecProtocol#23692)
chore: smaller eth-devnet (AztecProtocol#23704)
chore: enable testnet autoscaling (AztecProtocol#23705)
feat(api)!: redesign node log retrieval API around tag-based queries
(AztecProtocol#23625)
fix(sequencer): set own proposed checkpoint locally instead of via p2p
loopback (AztecProtocol#23659)
END_COMMIT_OVERRIDE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants