From 9fd8e336dd5f2498bc5d67f2fd31b0e57b4c1956 Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 16 Jun 2026 09:08:12 +0200 Subject: [PATCH 1/7] account-read: trustless sync-ledger account reads (transport-agnostic core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `account_read` module: the protocol layer that obtains an account + Merkle path from an untrusted peer via `answer_sync_ledger_query`, atop the existing pure fold in `account.rs`. Transport-free by design so every consumer (light-node over libp2p, mobile, MCP) reuses one trust-critical implementation instead of re-deriving it: - `sync_ledger_queries(index, depth)` → the RPC query plan (one What_child_hashes per level root→leaf + What_contents for the leaf), built from mina-tree `Address::from_index`. - `account_with_path(index, depth, answers)` → assembles `(Account, Vec)`, picking the off-path sibling at each level and reversing to the leaf→root order `implied_root` folds. - `verify_account(block, index, depth, answers)` → assemble + fold to the verified block's ledger root; a lying peer is caught as `NotIncluded`. - `ledger_hash(block)` → the root the relay pairs with each query. Hermetic test: answer the query plan from a real mina-tree `Database` and assert the assembled path equals mina-tree's canonical `merkle_path_at_index` and folds to the real root — correctness proven with no live network. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify/src/account_read.rs | 269 +++++++++++++++++++++++++ crates/mina-verify/src/lib.rs | 6 + 2 files changed, 275 insertions(+) create mode 100644 crates/mina-verify/src/account_read.rs diff --git a/crates/mina-verify/src/account_read.rs b/crates/mina-verify/src/account_read.rs new file mode 100644 index 0000000..87ffe8d --- /dev/null +++ b/crates/mina-verify/src/account_read.rs @@ -0,0 +1,269 @@ +//! Trustless account reads over the sync-ledger RPC — **transport-agnostic**. +//! +//! [`account.rs`](crate::account) is the trust core: given an account + a Merkle path +//! it folds them to the ledger root a verified block commits to. This module is the +//! protocol layer that *obtains* that account + path from an untrusted peer via Mina's +//! `answer_sync_ledger_query` RPC, without trusting the peer — any lie is caught when +//! the assembled path fails to fold to the verified root. +//! +//! It is deliberately transport-free: [`sync_ledger_queries`] returns the RPC *queries* +//! to issue, and [`account_with_path`] / [`verify_account`] consume the *answers*. The +//! caller supplies the wire (libp2p in `mina-light-node`, but equally a mobile app or +//! the MCP server over any transport). This keeps every trust-critical step in one +//! reusable crate instead of re-derived per consumer. +//! +//! ## The walk +//! A Mina account ledger is a depth-[`LEDGER_DEPTH`] binary Merkle tree; an account +//! sits at a leaf identified by its index. To read it: +//! - one `What_child_hashes` query per level (root→leaf): each returns the two child +//! hashes, of which the **off-path** one is a sibling on the account's Merkle path; +//! - one `What_contents` query at the leaf: returns the account itself. +//! +//! The siblings collected root→leaf are reversed to leaf→root — the order +//! [`crate::implied_root`] folds (height 0 = the account's immediate sibling). + +use mina_curves::pasta::Fp; +use mina_p2p_messages::v2::{ + LedgerHash, MerkleAddressBinableArgStableV1, MinaBaseAccountBinableArgStableV2, + MinaLedgerSyncLedgerAnswerStableV2 as SyncAnswer, + MinaLedgerSyncLedgerQueryStableV1 as SyncQuery, +}; +use mina_tree::{Account, AccountIndex, Address, MerklePath}; + +use crate::{verify_account_inclusion, Block}; + +/// Depth of the Mina account ledger (mainnet / devnet / mesa). An account index is a +/// [`LEDGER_DEPTH`]-bit path from the root; the Merkle path has this many siblings. +pub const LEDGER_DEPTH: usize = 35; + +/// Something went wrong reading an account from sync-ledger answers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountReadError { + /// The answer vector length didn't match the query plan (`depth + 1`). + AnswerCountMismatch { expected: usize, got: usize }, + /// A `What_child_hashes` slot held something other than `ChildHashesAre`. + NotChildHashes { level: usize }, + /// The `What_contents` slot held something other than `ContentsAre`, or was empty. + NotContents, + /// A ledger hash in an answer wasn't a valid field element. + BadHash, + /// The account binprot didn't decode into a ledger account. + BadAccount, + /// The account + assembled path did not fold to the verified block's ledger root — + /// the peer served a wrong account, a wrong path, or a stale ledger. + NotIncluded, +} + +impl std::fmt::Display for AccountReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AccountReadError::AnswerCountMismatch { expected, got } => { + write!(f, "expected {expected} sync-ledger answers, got {got}") + } + AccountReadError::NotChildHashes { level } => { + write!(f, "answer at level {level} was not ChildHashesAre") + } + AccountReadError::NotContents => write!(f, "leaf answer was not a non-empty ContentsAre"), + AccountReadError::BadHash => write!(f, "a sync-ledger hash was not a valid field element"), + AccountReadError::BadAccount => write!(f, "account binprot did not decode"), + AccountReadError::NotIncluded => { + write!(f, "account + path do not fold to the block's verified ledger root") + } + } + } +} +impl std::error::Error for AccountReadError {} + +/// The address of the depth-`level` node on the path to leaf `index` (its top `level` +/// bits), as the RPC's Merkle-address argument. +fn node_addr(index: u64, depth: usize, level: usize) -> MerkleAddressBinableArgStableV1 { + let prefix = index >> (depth - level); // top `level` bits + Address::from_index(AccountIndex(prefix), level).into() +} + +/// The sync-ledger queries to read the account at `index` in a depth-`depth` ledger: +/// one `What_child_hashes` per level root→leaf, then `What_contents` for the leaf. +/// +/// Issue each against the verified block's ledger root (the RPC pairs it with that +/// hash) and pass the answers, **in the same order**, to [`account_with_path`]. +pub fn sync_ledger_queries(index: u64, depth: usize) -> Vec { + let mut queries = Vec::with_capacity(depth + 1); + for level in 0..depth { + queries.push(SyncQuery::WhatChildHashes(node_addr(index, depth, level))); + } + let leaf = Address::from_index(AccountIndex(index), depth); + queries.push(SyncQuery::WhatContents(leaf.into())); + queries +} + +/// The block's committed ledger-root hash, in the form the `answer_sync_ledger_query` +/// RPC expects to be paired with each [`sync_ledger_queries`] entry. The relay is +/// proof-agnostic and never reaches into a block; the orchestrator passes this down. +pub fn ledger_hash(block: &Block) -> LedgerHash { + block + .header + .protocol_state + .body + .blockchain_state + .staged_ledger_hash + .non_snark + .ledger_hash + .clone() +} + +fn hash_to_fp(h: &LedgerHash) -> Result { + h.to_field::().map_err(|_| AccountReadError::BadHash) +} + +/// Assemble the account at `index` and its Merkle path from the `answers` to +/// [`sync_ledger_queries(index, depth)`]. **Does not verify** — fold with +/// [`crate::verify_account_inclusion`] (or call [`verify_account`]) against a verified +/// block before trusting the result. +pub fn account_with_path( + index: u64, + depth: usize, + answers: &[SyncAnswer], +) -> Result<(Account, Vec), AccountReadError> { + if answers.len() != depth + 1 { + return Err(AccountReadError::AnswerCountMismatch { + expected: depth + 1, + got: answers.len(), + }); + } + + // Child-hash answers, root→leaf. At each level the on-path direction is the + // corresponding bit of `index` (MSB-first); the *other* child is the sibling. + let mut root_to_leaf = Vec::with_capacity(depth); + for (level, answer) in answers[..depth].iter().enumerate() { + let (left, right) = match answer { + SyncAnswer::ChildHashesAre(l, r) => (l, r), + _ => return Err(AccountReadError::NotChildHashes { level }), + }; + let goes_right = (index >> (depth - level - 1)) & 1 == 1; + root_to_leaf.push(if goes_right { + MerklePath::Right(hash_to_fp(left)?) // we are the right child; sibling is left + } else { + MerklePath::Left(hash_to_fp(right)?) // we are the left child; sibling is right + }); + } + root_to_leaf.reverse(); // leaf→root: the order implied_root folds (height 0 first) + let path = root_to_leaf; + + // Leaf contents: the account at our index. `What_contents` at full leaf depth is a + // one-account subtree; take the first (and only) account. + let account = match &answers[depth] { + SyncAnswer::ContentsAre(accounts) => accounts + .iter() + .next() + .ok_or(AccountReadError::NotContents) + .and_then(|a: &MinaBaseAccountBinableArgStableV2| { + Account::try_from(a).map_err(|_| AccountReadError::BadAccount) + })?, + _ => return Err(AccountReadError::NotContents), + }; + + Ok((account, path)) +} + +/// Read **and verify** the account at `index` against a verified `block`: assemble the +/// account + path from `answers`, then confirm they fold to the block's committed +/// ledger root. A peer that lies about the account, the path, or the ledger is caught +/// here (`NotIncluded`). Returns the trusted account on success. +pub fn verify_account( + block: &Block, + index: u64, + depth: usize, + answers: &[SyncAnswer], +) -> Result { + let (account, path) = account_with_path(index, depth, answers)?; + if verify_account_inclusion(block, &account, &path) { + Ok(account) + } else { + Err(AccountReadError::NotIncluded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mina_p2p_messages::list::List; + use mina_p2p_messages::v2::MinaBaseLedgerHash0StableV1; + use mina_signer::CompressedPubKey; + use mina_tree::scan_state::currency::Balance; + use mina_tree::{AccountId, BaseLedger, Database, TokenId, V2}; + + // Answer our own queries from a real mina-tree ledger — i.e. act as the peer the + // light node would talk to — so the test exercises the exact query plan + sibling + // ordering against mina-tree's own Merkle layout, no live network needed. + fn serve(db: &mut Database, query: &SyncQuery) -> SyncAnswer { + match query { + SyncQuery::WhatChildHashes(addr) => { + let node: Address = addr.into(); + let l = db.get_inner_hash_at_addr(node.child_left()).unwrap(); + let r = db.get_inner_hash_at_addr(node.child_right()).unwrap(); + let wrap = |fp: Fp| LedgerHash::from(MinaBaseLedgerHash0StableV1(fp.into())); + SyncAnswer::ChildHashesAre(wrap(l), wrap(r)) + } + SyncQuery::WhatContents(addr) => { + let leaf: Address = addr.into(); + let account = db.get(leaf).unwrap(); + SyncAnswer::ContentsAre(List::one((&*account).into())) + } + SyncQuery::NumAccounts => unreachable!("not part of an account read"), + } + } + + #[test] + fn reconstructs_mina_tree_path_for_several_accounts() { + let depth = 10; // small tree; the logic is depth-agnostic + let mut db = Database::create(depth as u8); + + // A handful of accounts at different leaf positions: one valid public key × + // distinct token ids → distinct account ids → distinct leaf indices (good + // left/right path coverage). + let pk = CompressedPubKey::from_address( + "B62qnzbXmRNo9q32n4SNu2mpB8e7FYYLH8NmaX6oFCBYjjQ8SbD7uzV", + ) + .unwrap(); + let mut ids = Vec::new(); + for token in 1u64..=5 { + let id = AccountId::new(pk.clone(), TokenId::from(token)); + let acct = Account::create_with(id.clone(), Balance::from_u64(1000 + token)); + db.get_or_create_account(id.clone(), acct).unwrap(); + ids.push(id); + } + + for id in ids { + let index = db.index_of_account(id.clone()).unwrap(); + let want_path = db.merkle_path_at_index(index); + let want_account = *db.get_at_index(index).unwrap(); + + // Build the query plan, serve it from the same ledger, assemble back. + let queries = sync_ledger_queries(index.0, depth); + assert_eq!(queries.len(), depth + 1); + let answers: Vec = queries.iter().map(|q| serve(&mut db, q)).collect(); + let (got_account, got_path) = account_with_path(index.0, depth, &answers).unwrap(); + + assert_eq!(got_path, want_path, "assembled path must match mina-tree's"); + assert_eq!(got_account, want_account, "assembled account must match"); + // And the assembled path must fold to the real root. + assert_eq!(implied_root_for(&got_account, &got_path), db.merkle_root()); + } + } + + fn implied_root_for(account: &Account, path: &[MerklePath]) -> Fp { + crate::implied_root(account, path) + } + + #[test] + fn wrong_answer_count_is_rejected() { + let err = account_with_path(0, 10, &[]).unwrap_err(); + assert_eq!( + err, + AccountReadError::AnswerCountMismatch { + expected: 11, + got: 0 + } + ); + } +} diff --git a/crates/mina-verify/src/lib.rs b/crates/mina-verify/src/lib.rs index af036ec..b249bb4 100644 --- a/crates/mina-verify/src/lib.rs +++ b/crates/mina-verify/src/lib.rs @@ -47,6 +47,12 @@ pub use verifier_index::verifier_index_from_json; pub mod account; pub use account::{implied_root, ledger_root, verify_account_inclusion}; +pub mod account_read; +pub use account_read::{ + account_with_path, ledger_hash, sync_ledger_queries, verify_account, AccountReadError, + LEDGER_DEPTH, +}; + pub mod precomputed; pub use precomputed::header_from_precomputed; From eaf4fced315c5d4068dd6ab6246a230f59687b04 Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 16 Jun 2026 09:22:37 +0200 Subject: [PATCH 2/7] account-read: verify at an explicit proven root (epoch ledger), not the staged root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live devnet peers refuse sync-ledger queries for the tip's staged ledger root and the snarked target/source roots; they serve only the epoch ledgers (probed: staking 91585 accounts, next 91661). Both epoch-ledger hashes are fields of the proven consensus state, so they're sound trustless-read targets (finalized, proof-anchored balances). - Replace `verify_account(block, …)` (folded to the unservable staged root) with `verify_account_at_root(root, index, depth, answers)` — the caller picks a proven root and the fold is checked against it. - Add `staking_epoch_ledger_hash` / `next_epoch_ledger_hash` accessors (replacing `ledger_hash`, which returned the staged root). - Test now also drives `verify_account_at_root`: accepts the true root, rejects a wrong one. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify/src/account_read.rs | 64 +++++++++++++++++++------- crates/mina-verify/src/lib.rs | 4 +- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/crates/mina-verify/src/account_read.rs b/crates/mina-verify/src/account_read.rs index 87ffe8d..9283da8 100644 --- a/crates/mina-verify/src/account_read.rs +++ b/crates/mina-verify/src/account_read.rs @@ -30,7 +30,7 @@ use mina_p2p_messages::v2::{ }; use mina_tree::{Account, AccountIndex, Address, MerklePath}; -use crate::{verify_account_inclusion, Block}; +use crate::{implied_root, Block}; /// Depth of the Mina account ledger (mainnet / devnet / mesa). An account index is a /// [`LEDGER_DEPTH`]-bit path from the root; the Merkle path has this many siblings. @@ -96,18 +96,33 @@ pub fn sync_ledger_queries(index: u64, depth: usize) -> Vec { queries } -/// The block's committed ledger-root hash, in the form the `answer_sync_ledger_query` -/// RPC expects to be paired with each [`sync_ledger_queries`] entry. The relay is -/// proof-agnostic and never reaches into a block; the orchestrator passes this down. -pub fn ledger_hash(block: &Block) -> LedgerHash { +/// The **staking epoch** ledger root from a (verified) block's consensus state — a +/// proven field, and in practice the ledger that live peers actually serve over the +/// sync-ledger RPC (the tip's *staged* root is not served; see [`verify_account_at_root`]). +/// Pair this with [`sync_ledger_queries`] to read finalized, proof-anchored balances. +pub fn staking_epoch_ledger_hash(block: &Block) -> LedgerHash { block .header .protocol_state .body - .blockchain_state - .staged_ledger_hash - .non_snark - .ledger_hash + .consensus_state + .staking_epoch_data + .ledger + .hash + .clone() +} + +/// The **next epoch** ledger root from a (verified) block's consensus state — the other +/// proven, peer-served sync-ledger target (see [`staking_epoch_ledger_hash`]). +pub fn next_epoch_ledger_hash(block: &Block) -> LedgerHash { + block + .header + .protocol_state + .body + .consensus_state + .next_epoch_data + .ledger + .hash .clone() } @@ -165,18 +180,22 @@ pub fn account_with_path( Ok((account, path)) } -/// Read **and verify** the account at `index` against a verified `block`: assemble the -/// account + path from `answers`, then confirm they fold to the block's committed -/// ledger root. A peer that lies about the account, the path, or the ledger is caught -/// here (`NotIncluded`). Returns the trusted account on success. -pub fn verify_account( - block: &Block, +/// Read **and verify** the account at `index` against an explicit proven ledger `root`: +/// assemble the account + path from `answers`, then confirm they fold to `root`. The +/// root's trustworthiness comes from being a field of an already-verified block — e.g. +/// [`staking_epoch_ledger_hash`], which is what live peers actually serve over the +/// sync-ledger RPC (the tip's staged root is not served). A peer that lies about the +/// account, the path, or the ledger is caught here (`NotIncluded`). Returns the trusted +/// account on success. +pub fn verify_account_at_root( + root: &LedgerHash, index: u64, depth: usize, answers: &[SyncAnswer], ) -> Result { let (account, path) = account_with_path(index, depth, answers)?; - if verify_account_inclusion(block, &account, &path) { + let root_fp = root.to_field::().map_err(|_| AccountReadError::BadHash)?; + if implied_root(&account, &path) == root_fp { Ok(account) } else { Err(AccountReadError::NotIncluded) @@ -248,6 +267,19 @@ mod tests { assert_eq!(got_account, want_account, "assembled account must match"); // And the assembled path must fold to the real root. assert_eq!(implied_root_for(&got_account, &got_path), db.merkle_root()); + + // The full verify-at-root entry point (incl. LedgerHash conversion) accepts + // the true root and rejects a wrong one. + let root = LedgerHash::from(MinaBaseLedgerHash0StableV1(db.merkle_root().into())); + assert_eq!( + verify_account_at_root(&root, index.0, depth, &answers).unwrap(), + want_account + ); + let wrong = LedgerHash::from(MinaBaseLedgerHash0StableV1(Fp::from(7u64).into())); + assert_eq!( + verify_account_at_root(&wrong, index.0, depth, &answers), + Err(AccountReadError::NotIncluded) + ); } } diff --git a/crates/mina-verify/src/lib.rs b/crates/mina-verify/src/lib.rs index b249bb4..0ada420 100644 --- a/crates/mina-verify/src/lib.rs +++ b/crates/mina-verify/src/lib.rs @@ -49,8 +49,8 @@ pub use account::{implied_root, ledger_root, verify_account_inclusion}; pub mod account_read; pub use account_read::{ - account_with_path, ledger_hash, sync_ledger_queries, verify_account, AccountReadError, - LEDGER_DEPTH, + account_with_path, next_epoch_ledger_hash, staking_epoch_ledger_hash, sync_ledger_queries, + verify_account_at_root, AccountReadError, LEDGER_DEPTH, }; pub mod precomputed; From 1860771e82e75c9252587a0af98ffde7aa610c31 Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 16 Jun 2026 11:26:50 +0200 Subject: [PATCH 3/7] account-read: epoch-ledger sweep to build a pubkey->index map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the protocol logic to sweep a whole ledger and map public keys to leaf indices, so a light node can serve /account by pubkey (resolving the untrusted index hint itself) instead of requiring it from an indexer: - CONTENTS_SUBTREE_HEIGHT = 5 — the live sync-ledger responder serves What_contents at LEDGER_DEPTH-5 (32 accounts/batch); shallower is refused (probed on devnet). - ledger_sweep_queries(num_accounts, depth) — one What_contents per 32-account subtree covering indices 0..num. - pubkey_index_pairs(answers, depth) — parse the batches into (pubkey, leaf index); an untrusted hint, since every read still re-proves inclusion via verify_account_at_root and the caller cross-checks the pubkey. Test: a 70-account ledger (3 subtrees) reconstructs leaf indices 0..70 exactly against mina-tree's own indexing. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify/src/account_read.rs | 97 ++++++++++++++++++++++++-- crates/mina-verify/src/lib.rs | 5 +- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/crates/mina-verify/src/account_read.rs b/crates/mina-verify/src/account_read.rs index 9283da8..d59acc0 100644 --- a/crates/mina-verify/src/account_read.rs +++ b/crates/mina-verify/src/account_read.rs @@ -36,6 +36,13 @@ use crate::{implied_root, Block}; /// [`LEDGER_DEPTH`]-bit path from the root; the Merkle path has this many siblings. pub const LEDGER_DEPTH: usize = 35; +/// Subtree height the live sync-ledger responder serves `What_contents` at: it returns +/// the `2^h` accounts under a subtree rooted at depth `LEDGER_DEPTH - h`. Probed against +/// live devnet — `h = 5` (32 accounts/batch) is served; bigger batches are refused. Used +/// to sweep the whole ledger cheaply (≈ `num_accounts / 32` calls) to build a +/// public-key → leaf-index map (an untrusted hint; every read still re-proves inclusion). +pub const CONTENTS_SUBTREE_HEIGHT: usize = 5; + /// Something went wrong reading an account from sync-ledger answers. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AccountReadError { @@ -126,6 +133,49 @@ pub fn next_epoch_ledger_hash(block: &Block) -> LedgerHash { .clone() } +/// Queries to sweep every populated leaf of a `num_accounts`-account ledger of depth +/// `depth`: one `What_contents` per subtree at depth `depth - CONTENTS_SUBTREE_HEIGHT`, +/// in index order. Feed the answers to [`pubkey_index_pairs`] to build a +/// public-key → leaf-index map. (Accounts are densely packed at indices `0..num`, so +/// `ceil(num / 2^h)` subtrees cover them all.) +pub fn ledger_sweep_queries(num_accounts: u64, depth: usize) -> Vec { + let subtree_depth = depth - CONTENTS_SUBTREE_HEIGHT; + let batch = 1u64 << CONTENTS_SUBTREE_HEIGHT; + let subtrees = num_accounts.div_ceil(batch); + (0..subtrees) + .map(|s| { + let addr = Address::from_index(AccountIndex(s), subtree_depth); + SyncQuery::WhatContents(addr.into()) + }) + .collect() +} + +/// Parse the answers to [`ledger_sweep_queries`] (same order) into +/// `(public-key address, leaf index)` pairs. Subtree `s`'s batch holds the accounts at +/// leaf indices `s * 2^h + j` for the `j`-th returned account. The mapping is an +/// **untrusted hint** — a wrong pair can't forge a balance, since +/// [`verify_account_at_root`] re-proves the leaf and the caller cross-checks the pubkey. +pub fn pubkey_index_pairs( + answers: &[SyncAnswer], + _depth: usize, +) -> Result, AccountReadError> { + let batch = 1u64 << CONTENTS_SUBTREE_HEIGHT; + let mut pairs = Vec::new(); + for (s, answer) in answers.iter().enumerate() { + match answer { + SyncAnswer::ContentsAre(accounts) => { + for (j, a) in accounts.iter().enumerate() { + let account = Account::try_from(a).map_err(|_| AccountReadError::BadAccount)?; + let index = s as u64 * batch + j as u64; + pairs.push((account.public_key.into_address(), index)); + } + } + _ => return Err(AccountReadError::NotContents), + } + } + Ok(pairs) +} + fn hash_to_fp(h: &LedgerHash) -> Result { h.to_field::().map_err(|_| AccountReadError::BadHash) } @@ -214,7 +264,7 @@ mod tests { // Answer our own queries from a real mina-tree ledger — i.e. act as the peer the // light node would talk to — so the test exercises the exact query plan + sibling // ordering against mina-tree's own Merkle layout, no live network needed. - fn serve(db: &mut Database, query: &SyncQuery) -> SyncAnswer { + fn serve(db: &mut Database, depth: usize, query: &SyncQuery) -> SyncAnswer { match query { SyncQuery::WhatChildHashes(addr) => { let node: Address = addr.into(); @@ -223,10 +273,16 @@ mod tests { let wrap = |fp: Fp| LedgerHash::from(MinaBaseLedgerHash0StableV1(fp.into())); SyncAnswer::ChildHashesAre(wrap(l), wrap(r)) } + // WhatContents at any depth: return the filled accounts under the subtree, in + // leaf-index order (a single account when the address is a full-depth leaf). SyncQuery::WhatContents(addr) => { - let leaf: Address = addr.into(); - let account = db.get(leaf).unwrap(); - SyncAnswer::ContentsAre(List::one((&*account).into())) + let node: Address = addr.into(); + let span = 1u64 << (depth - node.length()); + let base = node.to_index().0 * span; + let accounts: List<_> = (0..span) + .filter_map(|k| db.get_at_index(AccountIndex(base + k)).map(|a| (&*a).into())) + .collect(); + SyncAnswer::ContentsAre(accounts) } SyncQuery::NumAccounts => unreachable!("not part of an account read"), } @@ -260,7 +316,7 @@ mod tests { // Build the query plan, serve it from the same ledger, assemble back. let queries = sync_ledger_queries(index.0, depth); assert_eq!(queries.len(), depth + 1); - let answers: Vec = queries.iter().map(|q| serve(&mut db, q)).collect(); + let answers: Vec = queries.iter().map(|q| serve(&mut db, depth, q)).collect(); let (got_account, got_path) = account_with_path(index.0, depth, &answers).unwrap(); assert_eq!(got_path, want_path, "assembled path must match mina-tree's"); @@ -298,4 +354,35 @@ mod tests { } ); } + + #[test] + fn sweep_reconstructs_every_leaf_index() { + // Build a ledger with more accounts than one What_contents batch (2^5 = 32) so + // the sweep spans multiple subtrees, then check the reconstructed leaf indices + // are exactly 0..n against mina-tree's own indexing. + let depth = 12; + let mut db = Database::create(depth as u8); + let pk = CompressedPubKey::from_address( + "B62qnzbXmRNo9q32n4SNu2mpB8e7FYYLH8NmaX6oFCBYjjQ8SbD7uzV", + ) + .unwrap(); + let n: u64 = 70; // > 2 subtrees of 32 + for token in 1..=n { + let id = AccountId::new(pk.clone(), TokenId::from(token)); + let acct = Account::create_with(id.clone(), Balance::from_u64(token)); + db.get_or_create_account(id, acct).unwrap(); + } + + let queries = ledger_sweep_queries(n, depth); + assert_eq!(queries.len(), (n as usize).div_ceil(32), "one query per 32-account subtree"); + let answers: Vec = queries.iter().map(|q| serve(&mut db, depth, q)).collect(); + + let pairs = pubkey_index_pairs(&answers, depth).unwrap(); + assert_eq!(pairs.len() as u64, n, "every account is swept exactly once"); + let mut indices: Vec = pairs.iter().map(|(_, i)| *i).collect(); + indices.sort_unstable(); + assert_eq!(indices, (0..n).collect::>(), "leaf indices reconstructed exactly"); + let want_addr = pk.into_address(); + assert!(pairs.iter().all(|(addr, _)| addr == &want_addr)); + } } diff --git a/crates/mina-verify/src/lib.rs b/crates/mina-verify/src/lib.rs index 0ada420..82a0bc5 100644 --- a/crates/mina-verify/src/lib.rs +++ b/crates/mina-verify/src/lib.rs @@ -49,8 +49,9 @@ pub use account::{implied_root, ledger_root, verify_account_inclusion}; pub mod account_read; pub use account_read::{ - account_with_path, next_epoch_ledger_hash, staking_epoch_ledger_hash, sync_ledger_queries, - verify_account_at_root, AccountReadError, LEDGER_DEPTH, + account_with_path, ledger_sweep_queries, next_epoch_ledger_hash, pubkey_index_pairs, + staking_epoch_ledger_hash, sync_ledger_queries, verify_account_at_root, AccountReadError, + CONTENTS_SUBTREE_HEIGHT, LEDGER_DEPTH, }; pub mod precomputed; From c9967c408b978770268c5e9878eed25b2c6ffc18 Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 16 Jun 2026 12:14:24 +0200 Subject: [PATCH 4/7] account-read: incremental tail sweep (indices are append-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mina account indices are permanent and append-only (accounts are never deleted or reordered), so a pubkey->index map is monotonic — valid across every ledger snapshot, only growing. Generalize the sweep to a range so a light node sweeps once at cold start, then only the newly-appended tail each epoch (a handful of accounts), not the whole 80k+: - ledger_sweep_queries(start_index, num_accounts, depth) — sweep [start, num); 0 sweeps the whole ledger, larger start sweeps just the tail. - sweep_base_index(start_index) — the subtree-aligned base leaf index to pass back. - pubkey_index_pairs(answers, base_index, depth) — index from the base, not from 0. Test: a tail sweep from index 40 reconstructs indices 32..n (subtree-aligned), matching the full sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify/src/account_read.rs | 59 ++++++++++++++++++-------- crates/mina-verify/src/lib.rs | 4 +- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/mina-verify/src/account_read.rs b/crates/mina-verify/src/account_read.rs index d59acc0..6ece264 100644 --- a/crates/mina-verify/src/account_read.rs +++ b/crates/mina-verify/src/account_read.rs @@ -133,16 +133,24 @@ pub fn next_epoch_ledger_hash(block: &Block) -> LedgerHash { .clone() } -/// Queries to sweep every populated leaf of a `num_accounts`-account ledger of depth -/// `depth`: one `What_contents` per subtree at depth `depth - CONTENTS_SUBTREE_HEIGHT`, -/// in index order. Feed the answers to [`pubkey_index_pairs`] to build a -/// public-key → leaf-index map. (Accounts are densely packed at indices `0..num`, so -/// `ceil(num / 2^h)` subtrees cover them all.) -pub fn ledger_sweep_queries(num_accounts: u64, depth: usize) -> Vec { +/// The leaf index where the queries from [`ledger_sweep_queries`]`(start_index, …)` begin +/// — `start_index` rounded down to its `What_contents` subtree boundary. Pass this as the +/// `base_index` to [`pubkey_index_pairs`]. +pub fn sweep_base_index(start_index: u64) -> u64 { + (start_index >> CONTENTS_SUBTREE_HEIGHT) << CONTENTS_SUBTREE_HEIGHT +} + +/// Queries to sweep the leaves at indices `[start_index, num_accounts)` of a depth-`depth` +/// ledger: one `What_contents` per subtree at depth `depth - CONTENTS_SUBTREE_HEIGHT`, in +/// index order. `start_index = 0` sweeps the whole ledger; a larger `start_index` sweeps +/// only the appended tail (Mina indices are permanent + append-only, so a pubkey→index map +/// is monotonic and only the growth needs re-sweeping). Feed the answers, with +/// [`sweep_base_index(start_index)`](sweep_base_index), to [`pubkey_index_pairs`]. +pub fn ledger_sweep_queries(start_index: u64, num_accounts: u64, depth: usize) -> Vec { let subtree_depth = depth - CONTENTS_SUBTREE_HEIGHT; - let batch = 1u64 << CONTENTS_SUBTREE_HEIGHT; - let subtrees = num_accounts.div_ceil(batch); - (0..subtrees) + let first = start_index >> CONTENTS_SUBTREE_HEIGHT; + let last = num_accounts.div_ceil(1u64 << CONTENTS_SUBTREE_HEIGHT); + (first..last) .map(|s| { let addr = Address::from_index(AccountIndex(s), subtree_depth); SyncQuery::WhatContents(addr.into()) @@ -151,12 +159,15 @@ pub fn ledger_sweep_queries(num_accounts: u64, depth: usize) -> Vec { } /// Parse the answers to [`ledger_sweep_queries`] (same order) into -/// `(public-key address, leaf index)` pairs. Subtree `s`'s batch holds the accounts at -/// leaf indices `s * 2^h + j` for the `j`-th returned account. The mapping is an -/// **untrusted hint** — a wrong pair can't forge a balance, since -/// [`verify_account_at_root`] re-proves the leaf and the caller cross-checks the pubkey. +/// `(public-key address, leaf index)` pairs. `base_index` is the leaf index of the first +/// answer's subtree — [`sweep_base_index`] of the `start_index` you swept from. Subtree +/// `s`'s batch holds the accounts at leaf indices `base_index + s * 2^h + j` for the +/// `j`-th returned account. The mapping is an **untrusted hint** — a wrong pair can't +/// forge a balance, since [`verify_account_at_root`] re-proves the leaf and the caller +/// cross-checks the pubkey. pub fn pubkey_index_pairs( answers: &[SyncAnswer], + base_index: u64, _depth: usize, ) -> Result, AccountReadError> { let batch = 1u64 << CONTENTS_SUBTREE_HEIGHT; @@ -164,10 +175,10 @@ pub fn pubkey_index_pairs( for (s, answer) in answers.iter().enumerate() { match answer { SyncAnswer::ContentsAre(accounts) => { + let subtree_base = base_index + s as u64 * batch; for (j, a) in accounts.iter().enumerate() { let account = Account::try_from(a).map_err(|_| AccountReadError::BadAccount)?; - let index = s as u64 * batch + j as u64; - pairs.push((account.public_key.into_address(), index)); + pairs.push((account.public_key.into_address(), subtree_base + j as u64)); } } _ => return Err(AccountReadError::NotContents), @@ -373,16 +384,28 @@ mod tests { db.get_or_create_account(id, acct).unwrap(); } - let queries = ledger_sweep_queries(n, depth); + // Full sweep from 0: every leaf index 0..n. + let queries = ledger_sweep_queries(0, n, depth); assert_eq!(queries.len(), (n as usize).div_ceil(32), "one query per 32-account subtree"); let answers: Vec = queries.iter().map(|q| serve(&mut db, depth, q)).collect(); - - let pairs = pubkey_index_pairs(&answers, depth).unwrap(); + let pairs = pubkey_index_pairs(&answers, sweep_base_index(0), depth).unwrap(); assert_eq!(pairs.len() as u64, n, "every account is swept exactly once"); let mut indices: Vec = pairs.iter().map(|(_, i)| *i).collect(); indices.sort_unstable(); assert_eq!(indices, (0..n).collect::>(), "leaf indices reconstructed exactly"); let want_addr = pk.into_address(); assert!(pairs.iter().all(|(addr, _)| addr == &want_addr)); + + // Incremental tail sweep from index 40: must reconstruct indices 40..n (rounded + // down to the subtree boundary, index 32), never re-deriving the wrong index. + let start = 40; + let base = sweep_base_index(start); + assert_eq!(base, 32); + let tail_q = ledger_sweep_queries(start, n, depth); + let tail_a: Vec = tail_q.iter().map(|q| serve(&mut db, depth, q)).collect(); + let tail = pubkey_index_pairs(&tail_a, base, depth).unwrap(); + let mut tail_idx: Vec = tail.iter().map(|(_, i)| *i).collect(); + tail_idx.sort_unstable(); + assert_eq!(tail_idx, (base..n).collect::>(), "tail sweep reconstructs indices from the subtree boundary"); } } diff --git a/crates/mina-verify/src/lib.rs b/crates/mina-verify/src/lib.rs index 82a0bc5..4918d25 100644 --- a/crates/mina-verify/src/lib.rs +++ b/crates/mina-verify/src/lib.rs @@ -50,8 +50,8 @@ pub use account::{implied_root, ledger_root, verify_account_inclusion}; pub mod account_read; pub use account_read::{ account_with_path, ledger_sweep_queries, next_epoch_ledger_hash, pubkey_index_pairs, - staking_epoch_ledger_hash, sync_ledger_queries, verify_account_at_root, AccountReadError, - CONTENTS_SUBTREE_HEIGHT, LEDGER_DEPTH, + staking_epoch_ledger_hash, sweep_base_index, sync_ledger_queries, verify_account_at_root, + AccountReadError, CONTENTS_SUBTREE_HEIGHT, LEDGER_DEPTH, }; pub mod precomputed; From e00a6ad2b812f38c6cab27272e18a8cb44c9e4ab Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 16 Jun 2026 19:03:02 +0200 Subject: [PATCH 5/7] capture: add fetch_sync_ledger_answers (on-device account reads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the sync-ledger RPC fetch into mina-verify-capture (the mobile/relay transport) so the mobile bridge can do trustless account reads on-device — fetch the account + Merkle path over libp2p, then verify via mina_verify::verify_account_at_root. Mirrors the mina-relay impl; uses capture's existing RpcConn. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify-capture/src/rpc_net.rs | 70 ++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/mina-verify-capture/src/rpc_net.rs b/crates/mina-verify-capture/src/rpc_net.rs index 6cf1502..abdda3e 100644 --- a/crates/mina-verify-capture/src/rpc_net.rs +++ b/crates/mina-verify-capture/src/rpc_net.rs @@ -19,8 +19,13 @@ use libp2p::swarm::{ use libp2p::{Multiaddr, PeerId}; use void::Void; +use crate::rpc::RpcConn; use crate::transport::ed25519::{Keypair as EdKeypair, SecretKey}; -use mina_p2p_messages::v2::MinaBlockBlockStableV2; +use mina_p2p_messages::rpc::AnswerSyncLedgerQueryV2; +use mina_p2p_messages::v2::{ + LedgerHash, MinaBlockBlockStableV2, MinaLedgerSyncLedgerAnswerStableV2, + MinaLedgerSyncLedgerQueryStableV1, +}; const RPC_PROTOCOL: StreamProtocol = StreamProtocol::new("coda/rpcs/0.0.1"); @@ -183,3 +188,66 @@ pub async fn fetch_best_tip( _ = tokio::time::sleep(deadline) => Err("deadline reached before fetching best tip".into()), } } + +/// Connect to a peer and issue a batch of `answer_sync_ledger_query` queries against one +/// ledger root, returning the answers in query order. The transport for trustless account +/// reads on-device: pair with `mina_verify::sync_ledger_queries` / +/// `mina_verify::verify_account_at_root`. Runs the queries over one persistent [`RpcConn`] +/// while keeping the swarm polled, exactly as [`fetch_best_tip`]. +pub async fn fetch_sync_ledger_answers( + chain_id: &str, + peers: &[&str], + ledger_hash: LedgerHash, + queries: &[MinaLedgerSyncLedgerQueryStableV1], + deadline: Duration, +) -> Result, String> { + let addrs: Vec = peers + .iter() + .map(|s| s.parse().expect("multiaddr")) + .collect(); + let local_key: libp2p::identity::Keypair = EdKeypair::from(SecretKey::generate()).into(); + let pnet_input = format!("/coda/0.0.1/{chain_id}"); + + let mut swarm = crate::transport::swarm( + local_key, + pnet_input.as_bytes(), + Vec::::new(), + addrs.iter().cloned(), + RpcBehaviour::default(), + ); + + let root = ledger_hash.0.clone(); + + let run = async { + let stream = loop { + match swarm.next().await { + Some(SwarmEvent::Behaviour(stream)) => break stream, + _ => {} + } + }; + let walk = async { + let mut conn = RpcConn::open(stream).await?; + let mut answers = Vec::with_capacity(queries.len()); + for query in queries { + let resp = conn + .call::(&(root.clone(), query.clone())) + .await?; + let answer = resp.0.map_err(|e| format!("sync-ledger rpc error: {e:?}"))?; + answers.push(answer); + } + Ok::<_, String>(answers) + }; + let mut walk = std::pin::pin!(walk); + loop { + tokio::select! { + _ = swarm.next() => {} + r = &mut walk => return r, + } + } + }; + + tokio::select! { + r = run => r, + _ = tokio::time::sleep(deadline) => Err("deadline reached before sync-ledger answers".into()), + } +} From 1113d2ec2b7a0191a269e04be78de3179b315903 Mon Sep 17 00:00:00 2001 From: dkijania Date: Wed, 17 Jun 2026 15:51:42 +0200 Subject: [PATCH 6/7] capture: resolve /dns4/ seeds with an explicit resolver (fixes Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libp2p's `.with_dns()` reads the system resolver from /etc/resolv.conf, which does not exist on Android — the libp2p-dns docs note it "fails (panics even!)" there. So /dns4/ seed multiaddrs never resolved and the mobile light client hung forever on "Connecting". Build the DNS transport with an explicit Cloudflare ResolverConfig (via dns::tokio::Transport::custom) instead, so the same path works on every OS. Verified end-to-end on the host (rpc_fetch). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify-capture/src/transport.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/mina-verify-capture/src/transport.rs b/crates/mina-verify-capture/src/transport.rs index 20fbf9e..ef1e55d 100644 --- a/crates/mina-verify-capture/src/transport.rs +++ b/crates/mina-verify-capture/src/transport.rs @@ -160,17 +160,26 @@ where .with_tokio() .with_other_transport(|local_key| { let pnet = pnet::PnetConfig::new(pnet::PreSharedKey::new(pnet_key)); - tcp::tokio::Transport::new(tcp::Config::default().nodelay(true)) + let base = tcp::tokio::Transport::new(tcp::Config::default().nodelay(true)) .and_then(move |socket, _| pnet.handshake(socket)) .upgrade(upgrade::Version::V1) .authenticate(noise::Config::new(local_key).expect("libp2p-noise static keypair")) .multiplex(yamux) .timeout(std::time::Duration::from_secs(20)) - .boxed() + .boxed(); + // Resolve `/dns4/…` seed multiaddrs with an explicit resolver config rather + // than the OS one. libp2p's `.with_dns()` reads `/etc/resolv.conf`, which does + // not exist on Android (libp2p-dns docs note it "fails (panics even!)" there) — + // so on a phone the seeds never resolve and we hang forever on "Connecting". + // Cloudflare's 1.1.1.1 is hard-coded here so the same path works on every OS. + libp2p::dns::tokio::Transport::custom( + base, + libp2p::dns::ResolverConfig::cloudflare(), + libp2p::dns::ResolverOpts::default(), + ) + .boxed() }) .unwrap() - .with_dns() - .unwrap() .with_behaviour(|_| behaviour) .unwrap() .with_swarm_config(|c| c.with_idle_connection_timeout(std::time::Duration::from_secs(300))) From 28e72865f25ca470a733ce7eaed647abc71b5971 Mon Sep 17 00:00:00 2001 From: dkijania Date: Wed, 17 Jun 2026 23:48:15 +0200 Subject: [PATCH 7/7] style: cargo fmt --all (account_read, rpc_net) to satisfy the Format gate Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mina-verify-capture/src/rpc_net.rs | 4 ++- crates/mina-verify/src/account_read.rs | 43 ++++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/mina-verify-capture/src/rpc_net.rs b/crates/mina-verify-capture/src/rpc_net.rs index abdda3e..ebb5545 100644 --- a/crates/mina-verify-capture/src/rpc_net.rs +++ b/crates/mina-verify-capture/src/rpc_net.rs @@ -232,7 +232,9 @@ pub async fn fetch_sync_ledger_answers( let resp = conn .call::(&(root.clone(), query.clone())) .await?; - let answer = resp.0.map_err(|e| format!("sync-ledger rpc error: {e:?}"))?; + let answer = resp + .0 + .map_err(|e| format!("sync-ledger rpc error: {e:?}"))?; answers.push(answer); } Ok::<_, String>(answers) diff --git a/crates/mina-verify/src/account_read.rs b/crates/mina-verify/src/account_read.rs index 6ece264..3597077 100644 --- a/crates/mina-verify/src/account_read.rs +++ b/crates/mina-verify/src/account_read.rs @@ -70,11 +70,18 @@ impl std::fmt::Display for AccountReadError { AccountReadError::NotChildHashes { level } => { write!(f, "answer at level {level} was not ChildHashesAre") } - AccountReadError::NotContents => write!(f, "leaf answer was not a non-empty ContentsAre"), - AccountReadError::BadHash => write!(f, "a sync-ledger hash was not a valid field element"), + AccountReadError::NotContents => { + write!(f, "leaf answer was not a non-empty ContentsAre") + } + AccountReadError::BadHash => { + write!(f, "a sync-ledger hash was not a valid field element") + } AccountReadError::BadAccount => write!(f, "account binprot did not decode"), AccountReadError::NotIncluded => { - write!(f, "account + path do not fold to the block's verified ledger root") + write!( + f, + "account + path do not fold to the block's verified ledger root" + ) } } } @@ -255,7 +262,9 @@ pub fn verify_account_at_root( answers: &[SyncAnswer], ) -> Result { let (account, path) = account_with_path(index, depth, answers)?; - let root_fp = root.to_field::().map_err(|_| AccountReadError::BadHash)?; + let root_fp = root + .to_field::() + .map_err(|_| AccountReadError::BadHash)?; if implied_root(&account, &path) == root_fp { Ok(account) } else { @@ -291,7 +300,10 @@ mod tests { let span = 1u64 << (depth - node.length()); let base = node.to_index().0 * span; let accounts: List<_> = (0..span) - .filter_map(|k| db.get_at_index(AccountIndex(base + k)).map(|a| (&*a).into())) + .filter_map(|k| { + db.get_at_index(AccountIndex(base + k)) + .map(|a| (&*a).into()) + }) .collect(); SyncAnswer::ContentsAre(accounts) } @@ -327,7 +339,8 @@ mod tests { // Build the query plan, serve it from the same ledger, assemble back. let queries = sync_ledger_queries(index.0, depth); assert_eq!(queries.len(), depth + 1); - let answers: Vec = queries.iter().map(|q| serve(&mut db, depth, q)).collect(); + let answers: Vec = + queries.iter().map(|q| serve(&mut db, depth, q)).collect(); let (got_account, got_path) = account_with_path(index.0, depth, &answers).unwrap(); assert_eq!(got_path, want_path, "assembled path must match mina-tree's"); @@ -386,13 +399,21 @@ mod tests { // Full sweep from 0: every leaf index 0..n. let queries = ledger_sweep_queries(0, n, depth); - assert_eq!(queries.len(), (n as usize).div_ceil(32), "one query per 32-account subtree"); + assert_eq!( + queries.len(), + (n as usize).div_ceil(32), + "one query per 32-account subtree" + ); let answers: Vec = queries.iter().map(|q| serve(&mut db, depth, q)).collect(); let pairs = pubkey_index_pairs(&answers, sweep_base_index(0), depth).unwrap(); assert_eq!(pairs.len() as u64, n, "every account is swept exactly once"); let mut indices: Vec = pairs.iter().map(|(_, i)| *i).collect(); indices.sort_unstable(); - assert_eq!(indices, (0..n).collect::>(), "leaf indices reconstructed exactly"); + assert_eq!( + indices, + (0..n).collect::>(), + "leaf indices reconstructed exactly" + ); let want_addr = pk.into_address(); assert!(pairs.iter().all(|(addr, _)| addr == &want_addr)); @@ -406,6 +427,10 @@ mod tests { let tail = pubkey_index_pairs(&tail_a, base, depth).unwrap(); let mut tail_idx: Vec = tail.iter().map(|(_, i)| *i).collect(); tail_idx.sort_unstable(); - assert_eq!(tail_idx, (base..n).collect::>(), "tail sweep reconstructs indices from the subtree boundary"); + assert_eq!( + tail_idx, + (base..n).collect::>(), + "tail sweep reconstructs indices from the subtree boundary" + ); } }