use alloc::string::{String, ToString};
use core::cmp::Ordering;
use core::fmt::Write as _;
use miden_protocol::account::{AccountId, PartialAccount};
use miden_protocol::transaction::{AccountInputs, TransactionScript};
use miden_protocol::vm::MIN_STACK_DEPTH;
use miden_protocol::{Felt, Word};
use miden_standards::code_builder::CodeBuilder;
use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
use super::TransactionRequestError;
use crate::rpc::domain::account::AccountFetch;
pub fn build_fpi_script(
code_builder: CodeBuilder,
foreign_account_id: AccountId,
procedure_root: Word,
args: &[Felt],
) -> Result<TransactionScript, TransactionRequestError> {
if args.len() > MIN_STACK_DEPTH {
return Err(TransactionRequestError::ForeignProcedureInputsTooLong {
max: MIN_STACK_DEPTH,
actual: args.len(),
});
}
let mut script = String::from(
"use miden::protocol::tx\nuse miden::core::sys\n\n@transaction_script\npub proc main\n",
);
let pad_count = MIN_STACK_DEPTH - args.len();
for _ in 0..pad_count / 4 {
script.push_str(" padw\n");
}
for _ in 0..pad_count % 4 {
script.push_str(" push.0\n");
}
for arg in args.iter().rev() {
writeln!(script, " push.{arg}").expect("writing to a string never fails");
}
writeln!(script, " push.{}", procedure_root.to_hex())
.expect("writing to a string never fails");
writeln!(script, " push.{}", foreign_account_id.prefix().as_u64())
.expect("writing to a string never fails");
writeln!(script, " push.{}", foreign_account_id.suffix())
.expect("writing to a string never fails");
script.push_str(" exec.tx::execute_foreign_procedure\n");
script.push_str(" exec.sys::truncate_stack\n");
script.push_str("end\n");
Ok(code_builder.compile_tx_script(&script)?)
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::large_enum_variant)]
pub enum ForeignAccount {
Public(AccountId, AccountFetch),
Private(PartialAccount),
Prefetched(AccountInputs),
}
impl ForeignAccount {
pub fn public(
account_id: AccountId,
fetch: AccountFetch,
) -> Result<Self, TransactionRequestError> {
if !account_id.is_public() {
return Err(TransactionRequestError::InvalidForeignAccountId(account_id));
}
Ok(Self::Public(account_id, fetch))
}
pub fn private(account: impl Into<PartialAccount>) -> Result<Self, TransactionRequestError> {
let partial_account: PartialAccount = account.into();
if partial_account.id().is_public() {
return Err(TransactionRequestError::InvalidForeignAccountId(partial_account.id()));
}
Ok(Self::Private(partial_account))
}
pub fn fetch(&self) -> Option<&AccountFetch> {
match self {
ForeignAccount::Public(_, fetch) => Some(fetch),
ForeignAccount::Private(_) | ForeignAccount::Prefetched(_) => None,
}
}
pub fn account_id(&self) -> AccountId {
match self {
ForeignAccount::Public(account_id, _) => *account_id,
ForeignAccount::Private(partial_account) => partial_account.id(),
ForeignAccount::Prefetched(inputs) => inputs.id(),
}
}
}
impl From<AccountInputs> for ForeignAccount {
fn from(inputs: AccountInputs) -> Self {
Self::Prefetched(inputs)
}
}
impl Ord for ForeignAccount {
fn cmp(&self, other: &Self) -> Ordering {
self.account_id().cmp(&other.account_id())
}
}
impl PartialOrd for ForeignAccount {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Serializable for ForeignAccount {
fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
match self {
ForeignAccount::Public(account_id, fetch) => {
target.write(0u8);
account_id.write_into(target);
fetch.write_into(target);
},
ForeignAccount::Private(partial_account) => {
target.write(1u8);
partial_account.write_into(target);
},
ForeignAccount::Prefetched(inputs) => {
target.write(2u8);
inputs.write_into(target);
},
}
}
}
impl Deserializable for ForeignAccount {
fn read_from<R: miden_tx::utils::serde::ByteReader>(
source: &mut R,
) -> Result<Self, miden_tx::utils::serde::DeserializationError> {
let account_type: u8 = source.read_u8()?;
match account_type {
0 => {
let account_id = AccountId::read_from(source)?;
let fetch = AccountFetch::read_from(source)?;
Ok(ForeignAccount::Public(account_id, fetch))
},
1 => {
let foreign_inputs = PartialAccount::read_from(source)?;
Ok(ForeignAccount::Private(foreign_inputs))
},
2 => Ok(ForeignAccount::Prefetched(AccountInputs::read_from(source)?)),
_ => Err(DeserializationError::InvalidValue("Invalid account type".to_string())),
}
}
}
#[cfg(all(test, feature = "testing"))]
mod foreign_vault_tests {
use alloc::sync::Arc;
use miden_protocol::Word;
use miden_protocol::account::{
Account,
AccountStorageHeader,
PartialAccount,
StorageSlotHeader,
StorageSlotName,
StorageSlotType,
};
use miden_protocol::asset::FungibleAsset;
use miden_testing::{Auth, MockChainBuilder};
use crate::rpc::domain::account::{AccountFetch, GetAccountRequest, VaultFetch};
use crate::rpc::{NodeRpcClient, RpcError};
use crate::test_utils::mock::MockRpcApi;
fn chain_with_funded_account() -> (Account, Arc<dyn NodeRpcClient>) {
let mut builder = MockChainBuilder::new();
let account = builder
.add_existing_wallet_with_assets(Auth::IncrNonce, [FungibleAsset::mock(500)])
.unwrap();
(account, Arc::new(MockRpcApi::new(builder.build().unwrap())))
}
#[tokio::test]
async fn omitted_asset_list_degrades_to_a_root_only_vault() {
let (account, rpc) = chain_with_funded_account();
let committed_root = account.vault().root();
let (_block, proof) = rpc
.get_account(GetAccountRequest::new(account.id()).with_fetch(
AccountFetch::new().with_vault(VaultFetch::IfChangedFrom(committed_root)),
))
.await
.unwrap();
let details = proof.vault_details().expect("public account must carry vault details");
assert!(
details.assets().is_some_and(<[_]>::is_empty),
"the node omits the asset list when the sent root matches"
);
let inputs = proof.into_account_inputs().unwrap();
assert_eq!(inputs.vault().root(), committed_root);
assert!(
inputs.vault().assets().next().is_none(),
"an omitted list must not be kept as an empty vault"
);
}
#[tokio::test]
async fn matching_asset_list_is_kept_as_a_full_vault() {
let (account, rpc) = chain_with_funded_account();
let committed_root = account.vault().root();
let (_block, proof) = rpc
.get_account(
GetAccountRequest::new(account.id())
.with_fetch(AccountFetch::new().with_vault(VaultFetch::Always)),
)
.await
.unwrap();
let inputs = proof.into_account_inputs().unwrap();
assert_eq!(inputs.vault().root(), committed_root);
assert!(
inputs.vault().assets().next().is_some(),
"a verified asset list must be kept in the partial vault"
);
}
#[tokio::test]
async fn account_details_convert_to_a_partial_account() {
let (account, rpc) = chain_with_funded_account();
let (_, proof) = rpc
.get_account(
GetAccountRequest::new(account.id())
.with_fetch(AccountFetch::new().with_vault(VaultFetch::Always)),
)
.await
.unwrap();
let partial = PartialAccount::try_from(proof.into_details().unwrap()).unwrap();
assert_eq!(partial.to_commitment(), account.to_commitment());
assert_eq!(
partial.vault().open(FungibleAsset::mock(500).id()).unwrap(),
account.vault().open(FungibleAsset::mock(500).id())
);
}
#[tokio::test]
async fn account_details_reject_a_mismatched_storage_header() {
let (account, rpc) = chain_with_funded_account();
let (_, proof) = rpc.get_account(GetAccountRequest::new(account.id())).await.unwrap();
let mut details = proof.into_details().unwrap();
details.storage_details.header = AccountStorageHeader::new(vec![StorageSlotHeader::new(
StorageSlotName::new("miden::testing::wrong_storage").unwrap(),
StorageSlotType::Value,
Word::from([1u32, 2, 3, 4]),
)])
.unwrap();
assert!(matches!(PartialAccount::try_from(details), Err(RpcError::InvalidResponse(_))));
}
}
#[cfg(all(test, feature = "testing"))]
mod foreign_storage_map_tests {
use alloc::sync::Arc;
use miden_protocol::Word;
use miden_protocol::account::{
Account,
StorageMap,
StorageMapKey,
StorageSlot,
StorageSlotName,
};
use miden_protocol::transaction::AccountInputs;
use miden_testing::{Auth, MockChainBuilder};
use crate::rpc::NodeRpcClient;
use crate::rpc::domain::account::{
AccountFetch,
AccountStorageRequirements,
GetAccountRequest,
StorageMapEntries,
StorageMapFetch,
};
use crate::test_utils::mock::MockRpcApi;
fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
chain_with_map_account_capped(usize::MAX)
}
fn chain_with_map_account_capped(
oversize_threshold: usize,
) -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
let slot_name = StorageSlotName::new("miden::testing::map").unwrap();
let mut map = StorageMap::new();
for i in 1..=3u32 {
map.insert(StorageMapKey::new(Word::from([i; 4])), Word::from([i * 10; 4]))
.unwrap();
}
let map_root = map.root();
let mut builder = MockChainBuilder::new();
let account = builder
.add_existing_mock_account_with_storage(
Auth::IncrNonce,
[StorageSlot::with_map(slot_name.clone(), map)],
)
.unwrap();
let rpc =
MockRpcApi::new(builder.build().unwrap()).with_oversize_threshold(oversize_threshold);
(account, slot_name, map_root, Arc::new(rpc))
}
async fn inputs_for_keys(
rpc: &Arc<dyn NodeRpcClient>,
account: &Account,
slot_name: &StorageSlotName,
keys: &[StorageMapKey],
) -> AccountInputs {
let requirements = AccountStorageRequirements::new([(slot_name.clone(), keys.iter())]);
let (_block, proof) =
rpc.get_account(GetAccountRequest::new(account.id()).with_fetch(
AccountFetch::new().with_storage(StorageMapFetch::Slots(requirements)),
))
.await
.unwrap();
proof.into_account_inputs().unwrap()
}
#[tokio::test]
async fn matching_map_entries_are_kept_as_a_full_map() {
let (account, slot_name, map_root, rpc) = chain_with_map_account();
let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
let map = inputs
.storage()
.maps()
.next()
.expect("a verified entry list must be kept in the partial storage");
assert_eq!(map.root(), map_root);
}
#[tokio::test]
async fn requested_keys_are_kept_as_a_partial_map() {
let (account, slot_name, map_root, rpc) = chain_with_map_account();
let present_key = StorageMapKey::new(Word::from([2u32; 4]));
let absent_key = StorageMapKey::new(Word::from([99u32; 4]));
let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[present_key, absent_key]).await;
let map = inputs
.storage()
.maps()
.next()
.expect("a partial map must be carried in the partial storage");
assert_eq!(map.root(), map_root, "the partial map must prove the committed slot root");
assert_eq!(map.get(&present_key), Some(Word::from([20u32; 4])));
assert_eq!(
map.get(&absent_key),
Some(Word::empty()),
"a requested key that is absent from the map must be proven absent, not untracked"
);
}
#[tokio::test]
async fn oversize_map_degrades_to_a_root_only_map() {
let (account, slot_name, _map_root, rpc) = chain_with_map_account_capped(1);
let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
assert!(
inputs.storage().maps().next().is_none(),
"an oversize map must not be carried in the partial storage"
);
}
#[tokio::test]
async fn mismatched_map_entries_are_rejected() {
let (account, slot_name, _map_root, rpc) = chain_with_map_account();
let requirements =
AccountStorageRequirements::all_entries(core::slice::from_ref(&slot_name));
let (_block, mut proof) =
rpc.get_account(GetAccountRequest::new(account.id()).with_fetch(
AccountFetch::new().with_storage(StorageMapFetch::Slots(requirements)),
))
.await
.unwrap();
let map_details = &mut proof
.details_mut()
.expect("public account must carry details")
.storage_details
.map_details;
let StorageMapEntries::AllEntries(entries) = &mut map_details[0].entries else {
panic!("the mock returns all entries when none are named");
};
entries.pop();
assert!(matches!(
proof.into_account_inputs(),
Err(crate::rpc::RpcError::InvalidResponse(_))
));
}
}
#[cfg(test)]
mod tests {
use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
use super::*;
#[test]
fn build_fpi_script_rejects_more_args_than_the_input_window() {
let foreign_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
let arg = Felt::new(1).expect("one is a valid field element");
let args = vec![arg; MIN_STACK_DEPTH + 1];
let err = build_fpi_script(CodeBuilder::new(), foreign_id, Word::empty(), &args)
.expect_err("a longer argument list must be rejected");
assert!(matches!(
err,
TransactionRequestError::ForeignProcedureInputsTooLong { max, actual }
if max == MIN_STACK_DEPTH && actual == MIN_STACK_DEPTH + 1
));
}
}