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
4 changes: 4 additions & 0 deletions .changes/unreleased/zebra-rpc-breaking-20260818-000002.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: zebra-rpc
kind: breaking
body: '`getaddressutxos` now accepts optional `startHeight` and `maxEntries` fields and bounds the state query by them. The JSON-RPC had no such fields before and silently dropped them, so a client that already sends them now receives fewer entries than before for the same request. The `GetAddressUtxos` gRPC method already accepted both, but applied them to the finished reply after the node had read every UTXO held by the named addresses; it now passes them through, so a request naming one address with a large UTXO set no longer costs the same whether it asks for one entry or all of them ([#11239](https://github.com/ZcashFoundation/zebra/issues/11239)).'
time: 2026-08-18T00:00:02.000000000Z
4 changes: 4 additions & 0 deletions .changes/unreleased/zebra-state-breaking-20260818-000001.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: zebra-state
kind: breaking
body: '`ReadRequest::UtxosByAddresses` is now a struct variant, taking a `height_range` and an optional `max_entries` alongside the addresses. Both bound the index scan, so the work the query does is set by what the caller asks for rather than by the size of the addresses'' UTXO sets ([#11239](https://github.com/ZcashFoundation/zebra/issues/11239)).'
time: 2026-08-18T00:00:01.000000000Z
2 changes: 1 addition & 1 deletion zebra-fuzz/fuzz/fuzz_targets/jsonrpsee_envelope_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ fn build_envelope(data: &[u8]) -> Option<String> {
/// z_listunifiedreceivers(address: String)
/// getaddressbalance(req: GetAddressBalanceRequest) // {"addresses":[...]}
/// getaddresstxids(req: GetAddressTxIdsRequest) // {"addresses":[...], "start":N, "end":N}
/// getaddressutxos(req: GetAddressUtxosRequest) // {"addresses":[...]} OR {"chainInfo": false, "addresses":[...]}
/// getaddressutxos(req: GetAddressUtxosRequest) // {"addresses":[...]} OR {"chainInfo": false, "addresses":[...], "startHeight": 0, "maxEntries": 0}
/// z_gettreestate(hash_or_height: String)
/// z_getsubtreesbyindex(pool: String, start_index: u16, limit: Option<u16>)
/// invalidateblock(block_hash: String)
Expand Down
15 changes: 10 additions & 5 deletions zebra-fuzz/fuzz/fuzz_targets/rpc_handler_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,10 @@ enum RpcCall {
/// raw fields constructor.
GetAddressTxIds(Vec<String>, Option<u32>, Option<u32>),
/// `getaddressutxos(GetAddressUtxosRequest)` — methods.rs:2093. Two-
/// form DTO + `chain_info` bool. Same family as the two above.
GetAddressUtxos(Vec<String>, bool),
/// form DTO + `chain_info` bool, and the `start_height` / `max_entries`
/// limits, which are pushed into the state index scan. Same family as
/// the two above.
GetAddressUtxos(Vec<String>, bool, u64, u32),

// ── Tier-B · treestate (hash_or_height + pool name) ────────────────
/// `z_gettreestate(hash_or_height)` — methods.rs:1874. Same
Expand Down Expand Up @@ -411,7 +413,7 @@ impl Service<zebra_state::ReadRequest> for MockReadState {
R::TransactionIdsByAddresses { .. } => {
Ok(Resp::AddressesTransactionIds(Default::default()))
}
R::UtxosByAddresses(_) => {
R::UtxosByAddresses { .. } => {
// AddressUtxos requires an `AddressUtxos` struct; the field is
// private to zebra-state so we cannot construct it without a
// public constructor. Fall back to Err for this variant.
Expand Down Expand Up @@ -762,8 +764,11 @@ async fn dispatch(rpc: &FuzzRpcImpl, call: RpcCall) {
.catch_unwind()
.await;
}
RpcCall::GetAddressUtxos(addrs, chain_info) => {
let req = GetAddressUtxosRequest::new(addrs, chain_info);
RpcCall::GetAddressUtxos(addrs, chain_info, start_height, max_entries) => {
// `start_height` is passed through unclamped so the fuzzer can reach the
// heights above `Height::MAX` that the RPC has to clamp rather than reject.
let req =
GetAddressUtxosRequest::new(addrs, chain_info).with_limits(start_height, max_entries);
let _ = AssertUnwindSafe(rpc.get_address_utxos(req))
.catch_unwind()
.await;
Expand Down
21 changes: 9 additions & 12 deletions zebra-rpc/src/lightwalletd/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,34 +955,31 @@ async fn address_balance<Rpc: RpcMethods>(
})
}

/// Fetches the UTXOs for a list of transparent addresses, filtered by the given
/// start height and maximum number of entries.
/// Fetches the UTXOs for a list of transparent addresses, at or above the given start height,
/// at most `max_entries` of them.
///
/// Both limits are passed to the state query, so they bound the work the node does rather than
/// just the size of the reply.
async fn address_utxos<Rpc: RpcMethods>(
rpc: &Rpc,
args: GetAddressUtxosArg,
) -> Result<Vec<GetAddressUtxosReply>, Status> {
check_address_count(&args.addresses)?;

let response = rpc
.get_address_utxos(GetAddressUtxosRequest::new(args.addresses, false))
.get_address_utxos(
GetAddressUtxosRequest::new(args.addresses, false)
.with_limits(args.start_height, args.max_entries),
)
.await
.map_err(rpc_arg_error_to_status)?;

let GetAddressUtxosResponse::Utxos(utxos) = response else {
unreachable!("chain info is never requested");
};

let max_entries = if args.max_entries == 0 {
usize::MAX
} else {
// Cast is safe: `usize` is at least 32 bits on all supported platforms.
args.max_entries as usize
};

Ok(utxos
.iter()
.filter(|utxo| u64::from(utxo.height().0) >= args.start_height)
.take(max_entries)
.map(|utxo| GetAddressUtxosReply {
address: utxo.address().to_string(),
txid: utxo.txid().0.to_vec(),
Expand Down
90 changes: 75 additions & 15 deletions zebra-rpc/src/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,16 @@ pub trait Rpc {
/// - An object with the following named fields:
/// - `addresses`: (array, required, example=[\"tmYXBYJj1K7vhejSec5osXK2QsGa5MTisUQ\"]) The addresses to get outputs from.
/// - `chaininfo`: (boolean, optional, default=false) Include chain info with results
/// - `startHeight`: (numeric, optional, default=0) Only return outputs at or above this height
/// - `maxEntries`: (numeric, optional, default=0) Return at most this many outputs, zero for no limit
///
/// # Notes
///
/// `startHeight` and `maxEntries` bound the index scan, not just the response, so a client
/// that pages through a large address does not make the node read the whole UTXO set each
/// time. Callers must set both or neither: `maxEntries` alone would truncate the outputs
/// before the height filter ran.
///
/// lightwalletd always uses the multi-address request, without chaininfo:
/// <https://github.com/zcash/lightwalletd/blob/master/frontend/service.go#L402>
#[method(name = "getaddressutxos")]
Expand Down Expand Up @@ -2295,8 +2302,23 @@ where

let valid_addresses = utxos_request.valid_addresses()?;

// Clamp rather than error: a start height above the chain selects nothing, which is
// already what the request means.
let start_height = Height(
u32::try_from(utxos_request.start_height)
.unwrap_or(u32::MAX)
.min(Height::MAX.0),
);
let max_entries = (utxos_request.max_entries > 0)
// Cast is safe: `usize` is at least 32 bits on all supported platforms.
.then_some(utxos_request.max_entries as usize);

// get utxos data for addresses
let request = zebra_state::ReadRequest::UtxosByAddresses(valid_addresses);
let request = zebra_state::ReadRequest::UtxosByAddresses {
addresses: valid_addresses,
height_range: start_height..=Height::MAX,
max_entries,
};
let response = read_state
.ready()
.and_then(|service| service.call(request))
Expand Down Expand Up @@ -3864,32 +3886,62 @@ pub use self::GetAddressBalanceResponse as AddressBalance;

/// Parameters of [`RpcServer::get_address_utxos`] RPC method.
#[derive(
Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, Getters, new, JsonSchema,
Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, Getters, JsonSchema,
)]
#[serde(from = "DGetAddressUtxosRequest")]
pub struct GetAddressUtxosRequest {
/// A list of addresses to get transactions from.
/// A list of addresses to get unspent outputs from.
addresses: Vec<String>,
/// The height to start looking for transactions.
/// Whether to return chain info along with the unspent outputs.
#[serde(default)]
#[serde(rename = "chainInfo")]
chain_info: bool,
/// The height to start looking for unspent outputs, or zero for the whole chain.
#[serde(default)]
#[serde(rename = "startHeight")]
start_height: u64,
/// The maximum number of unspent outputs to return, or zero for no limit.
#[serde(default)]
#[serde(rename = "maxEntries")]
max_entries: u32,
}

impl GetAddressUtxosRequest {
/// Creates a new request for every unspent output held by `addresses`.
pub fn new(addresses: Vec<String>, chain_info: bool) -> GetAddressUtxosRequest {
GetAddressUtxosRequest {
addresses,
chain_info,
start_height: 0,
max_entries: 0,
}
}

/// Limits this request to the unspent outputs at or above `start_height`, and to at most
/// `max_entries` of them. Zero means "from the genesis block" and "no limit".
///
/// Both limits are taken together, because applying `max_entries` on its own would
/// truncate the outputs before the height filter ran.
pub fn with_limits(self, start_height: u64, max_entries: u32) -> GetAddressUtxosRequest {
GetAddressUtxosRequest {
start_height,
max_entries,
..self
}
}
}

impl From<DGetAddressUtxosRequest> for GetAddressUtxosRequest {
fn from(request: DGetAddressUtxosRequest) -> Self {
match request {
DGetAddressUtxosRequest::Single(addr) => GetAddressUtxosRequest {
addresses: vec![addr],
chain_info: false,
},
DGetAddressUtxosRequest::Single(addr) => GetAddressUtxosRequest::new(vec![addr], false),
DGetAddressUtxosRequest::Object {
addresses,
chain_info,
} => GetAddressUtxosRequest {
addresses,
chain_info,
},
start_height,
max_entries,
} => GetAddressUtxosRequest::new(addresses, chain_info)
.with_limits(start_height, max_entries),
}
}
}
Expand All @@ -3900,14 +3952,22 @@ impl From<DGetAddressUtxosRequest> for GetAddressUtxosRequest {
enum DGetAddressUtxosRequest {
/// A single address string.
Single(String),
/// A full request object with address list and chainInfo flag.
/// A full request object with an address list, a chainInfo flag, and optional limits.
Object {
/// A list of addresses to get transactions from.
/// A list of addresses to get unspent outputs from.
addresses: Vec<String>,
/// The height to start looking for transactions.
/// Whether to return chain info along with the unspent outputs.
#[serde(default)]
#[serde(rename = "chainInfo")]
chain_info: bool,
/// The height to start looking for unspent outputs, or zero for the whole chain.
#[serde(default)]
#[serde(rename = "startHeight")]
start_height: u64,
/// The maximum number of unspent outputs to return, or zero for no limit.
#[serde(default)]
#[serde(rename = "maxEntries")]
max_entries: u32,
},
}

Expand Down
89 changes: 89 additions & 0 deletions zebra-rpc/src/methods/tests/vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,95 @@ async fn rpc_getaddressutxos_response() {
mempool.expect_no_requests().await;
}

/// Checks that `startHeight` and `maxEntries` select the same UTXOs that filtering the full
/// response would have, now that the state applies them to the index scan instead.
#[tokio::test(flavor = "multi_thread")]
async fn rpc_getaddressutxos_limits() {
let _init_guard = zebra_test::init();

let blocks: Vec<Arc<Block>> = zebra_test::vectors::CONTINUOUS_MAINNET_BLOCKS
.values()
.map(|block_bytes| block_bytes.zcash_deserialize_into().unwrap())
.collect();

// The address that receives the second output of every coinbase transaction,
// which is always `t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd`.
let address = &blocks[1].transactions[0].outputs()[1]
.address(&Mainnet)
.unwrap();

let mut mempool: MockService<_, _, _, BoxError> = MockService::build().for_unit_tests();
let (state, read_state, tip, _) = zebra_state::populated_state(blocks.clone(), &Mainnet).await;

let (_tx, rx) = tokio::sync::watch::channel(None);
let (rpc, _) = RpcImpl::new(
Mainnet,
Default::default(),
Default::default(),
"0.0.1",
"RPC test",
Buffer::new(mempool.clone(), 1),
state.clone(),
Buffer::new(read_state.clone(), 1),
MockService::build().for_unit_tests(),
MockSyncStatus::default(),
tip,
MockAddressBookPeers::default(),
rx,
None,
);

let addresses = vec![address.to_string()];

macro_rules! utxos {
($start_height:expr, $max_entries:expr) => {{
let response = rpc
.get_address_utxos(
GetAddressUtxosRequest::new(addresses.clone(), false)
.with_limits($start_height, $max_entries),
)
.await
.expect("address is valid so no error can happen here");

let GetAddressUtxosResponse::Utxos(utxos) = response else {
panic!("expected GetAddressUtxosResponse::ChainInfoFalse variant");
};

utxos
}};
}

// Unset limits return everything, exactly as they did before the limits existed.
let all = utxos!(0, 0);
assert_eq!(all.len(), 10);

// A limit takes the first entries in chain order, not an arbitrary subset.
assert_eq!(utxos!(0, 3), all[..3].to_vec());

// A start height drops the entries below it, and keeps the rest in the same order.
let start_height = all[5].height().0;
let from_start_height: Vec<_> = all
.iter()
.filter(|utxo| utxo.height().0 >= start_height)
.cloned()
.collect();
assert_eq!(utxos!(u64::from(start_height), 0), from_start_height);

// Together, they take the first entries at or above the start height. A backend that
// truncated before filtering by height would return the entries below it instead.
assert_eq!(
utxos!(u64::from(start_height), 2),
from_start_height[..2].to_vec()
);

// A start height past the tip selects nothing, and one past the maximum block height is
// clamped rather than rejected.
assert!(utxos!(u64::from(u32::MAX), 0).is_empty());
assert!(utxos!(u64::from(u32::MAX) + 1, 0).is_empty());

mempool.expect_no_requests().await;
}

#[tokio::test(flavor = "multi_thread")]
async fn rpc_getblockcount() {
let _init_guard = zebra_test::init();
Expand Down
18 changes: 15 additions & 3 deletions zebra-state/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1423,10 +1423,22 @@ pub enum ReadRequest {
#[cfg(feature = "indexer")]
SpendingTransactionId(Spend),

/// Looks up utxos for the provided addresses.
/// Looks up utxos for the provided addresses, in the provided height range,
/// returning at most `max_entries` of them.
///
/// Returns a type with found utxos and transaction information.
UtxosByAddresses(HashSet<transparent::Address>),
UtxosByAddresses {
/// The addresses to look up utxos for.
addresses: HashSet<transparent::Address>,

/// The blocks to be queried for utxos.
height_range: RangeInclusive<block::Height>,

/// The maximum number of utxos to return, or `None` for no limit.
///
/// The limit bounds the index scan, not just the response.
max_entries: Option<usize>,
},

/// Contextually validates anchors and nullifiers of a transaction on the best chain
///
Expand Down Expand Up @@ -1525,7 +1537,7 @@ impl ReadRequest {
ReadRequest::IronwoodSubtrees { .. } => "ironwood_subtrees",
ReadRequest::AddressBalance { .. } => "address_balance",
ReadRequest::TransactionIdsByAddresses { .. } => "transaction_ids_by_addresses",
ReadRequest::UtxosByAddresses(_) => "utxos_by_addresses",
ReadRequest::UtxosByAddresses { .. } => "utxos_by_addresses",
ReadRequest::CheckBestChainTipNullifiersAndAnchors(_) => {
"best_chain_tip_nullifiers_anchors"
}
Expand Down
8 changes: 7 additions & 1 deletion zebra-state/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1691,11 +1691,17 @@ impl Service<ReadRequest> for ReadStateService {
.map(ReadResponse::AddressesTransactionIds),

// For the get_address_utxos RPC.
ReadRequest::UtxosByAddresses(addresses) => read::address_utxos(
ReadRequest::UtxosByAddresses {
addresses,
height_range,
max_entries,
} => read::address_utxos(
&state.network,
state.latest_best_chain(),
&state.db,
addresses,
height_range,
max_entries,
)
.map(ReadResponse::AddressUtxos),

Expand Down
Loading