Skip to content
Open
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 @@ -28,6 +28,7 @@

### Enhancements

* [FEATURE][rust] Added `ChainAnchor` with `Client::execute_transaction_at` and `Client::chain_anchor_for_request` to capture and execute against a pinned reference block instead of the sync height, so a transaction summary signed at one block — which binds the reference block commitment since protocol 0.16 — can be reproduced and executed later on any client ([#2421](https://github.com/0xMiden/rust-sdk/pull/2421)).
* [FEATURE][rust] A client that only watches a public account now recovers notes the account consumed authenticated, even when it never tracked them by tag. During sync it reads the note references the node attaches to the account's transactions, fetches each note body by id, and surfaces it through `InputNoteReader`. Requires node `0.15.1` ([#2300](https://github.com/0xMiden/rust-sdk/pull/2300)).
* [FEATURE][cli] Added a `--payback-note-type` option to `swap` so the payback note can be created as public or private (defaults to private). Public payback works without any off-band advice now that SWAP derives the payback recipient deterministically ([#2190](https://github.com/0xMiden/rust-sdk/pull/2190)).
* [FEATURE][cli] `init` now also writes the non-fungible faucet, guarded multisig auth and network account auth component packages ([#2356](https://github.com/0xMiden/rust-sdk/pull/2356)).
Expand Down
9 changes: 8 additions & 1 deletion crates/rust-client/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ use crate::note::NoteScreenerError;
use crate::note_transport::NoteTransportError;
use crate::rpc::RpcError;
use crate::store::{NoteRecordError, StoreError};
use crate::transaction::{BatchBuilderError, TransactionRequestError, TransactionStoreUpdateError};
use crate::transaction::{
BatchBuilderError,
ChainAnchorError,
TransactionRequestError,
TransactionStoreUpdateError,
};

// ACTIONABLE HINTS
// ================================================================================================
Expand Down Expand Up @@ -105,6 +110,8 @@ pub enum ClientError {
AccountDataNotFound(AccountId),
#[error(transparent)]
BatchBuilder(#[from] BatchBuilderError),
#[error("chain anchor error")]
ChainAnchorError(#[from] ChainAnchorError),
#[error("data store error")]
DataStoreError(#[from] DataStoreError),
#[error("failed to construct the partial blockchain")]
Expand Down
48 changes: 46 additions & 2 deletions crates/rust-client/src/store/data_store/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use alloc::boxed::Box;
use alloc::collections::BTreeSet;
use alloc::string::ToString;
use alloc::sync::Arc;
use alloc::vec::Vec;

Expand Down Expand Up @@ -38,7 +40,7 @@ use crate::rpc::domain::account::{
};
use crate::rpc::{AccountStateAt, NodeRpcClient};
use crate::store::StoreError;
use crate::transaction::fetch_public_account_inputs;
use crate::transaction::{ChainAnchor, ChainAnchorError, fetch_public_account_inputs};

mod cache;
use cache::DataStoreCache;
Expand All @@ -54,6 +56,10 @@ pub struct ClientDataStore {
cache: DataStoreCache,
/// RPC client used to lazy-load foreign account data on cache miss.
rpc_api: Arc<dyn NodeRpcClient>,
/// When set, chain data (reference block header and partial blockchain) is served from this
/// anchor instead of being rebuilt at the store's sync height. Boxed to keep the data store
/// small: it is held inline by every execution future.
anchor: Option<Box<ChainAnchor>>,
}

impl ClientDataStore {
Expand All @@ -62,9 +68,23 @@ impl ClientDataStore {
store,
cache: DataStoreCache::new(),
rpc_api,
anchor: None,
}
}

/// Serves chain data from the provided [`ChainAnchor`] instead of rebuilding it at the
/// store's sync height, pinning execution to the anchor's reference block.
///
/// The store's account data is still used as-is: only the reference block header and the
/// partial blockchain come from the anchor. Any authenticated input note must have been
/// created in a block tracked by the anchor's partial blockchain, otherwise
/// `get_transaction_inputs` fails.
#[must_use]
pub fn with_chain_anchor(mut self, anchor: ChainAnchor) -> Self {
self.anchor = Some(Box::new(anchor));
self
}

/// Enables memoization of `get_transaction_inputs` and `get_vault_asset_witnesses` for the
/// lifetime of this data store.
///
Expand Down Expand Up @@ -267,7 +287,31 @@ impl DataStore for ClientDataStore {
partial_account
};

let (block_header, partial_blockchain) = if let Some((block_header, partial_blockchain)) =
let (block_header, partial_blockchain) = if let Some(anchor) = &self.anchor {
// Anchored execution: serve the pinned chain data instead of rebuilding it at the
// sync height. The executor derives the reference block from the request, so it must
// match the anchor; every other block in the set (input note creation blocks) must
// already be tracked by the anchor's partial blockchain.
if ref_block != anchor.block_num() {
return Err(DataStoreError::other(

@Dominik1999 Dominik1999 Aug 20, 2026

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.

Both anchor errors here are flattened to strings via DataStoreError::other(err.to_string()), and the executor then wraps them as FetchTransactionInputsFailed. So the caller of execute_transaction_at always sees ClientError::TransactionExecutorError, never the ClientError::ChainAnchorError its rustdoc promises for the untracked-note case. A multisig caller that wants to react to BlockNotTracked (recapture a wider anchor) can only substring-match, which breaks the first time the wording changes.

Suggest preserving the typed error through DataStoreError (or, at minimum, fixing the execute_transaction_at error docs to say these surface as TransactionExecutorError).

ChainAnchorError::ReferenceBlockMismatch {
requested: ref_block,
anchor: anchor.block_num(),
}
.to_string(),
));
}

for block_num in block_refs.iter().filter(|block_num| **block_num != ref_block) {
if !anchor.partial_blockchain().contains_block(*block_num) {
return Err(DataStoreError::other(
ChainAnchorError::BlockNotTracked { block_num: *block_num }.to_string(),
));
}
}

(anchor.header().clone(), anchor.partial_blockchain().clone())
} else if let Some((block_header, partial_blockchain)) =
self.cache.get_blockchain(&block_refs)
{
(block_header, partial_blockchain)
Expand Down
2 changes: 1 addition & 1 deletion crates/rust-client/src/transaction/batch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ where
};

let prep = client
.prepare_transaction(account.code_interface(), transaction_request)
.prepare_transaction(account.code_interface(), transaction_request, None)
.await?;

data_store.register_note_scripts(prep.output_note_scripts());
Expand Down
142 changes: 142 additions & 0 deletions crates/rust-client/src/transaction/chain_anchor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use alloc::string::ToString;

use miden_protocol::Word;
use miden_protocol::block::{BlockHeader, BlockNumber};
use miden_protocol::transaction::PartialBlockchain;
use miden_tx::utils::serde::{
ByteReader,
ByteWriter,
Deserializable,
DeserializationError,
Serializable,
};
use thiserror::Error;

// CHAIN ANCHOR
// ================================================================================================

/// A self-contained, verifiable anchor for executing a transaction against a specific reference
/// block instead of the client's current sync height.
///
/// The anchor bundles the reference [`BlockHeader`] with a [`PartialBlockchain`] consistent with
/// it — exactly the chain data `TransactionInputs` requires: `chain_length()` equals the header's
/// block number and the peaks hash to the header's chain commitment. Both invariants are enforced
/// on construction (including deserialization), so an anchor received from an untrusted party only
/// needs its [`Self::block_commitment`] checked against an independently trusted value — e.g. the
/// `BLOCK_COMMITMENT` word bound into a signed [`TransactionSummary`] — to be safe to execute
/// against.
///
/// Since protocol 0.16 the signed transaction summary binds the reference block commitment, so a
/// summary produced at one block cannot be reproduced by re-executing at another. Flows that
/// collect signatures over a summary and execute later (e.g. multisig) capture an anchor at the
/// block the summary was built at ([`crate::Client::chain_anchor_for_request`]), ship it with the
/// signed data, and replay the transaction with [`crate::Client::execute_transaction_at`] so the
/// summary — and with it the signature advice keys — reproduces exactly.
///
/// When the transaction consumes authenticated notes, the anchor's [`PartialBlockchain`] must
/// track each note's creation block; [`crate::Client::chain_anchor_for_request`] captures an
/// anchor tracking the blocks of a request's authenticated input notes.
///
/// [`TransactionSummary`]: miden_protocol::transaction::TransactionSummary
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainAnchor {
header: BlockHeader,
chain: PartialBlockchain,
}

impl ChainAnchor {
/// Returns a new anchor after validating that `chain` is consistent with `header`.
///
/// # Errors
///
/// - The partial blockchain's length does not match the header's block number.
/// - The partial blockchain's peaks do not hash to the header's chain commitment.
pub fn new(header: BlockHeader, chain: PartialBlockchain) -> Result<Self, ChainAnchorError> {
if chain.chain_length() != header.block_num() {
return Err(ChainAnchorError::ChainLengthMismatch {
chain_length: chain.chain_length(),
block_num: header.block_num(),
});
}

if chain.peaks().hash_peaks() != header.chain_commitment() {
return Err(ChainAnchorError::ChainCommitmentMismatch {
block_num: header.block_num(),
});
}

Ok(Self { header, chain })
}

/// Returns the number of the anchored reference block.
pub fn block_num(&self) -> BlockNumber {
self.header.block_num()
}

/// Returns the commitment of the anchored reference block.
///
/// Callers holding an anchor from an untrusted source should compare this against an
/// independently trusted commitment (e.g. the block commitment bound into a signed
/// transaction summary) before executing with the anchor.
pub fn block_commitment(&self) -> Word {
self.header.commitment()
}

/// Returns the anchored reference block header.
pub fn header(&self) -> &BlockHeader {
&self.header
}

/// Returns the partial blockchain at the anchored reference block.
pub fn partial_blockchain(&self) -> &PartialBlockchain {
&self.chain
}

/// Consumes the anchor and returns its parts.
pub fn into_parts(self) -> (BlockHeader, PartialBlockchain) {
(self.header, self.chain)
}
}

impl Serializable for ChainAnchor {
fn write_into<W: ByteWriter>(&self, target: &mut W) {
self.header.write_into(target);
self.chain.write_into(target);
}
}

impl Deserializable for ChainAnchor {
fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
let header = BlockHeader::read_from(source)?;
let chain = PartialBlockchain::read_from(source)?;

Self::new(header, chain).map_err(|err| DeserializationError::InvalidValue(err.to_string()))
}
}

// CHAIN ANCHOR ERROR
// ================================================================================================

#[derive(Debug, Error)]
pub enum ChainAnchorError {
#[error(
"partial blockchain length {chain_length} does not match the anchor block number {block_num}"
)]
ChainLengthMismatch {
chain_length: BlockNumber,
block_num: BlockNumber,
},
#[error(
"partial blockchain peaks do not hash to the chain commitment of anchor block {block_num}"
)]
ChainCommitmentMismatch { block_num: BlockNumber },
#[error(
"block {block_num} is not tracked by the anchor's partial blockchain; capture the anchor with the blocks of all authenticated input notes"
)]
BlockNotTracked { block_num: BlockNumber },
#[error("transaction reference block {requested} does not match the anchor block {anchor}")]
ReferenceBlockMismatch {
requested: BlockNumber,
anchor: BlockNumber,
},
}
Loading
Loading