1use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4use core::cmp::Ordering;
5use core::fmt::Write as _;
6
7use miden_protocol::account::{
8 AccountId,
9 PartialAccount,
10 PartialStorage,
11 PartialStorageMap,
12 StorageMap,
13 StorageMapKey,
14 StorageMapWitness,
15 StorageSlotHeader,
16};
17use miden_protocol::asset::{AssetVault, PartialVault};
18use miden_protocol::crypto::merkle::smt::PartialSmt;
19use miden_protocol::transaction::{AccountInputs, TransactionScript};
20use miden_protocol::vm::MIN_STACK_DEPTH;
21use miden_protocol::{Felt, Word};
22use miden_standards::code_builder::CodeBuilder;
23use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
24
25use super::TransactionRequestError;
26use crate::rpc::domain::account::{
27 AccountDetails,
28 AccountProof,
29 AccountStorageRequirements,
30 StorageMapEntries,
31};
32
33pub fn build_fpi_script(
43 code_builder: CodeBuilder,
44 foreign_account_id: AccountId,
45 procedure_root: Word,
46 args: &[Felt],
47) -> Result<TransactionScript, TransactionRequestError> {
48 if args.len() > MIN_STACK_DEPTH {
49 return Err(TransactionRequestError::ForeignProcedureInputsTooLong {
50 max: MIN_STACK_DEPTH,
51 actual: args.len(),
52 });
53 }
54
55 let mut script = String::from(
56 "use miden::protocol::tx\nuse miden::core::sys\n\n@transaction_script\npub proc main\n",
57 );
58
59 let pad_count = MIN_STACK_DEPTH - args.len();
61 for _ in 0..pad_count / 4 {
62 script.push_str(" padw\n");
63 }
64 for _ in 0..pad_count % 4 {
65 script.push_str(" push.0\n");
66 }
67 for arg in args.iter().rev() {
68 writeln!(script, " push.{arg}").expect("writing to a string never fails");
69 }
70
71 writeln!(script, " push.{}", procedure_root.to_hex())
72 .expect("writing to a string never fails");
73 writeln!(script, " push.{}", foreign_account_id.prefix().as_u64())
74 .expect("writing to a string never fails");
75 writeln!(script, " push.{}", foreign_account_id.suffix())
76 .expect("writing to a string never fails");
77
78 script.push_str(" exec.tx::execute_foreign_procedure\n");
79 script.push_str(" exec.sys::truncate_stack\n");
80 script.push_str("end\n");
81
82 Ok(code_builder.compile_tx_script(&script)?)
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
90#[allow(clippy::large_enum_variant)]
91pub enum ForeignAccount {
92 Public(AccountId, AccountStorageRequirements),
98 Private(PartialAccount),
102}
103
104impl ForeignAccount {
105 pub fn public(
109 account_id: AccountId,
110 storage_requirements: AccountStorageRequirements,
111 ) -> Result<Self, TransactionRequestError> {
112 if !account_id.is_public() {
113 return Err(TransactionRequestError::InvalidForeignAccountId(account_id));
114 }
115
116 Ok(Self::Public(account_id, storage_requirements))
117 }
118
119 pub fn private(account: impl Into<PartialAccount>) -> Result<Self, TransactionRequestError> {
122 let partial_account: PartialAccount = account.into();
123 if partial_account.id().is_public() {
124 return Err(TransactionRequestError::InvalidForeignAccountId(partial_account.id()));
125 }
126
127 Ok(Self::Private(partial_account))
128 }
129
130 pub fn storage_slot_requirements(&self) -> AccountStorageRequirements {
131 match self {
132 ForeignAccount::Public(_, account_storage_requirements) => {
133 account_storage_requirements.clone()
134 },
135 ForeignAccount::Private(_) => AccountStorageRequirements::default(),
136 }
137 }
138
139 pub fn account_id(&self) -> AccountId {
141 match self {
142 ForeignAccount::Public(account_id, _) => *account_id,
143 ForeignAccount::Private(partial_account) => partial_account.id(),
144 }
145 }
146}
147
148impl Ord for ForeignAccount {
149 fn cmp(&self, other: &Self) -> Ordering {
150 self.account_id().cmp(&other.account_id())
151 }
152}
153
154impl PartialOrd for ForeignAccount {
155 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
156 Some(self.cmp(other))
157 }
158}
159
160impl Serializable for ForeignAccount {
161 fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
162 match self {
163 ForeignAccount::Public(account_id, storage_requirements) => {
164 target.write(0u8);
165 account_id.write_into(target);
166 storage_requirements.write_into(target);
167 },
168 ForeignAccount::Private(partial_account) => {
169 target.write(1u8);
170 partial_account.write_into(target);
171 },
172 }
173 }
174}
175
176impl Deserializable for ForeignAccount {
177 fn read_from<R: miden_tx::utils::serde::ByteReader>(
178 source: &mut R,
179 ) -> Result<Self, miden_tx::utils::serde::DeserializationError> {
180 let account_type: u8 = source.read_u8()?;
181 match account_type {
182 0 => {
183 let account_id = AccountId::read_from(source)?;
184 let storage_requirements = AccountStorageRequirements::read_from(source)?;
185 Ok(ForeignAccount::Public(account_id, storage_requirements))
186 },
187 1 => {
188 let foreign_inputs = PartialAccount::read_from(source)?;
189 Ok(ForeignAccount::Private(foreign_inputs))
190 },
191 _ => Err(DeserializationError::InvalidValue("Invalid account type".to_string())),
192 }
193 }
194}
195
196pub(crate) fn account_proof_into_inputs(
198 account_proof: AccountProof,
199) -> Result<AccountInputs, TransactionRequestError> {
200 let (witness, account_details) = account_proof.into_parts();
201
202 if let Some(AccountDetails {
203 header: account_header,
204 code,
205 storage_details,
206 vault_details,
207 }) = account_details
208 {
209 let account_storage_map_details = storage_details.map_details;
211 let mut storage_map_proofs = Vec::with_capacity(account_storage_map_details.len());
212 for account_storage_detail in account_storage_map_details {
213 let partial_storage = match account_storage_detail.entries {
214 StorageMapEntries::AllEntries(entries) => {
215 let slot_root = storage_details
220 .header
221 .slots()
222 .find(|slot| *slot.name() == account_storage_detail.slot_name)
223 .map(StorageSlotHeader::value);
224 let storage_entries_iter = entries.iter().map(|e| (e.key, e.value));
225 match StorageMap::with_entries(storage_entries_iter)
226 .ok()
227 .filter(|map| Some(map.root()) == slot_root)
228 {
229 Some(map) => PartialStorageMap::new_full(map),
230 None => continue,
231 }
232 },
233 StorageMapEntries::PartialMap { map_keys, partial_smt } => {
234 partial_map_into_partial_storage(&map_keys, &partial_smt)?
235 },
236 StorageMapEntries::LimitExceeded => continue,
239 };
240 storage_map_proofs.push(partial_storage);
241 }
242
243 let vault = AssetVault::new(&vault_details.assets)
246 .ok()
247 .filter(|vault| vault.root() == account_header.vault_root())
248 .map_or_else(|| PartialVault::new(account_header.vault_root()), PartialVault::new_full);
249
250 return Ok(AccountInputs::new(
251 PartialAccount::new(
252 account_header.id(),
253 account_header.nonce(),
254 code,
255 PartialStorage::new(storage_details.header, storage_map_proofs)?,
256 vault,
257 None,
258 )?,
259 witness,
260 ));
261 }
262 Err(TransactionRequestError::ForeignAccountDataMissing)
263}
264
265fn partial_map_into_partial_storage(
270 map_keys: &[StorageMapKey],
271 partial_smt: &PartialSmt,
272) -> Result<PartialStorageMap, TransactionRequestError> {
273 if map_keys.is_empty() {
274 return Ok(PartialStorageMap::new(partial_smt.root()));
275 }
276
277 let witnesses = map_keys
278 .iter()
279 .map(|key| {
280 let proof = partial_smt.open(&key.hash().as_word())?;
281 StorageMapWitness::new(proof, [*key]).map_err(TransactionRequestError::StorageMapError)
282 })
283 .collect::<Result<Vec<_>, _>>()?;
284
285 Ok(PartialStorageMap::with_witnesses(witnesses)?)
286}
287
288#[cfg(all(test, feature = "testing"))]
292mod foreign_vault_tests {
293 use alloc::sync::Arc;
294
295 use miden_protocol::account::Account;
296 use miden_protocol::asset::FungibleAsset;
297 use miden_testing::{Auth, MockChainBuilder};
298
299 use super::account_proof_into_inputs;
300 use crate::rpc::NodeRpcClient;
301 use crate::rpc::domain::account::{GetAccountRequest, VaultFetch};
302 use crate::test_utils::mock::MockRpcApi;
303
304 fn chain_with_funded_account() -> (Account, Arc<dyn NodeRpcClient>) {
305 let mut builder = MockChainBuilder::new();
306 let account = builder
307 .add_existing_wallet_with_assets(Auth::IncrNonce, [FungibleAsset::mock(500)])
308 .unwrap();
309 (account, Arc::new(MockRpcApi::new(builder.build().unwrap())))
310 }
311
312 #[tokio::test]
315 async fn omitted_asset_list_degrades_to_a_root_only_vault() {
316 let (account, rpc) = chain_with_funded_account();
317 let committed_root = account.vault().root();
318
319 let (_block, proof) = rpc
320 .get_account(
321 account.id(),
322 GetAccountRequest::new().with_vault(VaultFetch::IfChangedFrom(committed_root)),
323 )
324 .await
325 .unwrap();
326
327 let details = proof.vault_details().expect("public account must carry vault details");
328 assert!(
329 details.assets.is_empty(),
330 "the node omits the asset list when the sent root matches"
331 );
332
333 let inputs = account_proof_into_inputs(proof).unwrap();
334
335 assert_eq!(inputs.vault().root(), committed_root);
336 assert!(
337 inputs.vault().assets().next().is_none(),
338 "an omitted list must not be kept as an empty vault"
339 );
340 }
341
342 #[tokio::test]
344 async fn matching_asset_list_is_kept_as_a_full_vault() {
345 let (account, rpc) = chain_with_funded_account();
346 let committed_root = account.vault().root();
347
348 let (_block, proof) = rpc
349 .get_account(account.id(), GetAccountRequest::new().with_vault(VaultFetch::Always))
350 .await
351 .unwrap();
352
353 let inputs = account_proof_into_inputs(proof).unwrap();
354
355 assert_eq!(inputs.vault().root(), committed_root);
356 assert!(
357 inputs.vault().assets().next().is_some(),
358 "a verified asset list must be kept in the partial vault"
359 );
360 }
361}
362
363#[cfg(all(test, feature = "testing"))]
364mod foreign_storage_map_tests {
365 use alloc::sync::Arc;
366
367 use miden_protocol::Word;
368 use miden_protocol::account::{
369 Account,
370 StorageMap,
371 StorageMapKey,
372 StorageSlot,
373 StorageSlotName,
374 };
375 use miden_protocol::transaction::AccountInputs;
376 use miden_testing::{Auth, MockChainBuilder};
377
378 use super::account_proof_into_inputs;
379 use crate::rpc::NodeRpcClient;
380 use crate::rpc::domain::account::{
381 AccountStorageRequirements,
382 GetAccountRequest,
383 StorageMapEntries,
384 StorageMapFetch,
385 };
386 use crate::test_utils::mock::MockRpcApi;
387
388 fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
391 chain_with_map_account_capped(usize::MAX)
392 }
393
394 fn chain_with_map_account_capped(
397 oversize_threshold: usize,
398 ) -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
399 let slot_name = StorageSlotName::new("miden::testing::map").unwrap();
400 let mut map = StorageMap::new();
401 for i in 1..=3u32 {
402 map.insert(StorageMapKey::new(Word::from([i; 4])), Word::from([i * 10; 4]))
403 .unwrap();
404 }
405 let map_root = map.root();
406
407 let mut builder = MockChainBuilder::new();
408 let account = builder
409 .add_existing_mock_account_with_storage(
410 Auth::IncrNonce,
411 [StorageSlot::with_map(slot_name.clone(), map)],
412 )
413 .unwrap();
414 let rpc =
415 MockRpcApi::new(builder.build().unwrap()).with_oversize_threshold(oversize_threshold);
416 (account, slot_name, map_root, Arc::new(rpc))
417 }
418
419 async fn inputs_for_keys(
421 rpc: &Arc<dyn NodeRpcClient>,
422 account: &Account,
423 slot_name: &StorageSlotName,
424 keys: &[StorageMapKey],
425 ) -> AccountInputs {
426 let requirements = AccountStorageRequirements::new([(slot_name.clone(), keys.iter())]);
427 let (_block, proof) = rpc
428 .get_account(
429 account.id(),
430 GetAccountRequest::new().with_storage(StorageMapFetch::Slots(requirements)),
431 )
432 .await
433 .unwrap();
434
435 account_proof_into_inputs(proof).unwrap()
436 }
437
438 #[tokio::test]
440 async fn matching_map_entries_are_kept_as_a_full_map() {
441 let (account, slot_name, map_root, rpc) = chain_with_map_account();
442
443 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
444
445 let map = inputs
446 .storage()
447 .maps()
448 .next()
449 .expect("a verified entry list must be kept in the partial storage");
450 assert_eq!(map.root(), map_root);
451 }
452
453 #[tokio::test]
456 async fn requested_keys_are_kept_as_a_partial_map() {
457 let (account, slot_name, map_root, rpc) = chain_with_map_account();
458 let present_key = StorageMapKey::new(Word::from([2u32; 4]));
459 let absent_key = StorageMapKey::new(Word::from([99u32; 4]));
460
461 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[present_key, absent_key]).await;
462
463 let map = inputs
464 .storage()
465 .maps()
466 .next()
467 .expect("a partial map must be carried in the partial storage");
468 assert_eq!(map.root(), map_root, "the partial map must prove the committed slot root");
469 assert_eq!(map.get(&present_key), Some(Word::from([20u32; 4])));
470 assert_eq!(
471 map.get(&absent_key),
472 Some(Word::empty()),
473 "a requested key that is absent from the map must be proven absent, not untracked"
474 );
475 }
476
477 #[tokio::test]
481 async fn oversize_map_degrades_to_a_root_only_map() {
482 let (account, slot_name, _map_root, rpc) = chain_with_map_account_capped(1);
483
484 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
485
486 assert!(
487 inputs.storage().maps().next().is_none(),
488 "an oversize map must not be carried in the partial storage"
489 );
490 }
491
492 #[tokio::test]
495 async fn mismatched_map_entries_degrade_to_a_root_only_map() {
496 let (account, slot_name, _map_root, rpc) = chain_with_map_account();
497
498 let requirements =
499 AccountStorageRequirements::all_entries(core::slice::from_ref(&slot_name));
500 let (_block, mut proof) = rpc
501 .get_account(
502 account.id(),
503 GetAccountRequest::new().with_storage(StorageMapFetch::Slots(requirements)),
504 )
505 .await
506 .unwrap();
507
508 let map_details = &mut proof
509 .details_mut()
510 .expect("public account must carry details")
511 .storage_details
512 .map_details;
513 let StorageMapEntries::AllEntries(entries) = &mut map_details[0].entries else {
514 panic!("the mock returns all entries when none are named");
515 };
516 entries.pop();
517
518 let inputs = account_proof_into_inputs(proof).unwrap();
519
520 assert!(
521 inputs.storage().maps().next().is_none(),
522 "an entry list that disagrees with the slot root must not be carried"
523 );
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
530
531 use super::*;
532
533 #[test]
535 fn build_fpi_script_rejects_more_args_than_the_input_window() {
536 let foreign_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
537 let arg = Felt::new(1).expect("one is a valid field element");
538 let args = vec![arg; MIN_STACK_DEPTH + 1];
539
540 let err = build_fpi_script(CodeBuilder::new(), foreign_id, Word::empty(), &args)
541 .expect_err("a longer argument list must be rejected");
542
543 assert!(matches!(
544 err,
545 TransactionRequestError::ForeignProcedureInputsTooLong { max, actual }
546 if max == MIN_STACK_DEPTH && actual == MIN_STACK_DEPTH + 1
547 ));
548 }
549}