Skip to content

feat: make SyncNotes return multiple blocks#1843

Open
TomasArrachea wants to merge 8 commits intonextfrom
tomasarrachea-sync-notes-rework
Open

feat: make SyncNotes return multiple blocks#1843
TomasArrachea wants to merge 8 commits intonextfrom
tomasarrachea-sync-notes-rework

Conversation

@TomasArrachea
Copy link
Collaborator

@TomasArrachea TomasArrachea commented Mar 25, 2026

Closes #1809

This PR updates the RPC component to batch multiple blocks into a single SyncNotes response.

Changes

  • RPC sync_notes handler now loops over store calls, accumulating blocks until the 4MB budget is exceeded or the range is exhausted
  • Proto: changed SyncNotesResponse proto from single-block (header + mmr_path + notes) to multi-block (repeated NoteSyncBlock blocks + BlockRange). Added StoreSyncNotesResponse for the store-internal single-block response, using BlockRange instead of PaginationInfo.
  • Store's sync_notes uses open_at(block_num, checkpoint) instead of open(block_num) so MMR proofs are relative to block_to

Follow ups:

  • Instead of batching the sync note responses at the RPC component, batch them on the store and minimize the gRPC requests.
  • Deduplicate the authentication nodes between returned note updates.

@TomasArrachea TomasArrachea force-pushed the tomasarrachea-sync-notes-rework branch from 6961007 to 6e0dd69 Compare March 26, 2026 15:26
@TomasArrachea TomasArrachea marked this pull request as ready for review March 26, 2026 15:34
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left a few small comments inline.

Comment on lines +500 to +504
BlockRange block_range = 5;

// List of all notes together with the Merkle paths from `response.block_header.note_root`.
repeated note.NoteSyncRecord notes = 4;
// Blocks containing matching notes, ordered by block number ascending.
// May be empty if no notes matched in the range.
repeated NoteSyncBlock blocks = 6;
Copy link
Contributor

Choose a reason for hiding this comment

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

The field numbering looks a bit off here. Could block_range not start at 1?

Comment on lines +20 to +24
/// Estimated byte size of a single [`NoteSyncRecord`].
///
/// Note ID (~38 bytes) + index + metadata (~26 bytes) + sparse merkle path with 16
/// siblings (~608 bytes).
const NOTE_RECORD_BYTES: usize = 700;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is fine for now, but most likely will be a significant overestimate because sparse Merkle paths get compressed, and in most cases shouldn't be more than a couple hundred bytes. But the compression depends on how many paths there are (the more paths, the worse the compression) - so, taking the worst case is fine for now.

Comment on lines +15 to +18
/// Estimated byte size of a [`NoteSyncBlock`] excluding its notes.
///
/// `BlockHeader` (~341 bytes) + MMR proof with 32 siblings (~1216 bytes).
const BLOCK_OVERHEAD_BYTES: usize = 1600;
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for this PR, but it would be good to make this based on the actual serialization sizes. For example, we could have BlockHeader::SERIALIZED_SIZE constant and use it here.

Let's create an issue for this (and include NOTE_RECORD_BYTES in this issue too).

Copy link
Collaborator

@igamigo igamigo left a comment

Choose a reason for hiding this comment

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

Looks great! I think there are a couple of edge cases that might need addressing though.

Comment on lines +495 to +496
// `block_to` matches the request's `block_range.block_to`, or the chain tip if it was
// not specified.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: This is not always true, right? The text below mentions that the block_to may be smaller than what the user requested (and the chain tip as well)

/// - `note_tags`: The tags the client is interested in, resulting notes are restricted to the
/// first block containing a matching note.
/// - `block_range`: The range of blocks from which to synchronize notes.
/// Returns as many blocks with matching notes as fit within the response payload
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: You can reference the max payload size constant here

@@ -130,26 +130,34 @@ impl rpc_server::Rpc for StoreApi {
let request = request.into_inner();

let chain_tip = self.state.latest_block_num().await;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit (feel free to disregard): There's no need to read the latest block number if the user set a block_to

let request = request.into_inner();

let chain_tip = self.state.latest_block_num().await;
let requested_block_to = request.block_range.as_ref().and_then(|r| r.block_to);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: I would move this down to be below line 134

Comment on lines +102 to +104
if accumulated_size > MAX_RESPONSE_PAYLOAD_BYTES {
break;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Returning a single block is always guaranteed, right? We should make sure this is the case because otherwise it might be problematic


loop {
let range = current_from..=checkpoint;
let Some(note_sync) = self.db.get_note_sync(range, note_tags.clone()).await? else {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wonder if get_note_sync could change to take something like &[u32] to avoid cloning every time here

block_range: RangeInclusive<BlockNumber>,
) -> Result<(NoteSyncUpdate, MmrProof, BlockNumber), NoteSyncError> {
) -> Result<Vec<(NoteSyncUpdate, MmrProof)>, NoteSyncError> {
let inner = self.inner.read().await;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we want to take the lock in the loop to avoid keeping it for the whole duration

Copy link
Collaborator

Choose a reason for hiding this comment

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

(and it can be moved right next to the open_at() call)

Comment on lines +108 to +110
if block_num >= checkpoint {
break;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We break here but AFAIK we are supposed to return notes for the checkpoint block, right? I think this might be a special case. If the user calls SyncMmr which returns the chain tip header and the MmrDelta, the user already has the header (and it's already validated by default). So when block_num == checkpoint, we might to just returnr the notes, and not return the merkle path at all (open_at would otherwise fail, I think). cc @bobbinth

break;
}

let mmr_proof = inner.blockchain.open_at(block_num, checkpoint)?;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Before, I think if the user set a block_to beyond the chain tip, the response would be automatically valid because the store would find no note after the chain tip, but now because we open_at the user-set checkpoint, this might fail. I think this is fine because it's a wrong argument, but maybe at the RPC level we should check that the block_to is not beyond the chain tip and return early.

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.

Return multiple blocks for SyncNotes

3 participants