feat: add bulk public account reads

Add ordered batched account lookup across sequencer RPC, wallet, and C FFI. Bound batch size to keep worst-case responses within transport limits and expose matching FFI ownership and error contracts.

Closes #617
This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-14 16:57:26 -03:00
parent ea41d5a738
commit 5362a12cd1
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
13 changed files with 446 additions and 12 deletions

1
Cargo.lock generated
View File

@ -9097,6 +9097,7 @@ version = "0.1.0"
dependencies = [
"jsonrpsee",
"sequencer_service_protocol",
"serde_json",
]
[[package]]

View File

@ -9,6 +9,7 @@ use key_protocol::key_management::KeyChain;
use lee::Data;
use lee_core::account::Nonce;
use log::info;
use sequencer_service_rpc::{ClientError, MAX_ACCOUNTS_PER_REQUEST, RpcClient as _};
use tokio::test;
use wallet::{
account::{AccountIdWithPrivacy, HumanReadableAccount, Label},
@ -38,6 +39,51 @@ async fn get_existing_account() -> Result<()> {
Ok(())
}
#[test]
async fn get_public_accounts_returns_states_in_input_order() -> Result<()> {
let ctx = TestContext::new().await?;
let account_ids = ctx.existing_public_accounts();
let ordered_account_ids = vec![account_ids[1], account_ids[0], account_ids[1]];
let empty_accounts = ctx.sequencer_client().get_accounts(Vec::new()).await?;
let single_account = ctx
.sequencer_client()
.get_accounts(vec![account_ids[0]])
.await?;
let accounts = ctx
.sequencer_client()
.get_accounts(ordered_account_ids)
.await?;
let unknown_account = ctx
.sequencer_client()
.get_accounts(vec![lee::AccountId::new([0; 32])])
.await?;
let oversized_error = ctx
.sequencer_client()
.get_accounts(vec![account_ids[0]; MAX_ACCOUNTS_PER_REQUEST + 1])
.await
.expect_err("oversized account batches must be rejected");
assert!(empty_accounts.is_empty());
assert_eq!(single_account, vec![accounts[1].clone()]);
assert_eq!(accounts.len(), 3);
assert_eq!(accounts[0].balance, 20_000);
assert_eq!(accounts[1].balance, 10_000);
assert_eq!(accounts[2], accounts[0]);
assert_eq!(unknown_account, vec![lee::Account::default()]);
let ClientError::Call(oversized_error) = oversized_error else {
panic!("expected an RPC call error for oversized batch")
};
assert_eq!(oversized_error.code(), -32602);
assert!(
oversized_error
.message()
.contains("Too many accounts requested")
);
Ok(())
}
#[test]
async fn new_public_account_with_label() -> Result<()> {
let mut ctx = TestContext::new().await?;

View File

@ -16,6 +16,7 @@ use std::{
ffi::{CStr, CString, c_char},
io::Write as _,
path::Path,
ptr, slice,
str::FromStr as _,
time::Duration,
};
@ -31,9 +32,9 @@ use log::info;
use tempfile::tempdir;
use wallet::{account::HumanReadableAccount, program_facades::vault::Vault};
use wallet_ffi::{
FfiAccount, FfiAccountIdWithPrivacy, FfiAccountIdentity, FfiAccountList, FfiBytes32,
FfiPrivateAccountKeys, FfiProgramId, FfiPublicAccountKey, FfiTransferResult, FfiU128,
WalletHandle, error,
FfiAccount, FfiAccountDataList, FfiAccountIdWithPrivacy, FfiAccountIdentity, FfiAccountList,
FfiBytes32, FfiPrivateAccountKeys, FfiProgramId, FfiPublicAccountKey, FfiTransferResult,
FfiU128, WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST, WalletHandle, error,
generic_transaction::{FfiProgramWithDependencies, FfiTransactionResult},
label::{AccountIdResolvedFromLabel, LabelAvailability, LabelList},
wallet::FfiCreateWalletOutput,
@ -101,6 +102,13 @@ unsafe extern "C" {
out_account: *mut FfiAccount,
) -> error::WalletFfiError;
fn wallet_ffi_get_accounts_public(
handle: *mut WalletHandle,
account_ids: *const FfiBytes32,
account_ids_len: usize,
out_accounts: *mut FfiAccountDataList,
) -> error::WalletFfiError;
fn wallet_ffi_get_account_private(
handle: *mut WalletHandle,
account_id: *const FfiBytes32,
@ -109,6 +117,8 @@ unsafe extern "C" {
fn wallet_ffi_free_account_data(account: *mut FfiAccount);
fn wallet_ffi_free_accounts_public(accounts: *mut FfiAccountDataList);
fn wallet_ffi_get_public_account_key(
handle: *mut WalletHandle,
account_id: *const FfiBytes32,
@ -650,6 +660,69 @@ fn test_wallet_ffi_get_account_public() -> Result<()> {
Ok(())
}
#[test]
fn test_wallet_ffi_get_accounts_public() -> Result<()> {
let ctx = BlockingTestContext::new()?;
let existing_account_ids = ctx.ctx().existing_public_accounts();
let account_ids = [existing_account_ids[1], existing_account_ids[0]];
let home = tempfile::tempdir()?;
let FfiCreateWalletOutput {
wallet: wallet_ffi_handle,
mnemonic: _,
} = new_wallet_ffi_with_test_context_config(&ctx, home.path())?;
let ffi_account_ids = account_ids.map(FfiBytes32::from);
let mut ffi_accounts = FfiAccountDataList::default();
unsafe {
wallet_ffi_get_accounts_public(
wallet_ffi_handle,
ffi_account_ids.as_ptr(),
ffi_account_ids.len(),
&raw mut ffi_accounts,
)
.unwrap();
}
let accounts = unsafe { slice::from_raw_parts(ffi_accounts.accounts, ffi_accounts.count) };
let [first, second] = accounts else {
panic!("expected two accounts, got {}", accounts.len());
};
assert_eq!(first.balance.data, 20_000_u128.to_le_bytes());
assert_eq!(second.balance.data, 10_000_u128.to_le_bytes());
let mut empty_accounts = FfiAccountDataList::default();
unsafe {
wallet_ffi_get_accounts_public(wallet_ffi_handle, ptr::null(), 0, &raw mut empty_accounts)
.unwrap();
}
assert!(empty_accounts.accounts.is_null());
assert_eq!(empty_accounts.count, 0);
let error = unsafe {
wallet_ffi_get_accounts_public(wallet_ffi_handle, ptr::null(), 1, &raw mut empty_accounts)
};
assert_eq!(error, error::WalletFfiError::NullPointer);
let oversized_ids = vec![FfiBytes32::default(); WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST + 1];
let error = unsafe {
wallet_ffi_get_accounts_public(
wallet_ffi_handle,
oversized_ids.as_ptr(),
oversized_ids.len(),
&raw mut empty_accounts,
)
};
assert_eq!(error, error::WalletFfiError::InvalidArgument);
unsafe {
wallet_ffi_free_accounts_public(&raw mut ffi_accounts);
wallet_ffi_free_accounts_public(&raw mut empty_accounts);
wallet_ffi_destroy(wallet_ffi_handle);
}
Ok(())
}
#[test]
fn test_wallet_ffi_get_account_private() -> Result<()> {
let ctx = BlockingTestContext::new()?;

View File

@ -12,6 +12,9 @@ sequencer_service_protocol.workspace = true
jsonrpsee = { workspace = true, features = ["macros"] }
[dev-dependencies]
serde_json.workspace = true
[features]
client = ["jsonrpsee/client"]
server = ["jsonrpsee/server"]

View File

@ -10,6 +10,13 @@ use sequencer_service_protocol::{
LeeTransaction, MembershipProof, Nonce, ProgramId,
};
/// Maximum number of full accounts returned by one `getAccounts` request.
///
/// A full account may contain 100 KiB of data. JSON encodes each byte as a decimal number, so this
/// limit keeps a worst-case batch below jsonrpsee's default 10 MiB response-body limit, with at
/// least 512 KiB of headroom for the JSON-RPC envelope.
pub const MAX_ACCOUNTS_PER_REQUEST: usize = 24;
#[cfg(all(not(feature = "server"), not(feature = "client")))]
compile_error!("At least one of `server` or `client` features must be enabled.");
@ -76,6 +83,16 @@ pub trait Rpc {
account_ids: Vec<AccountId>,
) -> Result<Vec<Nonce>, ErrorObjectOwned>;
/// Get full account states in input order.
///
/// Returns an invalid-params error when more than [`MAX_ACCOUNTS_PER_REQUEST`] IDs are
/// supplied.
#[method(name = "getAccounts")]
async fn get_accounts(
&self,
account_ids: Vec<AccountId>,
) -> Result<Vec<Account>, ErrorObjectOwned>;
#[method(name = "getProofsAndRoot")]
async fn get_proofs_and_root(
&self,
@ -93,3 +110,36 @@ pub trait Rpc {
// =============================================================================================
}
#[cfg(test)]
mod tests {
use super::*;
const MAX_ACCOUNT_DATA_SIZE: usize = 100 * 1024;
const DEFAULT_MAX_RESPONSE_BODY_SIZE: usize = 10 * 1024 * 1024;
const RESPONSE_BODY_HEADROOM: usize = 512 * 1024;
#[test]
fn maximum_accounts_response_fits_default_response_body_limit() {
let mut account = Account {
program_owner: [u32::MAX; 8],
balance: u128::MAX,
nonce: u128::MAX.into(),
..Account::default()
};
account.data = vec![u8::MAX; MAX_ACCOUNT_DATA_SIZE]
.try_into()
.expect("maximum-size account data should be valid");
let mut encoded = br#"{"jsonrpc":"2.0","result":"#.to_vec();
serde_json::to_writer(&mut encoded, &vec![account; MAX_ACCOUNTS_PER_REQUEST])
.expect("response result should serialize");
encoded.extend_from_slice(br#", "id":18446744073709551615}"#);
assert!(
encoded.len() <= DEFAULT_MAX_RESPONSE_BODY_SIZE - RESPONSE_BODY_HEADROOM,
"maximum batch response is {} bytes",
encoded.len()
);
}
}

View File

@ -15,6 +15,7 @@ use sequencer_service_protocol::{
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType,
MembershipProof, Nonce, ProgramId,
};
use sequencer_service_rpc::MAX_ACCOUNTS_PER_REQUEST;
use tokio::sync::Mutex;
const NOT_FOUND_ERROR_CODE: i32 = -31999;
@ -162,6 +163,28 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
Ok(nonces)
}
async fn get_accounts(
&self,
account_ids: Vec<AccountId>,
) -> Result<Vec<Account>, ErrorObjectOwned> {
if account_ids.len() > MAX_ACCOUNTS_PER_REQUEST {
return Err(ErrorObjectOwned::owned(
ErrorCode::InvalidParams.code(),
format!(
"Too many accounts requested: got {}, maximum is {MAX_ACCOUNTS_PER_REQUEST}",
account_ids.len()
),
None::<()>,
));
}
let sequencer = self.sequencer.lock().await;
Ok(account_ids
.into_iter()
.map(|account_id| sequencer.state().get_account_by_id(account_id))
.collect())
}
async fn get_proofs_and_root(
&self,
commitments: Vec<Commitment>,

View File

@ -28,7 +28,7 @@ include_version = true
no_includes = false
[export]
include = ["Ffi.*", "WalletFfiError", "WalletHandle"]
include = ["Ffi.*", "WalletFfiError", "WalletHandle", "WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST"]
[enum]
rename_variants = "ScreamingSnakeCase"

View File

@ -1,6 +1,6 @@
//! Account management functions.
use std::{ffi::c_char, ptr, str::FromStr as _};
use std::{ffi::c_char, ptr, slice, str::FromStr as _};
use key_protocol::key_management::{key_tree::chain_index::ChainIndex, KeyChain};
use lee::AccountId;
@ -10,11 +10,11 @@ use crate::{
block_on, c_str_to_string,
error::{print_error, WalletFfiError},
types::{
FfiAccount, FfiAccountList, FfiAccountListEntry, FfiBytes32, FfiPrivateAccountKeys,
WalletHandle,
FfiAccount, FfiAccountDataList, FfiAccountList, FfiAccountListEntry, FfiBytes32,
FfiPrivateAccountKeys, WalletHandle,
},
wallet::get_wallet,
FfiU128,
FfiU128, WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST,
};
/// Create a new public account.
@ -414,6 +414,99 @@ pub unsafe extern "C" fn wallet_ffi_get_account_public(
WalletFfiError::Success
}
/// Get full public account data from the network in input order.
///
/// # Parameters
/// - `handle`: Valid wallet handle
/// - `account_ids`: Array of account IDs (32 bytes each); may be null when `account_ids_len` is 0
/// - `account_ids_len`: Number of account IDs; must not exceed
/// `WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST`
/// - `out_accounts`: Output list for account data
///
/// # Returns
/// - `Success` on successful query
/// - `InvalidArgument` when `account_ids_len` exceeds `WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST`
/// - Error code on failure
///
/// # Memory
/// The returned list must be freed with `wallet_ffi_free_accounts_public()`.
///
/// # Safety
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
/// - `account_ids` must point to an array of `account_ids_len` `FfiBytes32` values when the length
/// is non-zero
/// - `out_accounts` must be a valid pointer to an `FfiAccountDataList` struct
#[no_mangle]
pub unsafe extern "C" fn wallet_ffi_get_accounts_public(
handle: *mut WalletHandle,
account_ids: *const FfiBytes32,
account_ids_len: usize,
out_accounts: *mut FfiAccountDataList,
) -> WalletFfiError {
let wrapper = match get_wallet(handle) {
Ok(w) => w,
Err(e) => return e,
};
if out_accounts.is_null() || (account_ids_len > 0 && account_ids.is_null()) {
print_error("Null pointer argument");
return WalletFfiError::NullPointer;
}
if account_ids_len > WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST {
print_error(format!(
"Too many accounts requested: got {account_ids_len}, maximum is \
{WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST}"
));
return WalletFfiError::InvalidArgument;
}
let account_ids = if account_ids_len == 0 {
Vec::new()
} else {
unsafe { slice::from_raw_parts(account_ids, account_ids_len) }
.iter()
.map(|account_id| AccountId::new(account_id.data))
.collect()
};
let wallet = match wrapper.core.lock() {
Ok(w) => w,
Err(e) => {
print_error(format!("Failed to lock wallet: {e}"));
return WalletFfiError::InternalError;
}
};
let accounts = match block_on(wallet.get_accounts_public(account_ids)) {
Ok(accounts) => accounts,
Err(e) => {
print_error(format!("Failed to get accounts: {e}"));
return WalletFfiError::NetworkError;
}
};
let count = accounts.len();
if count == 0 {
unsafe {
*out_accounts = FfiAccountDataList::default();
}
} else {
let accounts = accounts
.into_iter()
.map(FfiAccount::from)
.collect::<Vec<_>>()
.into_boxed_slice();
let accounts = Box::into_raw(accounts).cast::<FfiAccount>();
unsafe {
*out_accounts = FfiAccountDataList { accounts, count };
}
}
WalletFfiError::Success
}
/// Get full private account data from the local storage.
///
/// # Parameters
@ -481,12 +574,42 @@ pub unsafe extern "C" fn wallet_ffi_free_account_data(account: *mut FfiAccount)
}
unsafe {
let account = &*account;
if !account.data.is_null() && account.data_len > 0 {
let slice = std::slice::from_raw_parts_mut(account.data.cast_mut(), account.data_len);
drop(Box::from_raw(std::ptr::from_mut::<[u8]>(slice)));
free_account_data(&mut *account);
}
}
/// Free public accounts returned by `wallet_ffi_get_accounts_public`.
///
/// # Safety
/// The list must be either null or a valid list returned by
/// `wallet_ffi_get_accounts_public`.
#[no_mangle]
pub unsafe extern "C" fn wallet_ffi_free_accounts_public(accounts: *mut FfiAccountDataList) {
if accounts.is_null() {
return;
}
unsafe {
let accounts = &mut *accounts;
if !accounts.accounts.is_null() && accounts.count > 0 {
let list = slice::from_raw_parts_mut(accounts.accounts, accounts.count);
for account in list.iter_mut() {
free_account_data(account);
}
drop(Box::from_raw(std::ptr::from_mut::<[FfiAccount]>(list)));
}
*accounts = FfiAccountDataList::default();
}
}
fn free_account_data(account: &mut FfiAccount) {
if !account.data.is_null() && account.data_len > 0 {
unsafe {
let data = slice::from_raw_parts_mut(account.data.cast_mut(), account.data_len);
drop(Box::from_raw(std::ptr::from_mut::<[u8]>(data)));
}
}
*account = FfiAccount::default();
}
/// Import a public account private key into wallet storage.
@ -653,3 +776,32 @@ pub unsafe extern "C" fn wallet_ffi_import_private_account(
}
}
}
#[cfg(test)]
mod tests {
use lee::{Account, Data};
use super::*;
#[test]
fn free_accounts_public_releases_nested_account_data() {
let expected_data = vec![1, 2, 3, 4];
let account = Account {
data: Data::try_from(expected_data.clone()).expect("account data should be valid"),
..Account::default()
};
let accounts = vec![FfiAccount::from(account)].into_boxed_slice();
let count = accounts.len();
let accounts = Box::into_raw(accounts).cast::<FfiAccount>();
let mut list = FfiAccountDataList { accounts, count };
let data = unsafe { slice::from_raw_parts((*list.accounts).data, expected_data.len()) };
assert_eq!(data, expected_data);
unsafe {
wallet_ffi_free_accounts_public(&raw mut list);
}
assert!(list.accounts.is_null());
assert_eq!(list.count, 0);
}
}

View File

@ -45,6 +45,8 @@ pub enum WalletFfiError {
InvalidKeyValue = 16,
/// Invalid program bytecode.
InvalidBytecode = 17,
/// An argument is outside the supported range.
InvalidArgument = 18,
/// Internal error (catch-all).
InternalError = 99,
}

View File

@ -56,6 +56,14 @@ pub mod types;
pub mod vault;
pub mod wallet;
/// Maximum number of account IDs accepted by `wallet_ffi_get_accounts_public`.
pub const WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST: usize = 24;
const _: () = assert!(
WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST == sequencer_service_rpc::MAX_ACCOUNTS_PER_REQUEST,
"wallet FFI and sequencer RPC account batch limits must match"
);
static TOKIO_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
/// Get a reference to the global runtime.

View File

@ -102,6 +102,22 @@ impl Default for FfiAccount {
}
}
/// List of full account data returned by `wallet_ffi_get_accounts_public`.
#[repr(C)]
pub struct FfiAccountDataList {
pub accounts: *mut FfiAccount,
pub count: usize,
}
impl Default for FfiAccountDataList {
fn default() -> Self {
Self {
accounts: std::ptr::null_mut(),
count: 0,
}
}
}
/// Public keys for a private account (safe to expose).
#[repr(C)]
pub struct FfiPrivateAccountKeys {

View File

@ -31,6 +31,11 @@
#include <stdint.h>
#include <stdlib.h>
/**
* Maximum number of account IDs accepted by `wallet_ffi_get_accounts_public`.
*/
#define WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST 24
/**
* Error codes returned by FFI functions.
*/
@ -107,6 +112,10 @@ typedef enum WalletFfiError {
* Invalid program bytecode.
*/
INVALID_BYTECODE = 17,
/**
* An argument is outside the supported range.
*/
INVALID_ARGUMENT = 18,
/**
* Internal error (catch-all).
*/
@ -219,6 +228,14 @@ typedef struct FfiAccount {
struct FfiU128 nonce;
} FfiAccount;
/**
* List of full account data returned by `wallet_ffi_get_accounts_public`.
*/
typedef struct FfiAccountDataList {
struct FfiAccount *accounts;
uintptr_t count;
} FfiAccountDataList;
/**
* Result of a transfer operation.
*/
@ -488,6 +505,35 @@ enum WalletFfiError wallet_ffi_get_account_public(struct WalletHandle *handle,
const struct FfiBytes32 *account_id,
struct FfiAccount *out_account);
/**
* Get full public account data from the network in input order.
*
* # Parameters
* - `handle`: Valid wallet handle
* - `account_ids`: Array of account IDs (32 bytes each); may be null when `account_ids_len` is 0
* - `account_ids_len`: Number of account IDs; must not exceed
* `WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST`
* - `out_accounts`: Output list for account data
*
* # Returns
* - `Success` on successful query
* - `InvalidArgument` when `account_ids_len` exceeds `WALLET_FFI_MAX_ACCOUNTS_PER_REQUEST`
* - Error code on failure
*
* # Memory
* The returned list must be freed with `wallet_ffi_free_accounts_public()`.
*
* # Safety
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
* - `account_ids` must point to an array of `account_ids_len` `FfiBytes32` values when the length
* is non-zero
* - `out_accounts` must be a valid pointer to an `FfiAccountDataList` struct
*/
enum WalletFfiError wallet_ffi_get_accounts_public(struct WalletHandle *handle,
const struct FfiBytes32 *account_ids,
uintptr_t account_ids_len,
struct FfiAccountDataList *out_accounts);
/**
* Get full private account data from the local storage.
*
@ -521,6 +567,15 @@ enum WalletFfiError wallet_ffi_get_account_private(struct WalletHandle *handle,
*/
void wallet_ffi_free_account_data(struct FfiAccount *account);
/**
* Free public accounts returned by `wallet_ffi_get_accounts_public`.
*
* # Safety
* The list must be either null or a valid list returned by
* `wallet_ffi_get_accounts_public`.
*/
void wallet_ffi_free_accounts_public(struct FfiAccountDataList *accounts);
/**
* Import a public account private key into wallet storage.
*

View File

@ -424,6 +424,11 @@ impl WalletCore {
Ok(self.sequencer_client.get_accounts_nonces(accs).await?)
}
/// Get public accounts from the sequencer in input order.
pub async fn get_accounts_public(&self, account_ids: Vec<AccountId>) -> Result<Vec<Account>> {
Ok(self.sequencer_client.get_accounts(account_ids).await?)
}
pub async fn get_account(&self, account_id: AccountIdWithPrivacy) -> Result<Account> {
match account_id {
AccountIdWithPrivacy::Public(acc_id) => self.get_account_public(acc_id).await,