Skip to content

fix(archiver): key contract instance updates by the block that carried them - #230

Open
spalladino wants to merge 3 commits into
spl/fast-inbox-networkfrom
spl/archiver-contract-update-block-ordering
Open

spalladino wants to merge 3 commits into
spl/fast-inbox-networkfrom
spl/archiver-contract-update-block-ordering

Conversation

@spalladino

@spalladino spalladino commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #216, the tail of the fast inbox stack. Three commits; review the top ones only.

Problem

The archiver indexes each scheduled contract class change under [address, schedulingTimestamp, indexWithinBlock]. The block that carried the update is not part of the key, so two blocks that share a timestamp collide:

  • The later block's update is written to the same key as the earlier block's, so getCurrentContractInstanceClassId can resolve the stale class id.
  • Unwinding the later block deletes that shared key, taking the earlier block's update with it and leaving the contract resolving to its original class.

Separately, timestamps were stored with toString(). Keys compare lexicographically, so an update scheduled at a timestamp with fewer digits than the queried one sorted outside the lookup range and was missed entirely — 99 is not found by a query at 100.

Change

  • Stored keys become [address, schedulingTimestamp, blockNumber, indexWithinBlock]. The block number is threaded through updateContractDataOnDb into both addContractInstanceUpdates and deleteContractInstanceUpdates, so insertion and rollback agree on the key.
  • Timestamps are encoded as 20-digit zero-padded decimal strings, making lexicographic key order match numeric order across the whole uint64 range plus the exclusive upper boundary a lookup builds.
  • The in-block index is unchanged: filtering already preserves execution order within a block, and the block order was the only missing component.

kv-store: prefix range boundaries (second commit)

Range<K> typed its start/end as the map's own key type, so a store keyed by a tuple could not express "every key under this address" without inventing values for the components it did not want to constrain. The existing workaround — and what this PR first did — was to widen the map's key type to a union of the stored key and the boundary shape, which also makes the key type stop proving that only whole keys are stored.

Range<K> now takes KeyPrefix<K>: a tuple key or any of its prefixes, and other key types unchanged. Array keys already order element-wise in all four backends, so a prefix is a well-defined boundary — this only lets the type say so. The archiver map then drops its union and its key builders become private.

Backend helpers that serialize a boundary take Key rather than K, because TypeScript cannot prove K extends KeyPrefix<K> while K is generic; they only hand the value to the key encoder. mapRange returns CustomRange<K> for the same reason.

Coverage is added to the shared map test suite, so it runs against lmdb, lmdb-v2, indexeddb and sqlite-opfs.

Database version

ARCHIVER_DB_VERSION goes from 10 to 11. The two key layouts cannot coexist in one map, and entries lost to a collision cannot be reconstructed from the surviving index, so renaming the map is not enough — it would leave a node marked synchronized with an empty update index. The version manager resets the archiver database and normal synchronization rebuilds it from block logs.

Operational consequence: nodes resync their archiver on first start under this version. Not currently written up in the migration notes.

Tests

Red first, then green.

data_store_updater.test.ts, driving real ContractInstanceUpdated public logs through the updater:

  • Two blocks at the same timestamp both updating one contract resolve to the later block's class id.
  • Pruning the later of two same-timestamp blocks restores the earlier block's update rather than erasing it.

contract_instance_store.test.ts: same timestamp across different blocks, multiple updates within one block, deletion revealing the earlier block, address isolation, a later timestamp in a lower block, the decimal digit boundary, before/at activation, and a query at 2^64 - 1.

Archiver suite 693 passing, kv-store suite 292 passing. Build, format and lint clean.

Noted while here, not fixed

lmdb-v2 does not round-trip a tuple key through keysAsyncdeserializeKey returns parsed[1], but ordered-binary flattens the nested array, so a ['a', 1, 0] key reads back as 'a'. Pre-existing and unrelated to ranges; nothing hits it today because tuple-keyed stores read values rather than keys. The new suite test asserts on values to stay clear of it.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with insertion, lookup, rollback, and database migration behavior aligned with the new key layout.

Summary

