Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Added chain tip to the block producer status ([#1419](https://github.com/0xMiden/miden-node/pull/1419)).
- The mempool's transaction capacity is now configurable ([#1433](https://github.com/0xMiden/miden-node/pull/1433)).
- Renamed card's names in the `miden-network-monitor` binary ([#1441](https://github.com/0xMiden/miden-node/pull/1441)).
- Added pagination to `GetNetworkAccountIds` endpoint ([#1452](https://github.com/0xMiden/miden-node/pull/1452)).
- Improved tracing in `miden-network-monitor` binary ([#1366](https://github.com/0xMiden/miden-node/pull/1366)).
- Integrated RPC stack with Validator component for transaction validation ([#1457](https://github.com/0xMiden/miden-node/pull/1457)).
- Added explorer status to the `miden-network-monitor` binary ([#1450](https://github.com/0xMiden/miden-node/pull/1450)).
Expand Down
70 changes: 56 additions & 14 deletions crates/ntx-builder/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ use miden_node_proto::clients::{Builder, StoreNtxBuilderClient};
use miden_node_proto::domain::account::NetworkAccountPrefix;
use miden_node_proto::domain::note::NetworkNote;
use miden_node_proto::errors::ConversionError;
use miden_node_proto::generated::rpc::BlockRange;
use miden_node_proto::generated::{self as proto};
use miden_node_proto::try_convert;
use miden_protocol::Word;
use miden_protocol::account::{Account, AccountId};
use miden_protocol::block::BlockHeader;
use miden_protocol::block::{BlockHeader, BlockNumber};
use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr};
use miden_protocol::note::NoteScript;
use miden_tx::utils::Deserializable;
Expand Down Expand Up @@ -172,21 +173,60 @@ impl StoreClient {
Ok(all_notes)
}

// TODO: add pagination.
/// Get all network account IDs.
///
/// Since the `GetNetworkAccountIds` method is paginated, we loop through all pages until we
/// reach the end.
///
/// Each page can return up to `MAX_RESPONSE_PAYLOAD_BYTES / AccountId::SERIALIZED_SIZE`
/// accounts (~289,000). With 1000 iterations, this supports up to ~524 million network
/// accounts, which is assumed to be sufficient for the foreseeable future.
#[instrument(target = COMPONENT, name = "store.client.get_network_account_ids", skip_all, err)]
pub async fn get_network_account_ids(&self) -> Result<Vec<AccountId>, StoreError> {
let response = self.inner.clone().get_network_account_ids(()).await?.into_inner();

let accounts: Result<Vec<AccountId>, ConversionError> = response
.account_ids
.into_iter()
.map(|account_id| {
AccountId::read_from_bytes(&account_id.id)
.map_err(|err| ConversionError::deserialization_error("account_id", err))
})
.collect();

Ok(accounts?)
const MAX_ITERATIONS: u32 = 1000;

let block_range = BlockNumber::from(0)..=BlockNumber::from(u32::MAX);

let mut ids = Vec::new();
let mut iterations_count = 0;

loop {
let response = self
.inner
.clone()
.get_network_account_ids(Into::<BlockRange>::into(block_range.clone()))
.await?
.into_inner();

let accounts: Result<Vec<AccountId>, ConversionError> = response
.account_ids
.into_iter()
.map(|account_id| {
AccountId::read_from_bytes(&account_id.id)
.map_err(|err| ConversionError::deserialization_error("account_id", err))
})
.collect();

let pagination_info = response.pagination_info.ok_or(
ConversionError::MissingFieldInProtobufRepresentation {
entity: "NetworkAccountIdList",
field_name: "pagination_info",
},
)?;

ids.extend(accounts?);
iterations_count += 1;

if pagination_info.block_num == pagination_info.chain_tip {
break;
}

if iterations_count >= MAX_ITERATIONS {
return Err(StoreError::MaxIterationsReached("GetNetworkAccountIds".to_string()));
}
}

Ok(ids)
}

#[instrument(target = COMPONENT, name = "store.client.get_note_script_by_root", skip_all, err)]
Expand Down Expand Up @@ -226,4 +266,6 @@ pub enum StoreError {
MalformedResponse(String),
#[error("failed to parse response")]
DeserializationError(#[from] ConversionError),
#[error("max iterations reached: {0}")]
MaxIterationsReached(String),
}
18 changes: 13 additions & 5 deletions crates/proto/src/generated/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,11 @@ pub struct UnconsumedNetworkNotes {
/// Represents the result of getting the network account ids.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct NetworkAccountIdList {
/// Pagination information.
#[prost(message, optional, tag = "1")]
pub pagination_info: ::core::option::Option<super::rpc::PaginationInfo>,
/// The list of network account ids.
#[prost(message, repeated, tag = "1")]
#[prost(message, repeated, tag = "2")]
pub account_ids: ::prost::alloc::vec::Vec<super::account::AccountId>,
}
/// Current blockchain data based on the requested block number.
Expand Down Expand Up @@ -2432,7 +2435,7 @@ pub mod ntx_builder_client {
/// Returns a list of all network account ids.
pub async fn get_network_account_ids(
&mut self,
request: impl tonic::IntoRequest<()>,
request: impl tonic::IntoRequest<super::super::rpc::BlockRange>,
) -> std::result::Result<
tonic::Response<super::NetworkAccountIdList>,
tonic::Status,
Expand Down Expand Up @@ -2532,7 +2535,7 @@ pub mod ntx_builder_server {
/// Returns a list of all network account ids.
async fn get_network_account_ids(
&self,
request: tonic::Request<()>,
request: tonic::Request<super::super::rpc::BlockRange>,
) -> std::result::Result<
tonic::Response<super::NetworkAccountIdList>,
tonic::Status,
Expand Down Expand Up @@ -2830,14 +2833,19 @@ pub mod ntx_builder_server {
"/store.NtxBuilder/GetNetworkAccountIds" => {
#[allow(non_camel_case_types)]
struct GetNetworkAccountIdsSvc<T: NtxBuilder>(pub Arc<T>);
impl<T: NtxBuilder> tonic::server::UnaryService<()>
impl<
T: NtxBuilder,
> tonic::server::UnaryService<super::super::rpc::BlockRange>
for GetNetworkAccountIdsSvc<T> {
type Response = super::NetworkAccountIdList;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(&mut self, request: tonic::Request<()>) -> Self::Future {
fn call(
&mut self,
request: tonic::Request<super::super::rpc::BlockRange>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as NtxBuilder>::get_network_account_ids(&inner, request)
Expand Down
2 changes: 2 additions & 0 deletions crates/store/src/db/migrations/2025062000000_setup/up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ CREATE TABLE accounts (
vault BLOB,
nonce INTEGER,
is_latest BOOLEAN NOT NULL DEFAULT 0, -- Indicates if this is the latest state for this account_id
created_at_block INTEGER NOT NULL,

PRIMARY KEY (account_id, block_num),
CONSTRAINT all_null_or_none_null CHECK
Expand All @@ -35,6 +36,7 @@ CREATE TABLE accounts (
CREATE INDEX idx_accounts_network_prefix ON accounts(network_account_id_prefix) WHERE network_account_id_prefix IS NOT NULL;
CREATE INDEX idx_accounts_id_block ON accounts(account_id, block_num DESC);
CREATE INDEX idx_accounts_latest ON accounts(account_id, is_latest) WHERE is_latest = 1;
CREATE INDEX idx_accounts_created_at_block ON accounts(created_at_block);
-- Index for joining with block_headers
CREATE INDEX idx_accounts_block_num ON accounts(block_num);
-- Index for joining with account_codes
Expand Down
25 changes: 21 additions & 4 deletions crates/store/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,11 +433,28 @@ impl Db {
.await
}

/// Loads all network account IDs from the DB.
/// Returns network account IDs within the specified block range (based on account creation
/// block).
///
/// The function may return fewer accounts than exist in the range if the result would exceed
/// `MAX_RESPONSE_PAYLOAD_BYTES / AccountId::SERIALIZED_SIZE` rows. In this case, the result is
/// truncated at a block boundary to ensure all accounts from included blocks are returned.
///
/// # Returns
///
/// A tuple containing:
/// - A vector of network account IDs.
/// - The last block number that was fully included in the result. When truncated, this will be
/// less than the requested range end.
#[instrument(level = "debug", target = COMPONENT, skip_all, ret(level = "debug"), err)]
pub async fn select_all_network_account_ids(&self) -> Result<Vec<AccountId>> {
self.transact("Get all network account IDs", queries::select_all_network_account_ids)
.await
pub async fn select_all_network_account_ids(
&self,
block_range: RangeInclusive<BlockNumber>,
) -> Result<(Vec<AccountId>, BlockNumber)> {
self.transact("Get all network account IDs", move |conn| {
queries::select_all_network_account_ids(conn, block_range)
})
.await
}

#[instrument(level = "debug", target = COMPONENT, skip_all, ret(level = "debug"), err)]
Expand Down
103 changes: 86 additions & 17 deletions crates/store/src/db/models/queries/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,28 +410,79 @@ pub(crate) fn select_all_accounts(
Ok(account_infos)
}

/// Returns all network account IDs.
/// Returns network account IDs within the specified block range (based on account creation
/// block).
///
/// The function may return fewer accounts than exist in the range if the result would exceed
/// `MAX_RESPONSE_PAYLOAD_BYTES / AccountId::SERIALIZED_SIZE` rows. In this case, the result is
/// truncated at a block boundary to ensure all accounts from included blocks are returned.
///
/// # Returns
///
/// A vector with network account IDs, or an error.
/// A tuple containing:
/// - A vector of network account IDs.
/// - The last block number that was fully included in the result. When truncated, this will be less
/// than the requested range end.
pub(crate) fn select_all_network_account_ids(
conn: &mut SqliteConnection,
) -> Result<Vec<AccountId>, DatabaseError> {
let account_ids_raw: Vec<Vec<u8>> = QueryDsl::select(
schema::accounts::table.filter(schema::accounts::network_account_id_prefix.is_not_null()),
schema::accounts::account_id,
block_range: RangeInclusive<BlockNumber>,
) -> Result<(Vec<AccountId>, BlockNumber), DatabaseError> {
const ROW_OVERHEAD_BYTES: usize = AccountId::SERIALIZED_SIZE;
Copy link
Contributor

Choose a reason for hiding this comment

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

future work/probably too much: we might want to use build.rs that generates this const value

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should I create an issue for this one?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like @Mirko-von-Leipzig 's input on it first :)

Copy link
Collaborator

@Mirko-von-Leipzig Mirko-von-Leipzig Dec 26, 2025

Choose a reason for hiding this comment

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

This is an internal only endpoint, so I think we can get away with simplifying this as much as possible such that we don't need a derived MAX_ROWS at all, but can simply keep it at some arbitrary value.

With a small bit of refactoring in the ntx builder (briefly touched upon in #1478 (comment)), we can simplify this function signature to

pub(crate) fn select_all_network_account_ids(
    conn: &mut SqliteConnection,
    token: ContinuationToken,
) -> Result<(Vec<AccountId>, ContinuationToken), DatabaseError> 

This is because the ntx builder won't need to care about block-delimited pages, and instead only needs a mechanism to get all currently committed network account IDs.

So all we need is an iteration key that retains the order, which could be

ORDER BY rowid                  -- if we don't delete, or
ORDER BY created_at, account_id -- for an agnostic one

and then

struct ContinuationToken(rowid);
// or
struct ContinuationToken(BlockNumber, AccountId);

If we do decide to go the stream route, then ContinuationToken would remain internal only, otherwise it forms part of the protobuf.

const MAX_ROWS: usize = MAX_RESPONSE_PAYLOAD_BYTES / ROW_OVERHEAD_BYTES;

const _: () = assert!(
MAX_ROWS > miden_protocol::MAX_ACCOUNTS_PER_BLOCK,
"Block pagination limit must exceed maximum block capacity to uphold assumed logic invariant"
);

if block_range.is_empty() {
return Err(DatabaseError::InvalidBlockRange {
from: *block_range.start(),
to: *block_range.end(),
});
}

let account_ids_raw: Vec<(Vec<u8>, i64)> = Box::new(
QueryDsl::select(
schema::accounts::table
.filter(schema::accounts::network_account_id_prefix.is_not_null()),
(schema::accounts::account_id, schema::accounts::created_at_block),
)
.filter(
schema::accounts::block_num
.between(block_range.start().to_raw_sql(), block_range.end().to_raw_sql()),
)
.order(schema::accounts::created_at_block.asc())
.limit(i64::try_from(MAX_ROWS + 1).expect("limit fits within i64")),
)
.load::<Vec<u8>>(conn)?;
.load::<(Vec<u8>, i64)>(conn)?;

let account_ids = account_ids_raw
.into_iter()
.map(|id_bytes| {
AccountId::read_from_bytes(&id_bytes).map_err(DatabaseError::DeserializationError)
})
.collect::<Result<Vec<AccountId>, DatabaseError>>()?;
if account_ids_raw.len() > MAX_ROWS {
// SAFETY: We just checked that len > MAX_ROWS, so the vec is not empty.
let last_created_at_block = account_ids_raw.last().expect("vec is not empty").1;

let account_ids = account_ids_raw
.into_iter()
.take_while(|(_, created_at_block)| *created_at_block != last_created_at_block)
.map(|(id_bytes, _)| {
AccountId::read_from_bytes(&id_bytes).map_err(DatabaseError::DeserializationError)
})
.collect::<Result<Vec<AccountId>, DatabaseError>>()?;

let last_block_included =
BlockNumber::from_raw_sql(last_created_at_block.saturating_sub(1))?;

Ok((account_ids, last_block_included))
} else {
let account_ids = account_ids_raw
.into_iter()
.map(|(id_bytes, _)| {
AccountId::read_from_bytes(&id_bytes).map_err(DatabaseError::DeserializationError)
})
.collect::<Result<Vec<AccountId>, DatabaseError>>()?;

Ok(account_ids)
Ok((account_ids, *block_range.end()))
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -803,13 +854,29 @@ pub(crate) fn upsert_accounts(
let mut count = 0;
for update in accounts {
let account_id = update.account_id();
let account_id_bytes = account_id.to_bytes();
let block_num_raw = block_num.to_raw_sql();

let network_account_id_prefix = if account_id.is_network() {
Some(NetworkAccountPrefix::try_from(account_id)?)
} else {
None
};

// Preserve the original creation block when updating existing accounts.
let created_at_block = QueryDsl::select(
schema::accounts::table.filter(
schema::accounts::account_id
.eq(&account_id_bytes)
.and(schema::accounts::is_latest.eq(true)),
),
schema::accounts::created_at_block,
)
.first::<i64>(conn)
.optional()
.map_err(DatabaseError::Diesel)?
.unwrap_or(block_num_raw);

// NOTE: we collect storage / asset inserts to apply them only after the account row is
// written. The storage and vault tables have FKs pointing to `accounts (account_id,
// block_num)`, so inserting them earlier would violate those constraints when inserting a
Expand Down Expand Up @@ -916,25 +983,26 @@ pub(crate) fn upsert_accounts(
diesel::update(schema::accounts::table)
.filter(
schema::accounts::account_id
.eq(&account_id.to_bytes())
.eq(&account_id_bytes)
.and(schema::accounts::is_latest.eq(true)),
)
.set(schema::accounts::is_latest.eq(false))
.execute(conn)?;

let account_value = AccountRowInsert {
account_id: account_id.to_bytes(),
account_id: account_id_bytes,
network_account_id_prefix: network_account_id_prefix
.map(NetworkAccountPrefix::to_raw_sql),
account_commitment: update.final_state_commitment().to_bytes(),
block_num: block_num.to_raw_sql(),
block_num: block_num_raw,
nonce: full_account.as_ref().map(|account| nonce_to_raw_sql(account.nonce())),
storage: full_account.as_ref().map(|account| account.storage().to_bytes()),
vault: full_account.as_ref().map(|account| account.vault().to_bytes()),
code_commitment: full_account
.as_ref()
.map(|account| account.code().commitment().to_bytes()),
is_latest: true,
created_at_block,
};

diesel::insert_into(schema::accounts::table)
Expand Down Expand Up @@ -995,6 +1063,7 @@ pub(crate) struct AccountRowInsert {
pub(crate) vault: Option<Vec<u8>>,
pub(crate) nonce: Option<i64>,
pub(crate) is_latest: bool,
pub(crate) created_at_block: i64,
}

#[derive(Insertable, AsChangeset, Debug, Clone)]
Expand Down
1 change: 1 addition & 0 deletions crates/store/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ diesel::table! {
nonce -> Nullable<BigInt>,
block_num -> BigInt,
is_latest -> Bool,
created_at_block -> BigInt,
}
}

Expand Down
Loading