use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::cmp::Ordering;
use core::fmt::Write as _;
use miden_protocol::account::{
AccountId,
PartialAccount,
PartialStorage,
PartialStorageMap,
StorageMap,
StorageMapKey,
StorageMapWitness,
StorageSlotHeader,
};
use miden_protocol::asset::{AssetVault, PartialVault};
use miden_protocol::crypto::merkle::smt::PartialSmt;
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::{
AccountDetails,
AccountProof,
AccountStorageRequirements,
StorageMapEntries,
};
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, AccountStorageRequirements),
Private(PartialAccount),
Prefetched(AccountInputs),
}
impl ForeignAccount {
pub fn public(
account_id: AccountId,
storage_requirements: AccountStorageRequirements,
) -> Result<Self, TransactionRequestError> {
if !account_id.is_public() {
return Err(TransactionRequestError::InvalidForeignAccountId(account_id));
}
Ok(Self::Public(account_id, storage_requirements))
}
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 storage_slot_requirements(&self) -> AccountStorageRequirements {
match self {
ForeignAccount::Public(_, account_storage_requirements) => {
account_storage_requirements.clone()
},
ForeignAccount::Private(_) | ForeignAccount::Prefetched(_) => {
AccountStorageRequirements::default()
},
}
}
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, storage_requirements) => {
target.write(0u8);
account_id.write_into(target);
storage_requirements.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 storage_requirements = AccountStorageRequirements::read_from(source)?;
Ok(ForeignAccount::Public(account_id, storage_requirements))
},
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())),
}
}
}
pub(crate) fn account_proof_into_inputs(
account_proof: AccountProof,
) -> Result<AccountInputs, TransactionRequestError> {
let (witness, account_details) = account_proof.into_parts();
if let Some(AccountDetails {
header: account_header,
code,
storage_details,
vault_details,
}) = account_details
{
let account_storage_map_details = storage_details.map_details;
let mut storage_map_proofs = Vec::with_capacity(account_storage_map_details.len());
for account_storage_detail in account_storage_map_details {
let partial_storage = match account_storage_detail.entries {
StorageMapEntries::AllEntries(entries) => {
let slot_root = storage_details
.header
.slots()
.find(|slot| *slot.name() == account_storage_detail.slot_name)
.map(StorageSlotHeader::value);
let storage_entries_iter = entries.iter().map(|e| (e.key, e.value));
match StorageMap::with_entries(storage_entries_iter)
.ok()
.filter(|map| Some(map.root()) == slot_root)
{
Some(map) => PartialStorageMap::new_full(map),
None => continue,
}
},
StorageMapEntries::PartialMap { map_keys, partial_smt } => {
partial_map_into_partial_storage(&map_keys, &partial_smt)?
},
StorageMapEntries::LimitExceeded => continue,
};
storage_map_proofs.push(partial_storage);
}
let vault = AssetVault::new(&vault_details.assets)
.ok()
.filter(|vault| vault.root() == account_header.vault_root())
.map_or_else(|| PartialVault::new(account_header.vault_root()), PartialVault::new_full);
return Ok(AccountInputs::new(
PartialAccount::new(
account_header.id(),
account_header.nonce(),
code,
PartialStorage::new(storage_details.header, storage_map_proofs)?,
vault,
None,
)?,
witness,
));
}
Err(TransactionRequestError::ForeignAccountDataMissing)
}
fn partial_map_into_partial_storage(
map_keys: &[StorageMapKey],
partial_smt: &PartialSmt,
) -> Result<PartialStorageMap, TransactionRequestError> {
if map_keys.is_empty() {
return Ok(PartialStorageMap::new(partial_smt.root()));
}
let witnesses = map_keys
.iter()
.map(|key| {
let proof = partial_smt.open(&key.hash().as_word())?;
StorageMapWitness::new(proof, [*key]).map_err(TransactionRequestError::StorageMapError)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(PartialStorageMap::with_witnesses(witnesses)?)
}
#[cfg(all(test, feature = "testing"))]
mod foreign_vault_tests {
use alloc::sync::Arc;
use miden_protocol::account::Account;
use miden_protocol::asset::FungibleAsset;
use miden_testing::{Auth, MockChainBuilder};
use super::account_proof_into_inputs;
use crate::rpc::NodeRpcClient;
use crate::rpc::domain::account::{GetAccountRequest, VaultFetch};
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(
account.id(),
GetAccountRequest::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_empty(),
"the node omits the asset list when the sent root matches"
);
let inputs = account_proof_into_inputs(proof).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(account.id(), GetAccountRequest::new().with_vault(VaultFetch::Always))
.await
.unwrap();
let inputs = account_proof_into_inputs(proof).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"
);
}
}
#[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 super::account_proof_into_inputs;
use crate::rpc::NodeRpcClient;
use crate::rpc::domain::account::{
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(
account.id(),
GetAccountRequest::new().with_storage(StorageMapFetch::Slots(requirements)),
)
.await
.unwrap();
account_proof_into_inputs(proof).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_degrade_to_a_root_only_map() {
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(
account.id(),
GetAccountRequest::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();
let inputs = account_proof_into_inputs(proof).unwrap();
assert!(
inputs.storage().maps().next().is_none(),
"an entry list that disagrees with the slot root must not be carried"
);
}
}
#[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
));
}
}