This PR corrects contract-instance update indexing by including the carrying block number and encoding timestamps so lexicographic key ordering matches numeric ordering.

  • Threads block numbers through insertion and rollback paths.
  • Separates stored update keys from lookup boundary keys.
  • Bumps the archiver database version to force reconstruction of the incompatible index.
  • Adds store-level and updater-level coverage for collisions, rollback, ordering, activation, isolation, and uint64 boundaries.
  • Documents the required archiver resynchronization.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[L2 block public logs] --> B[Filter contract instance updates]
  B --> C[Preserve execution-order index]
  C --> D[Encode scheduling timestamp]
  D --> E["Key: address, timestamp, block number, index"]
  E --> F[(Archiver update index)]
  Q[Class ID lookup at timestamp] --> R["Range: address, 0 through timestamp + 1"]
  R --> F
  F --> S[Highest matching update]
  U[Block rollback] --> V[Reconstruct identical keys]
  V --> F
Loading

Reviews (1) · Last reviewed commit: "fix(archiver): key contract instance upd..."

@spalladino
spalladino force-pushed the spl/archiver-contract-update-block-ordering branch from 394b962 to 6f72f04 Compare September 17, 2026 18:19
@spalladino
spalladino changed the base branch from spl/a-2041-append-messages-before-txs to spl/fast-inbox-network September 17, 2026 18:20
@spalladino
spalladino added this pull request to stack #217 September 17, 2026 18:20
@spalladino
spalladino force-pushed the spl/archiver-contract-update-block-ordering branch from 6f72f04 to 459530b Compare September 17, 2026 18:36
@spalladino
spalladino force-pushed the spl/archiver-contract-update-block-ordering branch from 459530b to 9e71ab4 Compare September 17, 2026 21:15
…d them

The archiver indexed each scheduled contract class change under
[address, schedulingTimestamp, indexWithinBlock]. Two blocks that share a
timestamp therefore collide: the later block's update lands on the same key as
the earlier one, so a lookup can resolve the wrong class id, and unwinding the
later block deletes the entry the earlier block had written.

Key stored updates by [address, schedulingTimestamp, blockNumber,
indexWithinBlock] instead, and thread the block number through ingestion and
rollback so insertion and deletion agree on the key. Timestamps are encoded as
20-digit decimal strings so that lexicographic key ordering matches numeric
ordering; previously an update at a timestamp with fewer digits than the queried
one fell outside the lookup range entirely.

Range boundaries keep their own two-component type, so a partial key can no
longer be built where a stored one is expected.

ARCHIVER_DB_VERSION moves to 11: the two layouts cannot coexist in one map and
the colliding entries cannot be reconstructed from the surviving index, so nodes
resync the archiver on upgrade. Noted in the migration notes.
Supersedes the "Noted in the migration notes" line in the previous commit: the
ARCHIVER_DB_VERSION bump carries no migration-notes entry.
Range boundaries were typed as the map's own key type, so a store keyed by a
tuple could not express "every key under this address" without inventing values
for the components it did not want to constrain. Callers worked around it by
widening the map's key type to a union of the stored key and the boundary shape,
which also made the key type stop proving that only whole keys are ever stored.

Range<K> now takes KeyPrefix<K>: for a tuple key that is the key itself or any
of its prefixes, and for every other key type it is the key type unchanged.
Array keys already order element-wise in all four backends, so a prefix is a
well-defined boundary; this only lets the type say so.

Backend helpers that serialize a boundary take Key rather than K, since
TypeScript cannot prove K extends KeyPrefix<K> while K is still generic, and
they only ever hand the value to the key encoder. mapRange returns CustomRange<K>
for the same reason; its result is still assignable at concrete call sites.

The archiver's contract instance update map drops its union and is keyed by the
stored key alone, and its two key builders become private.

Test coverage is added to the shared map suite, so it runs against lmdb, lmdb-v2,
indexeddb and sqlite-opfs. It asserts on values rather than keys: lmdb-v2 does not
round-trip a tuple key through keysAsync, which is a separate pre-existing issue.
@spalladino
spalladino force-pushed the spl/archiver-contract-update-block-ordering branch from 9e71ab4 to 8636218 Compare September 18, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant