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 Prefetched(AccountInputs),
111}
112
113impl ForeignAccount {
114 pub fn public(
118 account_id: AccountId,
119 storage_requirements: AccountStorageRequirements,
120 ) -> Result<Self, TransactionRequestError> {
121 if !account_id.is_public() {
122 return Err(TransactionRequestError::InvalidForeignAccountId(account_id));
123 }
124
125 Ok(Self::Public(account_id, storage_requirements))
126 }
127
128 pub fn private(account: impl Into<PartialAccount>) -> Result<Self, TransactionRequestError> {
131 let partial_account: PartialAccount = account.into();
132 if partial_account.id().is_public() {
133 return Err(TransactionRequestError::InvalidForeignAccountId(partial_account.id()));
134 }
135
136 Ok(Self::Private(partial_account))
137 }
138
139 pub fn storage_slot_requirements(&self) -> AccountStorageRequirements {
140 match self {
141 ForeignAccount::Public(_, account_storage_requirements) => {
142 account_storage_requirements.clone()
143 },
144 ForeignAccount::Private(_) | ForeignAccount::Prefetched(_) => {
145 AccountStorageRequirements::default()
146 },
147 }
148 }
149
150 pub fn account_id(&self) -> AccountId {
152 match self {
153 ForeignAccount::Public(account_id, _) => *account_id,
154 ForeignAccount::Private(partial_account) => partial_account.id(),
155 ForeignAccount::Prefetched(inputs) => inputs.id(),
156 }
157 }
158}
159
160impl From<AccountInputs> for ForeignAccount {
161 fn from(inputs: AccountInputs) -> Self {
162 Self::Prefetched(inputs)
163 }
164}
165
166impl Ord for ForeignAccount {
167 fn cmp(&self, other: &Self) -> Ordering {
168 self.account_id().cmp(&other.account_id())
169 }
170}
171
172impl PartialOrd for ForeignAccount {
173 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
174 Some(self.cmp(other))
175 }
176}
177
178impl Serializable for ForeignAccount {
179 fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
180 match self {
181 ForeignAccount::Public(account_id, storage_requirements) => {
182 target.write(0u8);
183 account_id.write_into(target);
184 storage_requirements.write_into(target);
185 },
186 ForeignAccount::Private(partial_account) => {
187 target.write(1u8);
188 partial_account.write_into(target);
189 },
190 ForeignAccount::Prefetched(inputs) => {
191 target.write(2u8);
192 inputs.write_into(target);
193 },
194 }
195 }
196}
197
198impl Deserializable for ForeignAccount {
199 fn read_from<R: miden_tx::utils::serde::ByteReader>(
200 source: &mut R,
201 ) -> Result<Self, miden_tx::utils::serde::DeserializationError> {
202 let account_type: u8 = source.read_u8()?;
203 match account_type {
204 0 => {
205 let account_id = AccountId::read_from(source)?;
206 let storage_requirements = AccountStorageRequirements::read_from(source)?;
207 Ok(ForeignAccount::Public(account_id, storage_requirements))
208 },
209 1 => {
210 let foreign_inputs = PartialAccount::read_from(source)?;
211 Ok(ForeignAccount::Private(foreign_inputs))
212 },
213 2 => Ok(ForeignAccount::Prefetched(AccountInputs::read_from(source)?)),
214 _ => Err(DeserializationError::InvalidValue("Invalid account type".to_string())),
215 }
216 }
217}
218
219pub(crate) fn account_proof_into_inputs(
221 account_proof: AccountProof,
222) -> Result<AccountInputs, TransactionRequestError> {
223 let (witness, account_details) = account_proof.into_parts();
224
225 if let Some(AccountDetails {
226 header: account_header,
227 code,
228 storage_details,
229 vault_details,
230 }) = account_details
231 {
232 let account_storage_map_details = storage_details.map_details;
234 let mut storage_map_proofs = Vec::with_capacity(account_storage_map_details.len());
235 for account_storage_detail in account_storage_map_details {
236 let partial_storage = match account_storage_detail.entries {
237 StorageMapEntries::AllEntries(entries) => {
238 let slot_root = storage_details
243 .header
244 .slots()
245 .find(|slot| *slot.name() == account_storage_detail.slot_name)
246 .map(StorageSlotHeader::value);
247 let storage_entries_iter = entries.iter().map(|e| (e.key, e.value));
248 match StorageMap::with_entries(storage_entries_iter)
249 .ok()
250 .filter(|map| Some(map.root()) == slot_root)
251 {
252 Some(map) => PartialStorageMap::new_full(map),
253 None => continue,
254 }
255 },
256 StorageMapEntries::PartialMap { map_keys, partial_smt } => {
257 partial_map_into_partial_storage(&map_keys, &partial_smt)?
258 },
259 StorageMapEntries::LimitExceeded => continue,
262 };
263 storage_map_proofs.push(partial_storage);
264 }
265
266 let vault = AssetVault::new(&vault_details.assets)
269 .ok()
270 .filter(|vault| vault.root() == account_header.vault_root())
271 .map_or_else(|| PartialVault::new(account_header.vault_root()), PartialVault::new_full);
272
273 return Ok(AccountInputs::new(
274 PartialAccount::new(
275 account_header.id(),
276 account_header.nonce(),
277 code,
278 PartialStorage::new(storage_details.header, storage_map_proofs)?,
279 vault,
280 None,
281 )?,
282 witness,
283 ));
284 }
285 Err(TransactionRequestError::ForeignAccountDataMissing)
286}
287
288fn partial_map_into_partial_storage(
293 map_keys: &[StorageMapKey],
294 partial_smt: &PartialSmt,
295) -> Result<PartialStorageMap, TransactionRequestError> {
296 if map_keys.is_empty() {
297 return Ok(PartialStorageMap::new(partial_smt.root()));
298 }
299
300 let witnesses = map_keys
301 .iter()
302 .map(|key| {
303 let proof = partial_smt.open(&key.hash().as_word())?;
304 StorageMapWitness::new(proof, [*key]).map_err(TransactionRequestError::StorageMapError)
305 })
306 .collect::<Result<Vec<_>, _>>()?;
307
308 Ok(PartialStorageMap::with_witnesses(witnesses)?)
309}
310
311#[cfg(all(test, feature = "testing"))]
315mod foreign_vault_tests {
316 use alloc::sync::Arc;
317
318 use miden_protocol::account::Account;
319 use miden_protocol::asset::FungibleAsset;
320 use miden_testing::{Auth, MockChainBuilder};
321
322 use super::account_proof_into_inputs;
323 use crate::rpc::NodeRpcClient;
324 use crate::rpc::domain::account::{GetAccountRequest, VaultFetch};
325 use crate::test_utils::mock::MockRpcApi;
326
327 fn chain_with_funded_account() -> (Account, Arc<dyn NodeRpcClient>) {
328 let mut builder = MockChainBuilder::new();
329 let account = builder
330 .add_existing_wallet_with_assets(Auth::IncrNonce, [FungibleAsset::mock(500)])
331 .unwrap();
332 (account, Arc::new(MockRpcApi::new(builder.build().unwrap())))
333 }
334
335 #[tokio::test]
338 async fn omitted_asset_list_degrades_to_a_root_only_vault() {
339 let (account, rpc) = chain_with_funded_account();
340 let committed_root = account.vault().root();
341
342 let (_block, proof) = rpc
343 .get_account(
344 account.id(),
345 GetAccountRequest::new().with_vault(VaultFetch::IfChangedFrom(committed_root)),
346 )
347 .await
348 .unwrap();
349
350 let details = proof.vault_details().expect("public account must carry vault details");
351 assert!(
352 details.assets.is_empty(),
353 "the node omits the asset list when the sent root matches"
354 );
355
356 let inputs = account_proof_into_inputs(proof).unwrap();
357
358 assert_eq!(inputs.vault().root(), committed_root);
359 assert!(
360 inputs.vault().assets().next().is_none(),
361 "an omitted list must not be kept as an empty vault"
362 );
363 }
364
365 #[tokio::test]
367 async fn matching_asset_list_is_kept_as_a_full_vault() {
368 let (account, rpc) = chain_with_funded_account();
369 let committed_root = account.vault().root();
370
371 let (_block, proof) = rpc
372 .get_account(account.id(), GetAccountRequest::new().with_vault(VaultFetch::Always))
373 .await
374 .unwrap();
375
376 let inputs = account_proof_into_inputs(proof).unwrap();
377
378 assert_eq!(inputs.vault().root(), committed_root);
379 assert!(
380 inputs.vault().assets().next().is_some(),
381 "a verified asset list must be kept in the partial vault"
382 );
383 }
384}
385
386#[cfg(all(test, feature = "testing"))]
387mod foreign_storage_map_tests {
388 use alloc::sync::Arc;
389
390 use miden_protocol::Word;
391 use miden_protocol::account::{
392 Account,
393 StorageMap,
394 StorageMapKey,
395 StorageSlot,
396 StorageSlotName,
397 };
398 use miden_protocol::transaction::AccountInputs;
399 use miden_testing::{Auth, MockChainBuilder};
400
401 use super::account_proof_into_inputs;
402 use crate::rpc::NodeRpcClient;
403 use crate::rpc::domain::account::{
404 AccountStorageRequirements,
405 GetAccountRequest,
406 StorageMapEntries,
407 StorageMapFetch,
408 };
409 use crate::test_utils::mock::MockRpcApi;
410
411 fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
414 chain_with_map_account_capped(usize::MAX)
415 }
416
417 fn chain_with_map_account_capped(
420 oversize_threshold: usize,
421 ) -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
422 let slot_name = StorageSlotName::new("miden::testing::map").unwrap();
423 let mut map = StorageMap::new();
424 for i in 1..=3u32 {
425 map.insert(StorageMapKey::new(Word::from([i; 4])), Word::from([i * 10; 4]))
426 .unwrap();
427 }
428 let map_root = map.root();
429
430 let mut builder = MockChainBuilder::new();
431 let account = builder
432 .add_existing_mock_account_with_storage(
433 Auth::IncrNonce,
434 [StorageSlot::with_map(slot_name.clone(), map)],
435 )
436 .unwrap();
437 let rpc =
438 MockRpcApi::new(builder.build().unwrap()).with_oversize_threshold(oversize_threshold);
439 (account, slot_name, map_root, Arc::new(rpc))
440 }
441
442 async fn inputs_for_keys(
444 rpc: &Arc<dyn NodeRpcClient>,
445 account: &Account,
446 slot_name: &StorageSlotName,
447 keys: &[StorageMapKey],
448 ) -> AccountInputs {
449 let requirements = AccountStorageRequirements::new([(slot_name.clone(), keys.iter())]);
450 let (_block, proof) = rpc
451 .get_account(
452 account.id(),
453 GetAccountRequest::new().with_storage(StorageMapFetch::Slots(requirements)),
454 )
455 .await
456 .unwrap();
457
458 account_proof_into_inputs(proof).unwrap()
459 }
460
461 #[tokio::test]
463 async fn matching_map_entries_are_kept_as_a_full_map() {
464 let (account, slot_name, map_root, rpc) = chain_with_map_account();
465
466 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
467
468 let map = inputs
469 .storage()
470 .maps()
471 .next()
472 .expect("a verified entry list must be kept in the partial storage");
473 assert_eq!(map.root(), map_root);
474 }
475
476 #[tokio::test]
479 async fn requested_keys_are_kept_as_a_partial_map() {
480 let (account, slot_name, map_root, rpc) = chain_with_map_account();
481 let present_key = StorageMapKey::new(Word::from([2u32; 4]));
482 let absent_key = StorageMapKey::new(Word::from([99u32; 4]));
483
484 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[present_key, absent_key]).await;
485
486 let map = inputs
487 .storage()
488 .maps()
489 .next()
490 .expect("a partial map must be carried in the partial storage");
491 assert_eq!(map.root(), map_root, "the partial map must prove the committed slot root");
492 assert_eq!(map.get(&present_key), Some(Word::from([20u32; 4])));
493 assert_eq!(
494 map.get(&absent_key),
495 Some(Word::empty()),
496 "a requested key that is absent from the map must be proven absent, not untracked"
497 );
498 }
499
500 #[tokio::test]
504 async fn oversize_map_degrades_to_a_root_only_map() {
505 let (account, slot_name, _map_root, rpc) = chain_with_map_account_capped(1);
506
507 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
508
509 assert!(
510 inputs.storage().maps().next().is_none(),
511 "an oversize map must not be carried in the partial storage"
512 );
513 }
514
515 #[tokio::test]
518 async fn mismatched_map_entries_degrade_to_a_root_only_map() {
519 let (account, slot_name, _map_root, rpc) = chain_with_map_account();
520
521 let requirements =
522 AccountStorageRequirements::all_entries(core::slice::from_ref(&slot_name));
523 let (_block, mut proof) = rpc
524 .get_account(
525 account.id(),
526 GetAccountRequest::new().with_storage(StorageMapFetch::Slots(requirements)),
527 )
528 .await
529 .unwrap();
530
531 let map_details = &mut proof
532 .details_mut()
533 .expect("public account must carry details")
534 .storage_details
535 .map_details;
536 let StorageMapEntries::AllEntries(entries) = &mut map_details[0].entries else {
537 panic!("the mock returns all entries when none are named");
538 };
539 entries.pop();
540
541 let inputs = account_proof_into_inputs(proof).unwrap();
542
543 assert!(
544 inputs.storage().maps().next().is_none(),
545 "an entry list that disagrees with the slot root must not be carried"
546 );
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
553
554 use super::*;
555
556 #[test]
558 fn build_fpi_script_rejects_more_args_than_the_input_window() {
559 let foreign_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
560 let arg = Felt::new(1).expect("one is a valid field element");
561 let args = vec![arg; MIN_STACK_DEPTH + 1];
562
563 let err = build_fpi_script(CodeBuilder::new(), foreign_id, Word::empty(), &args)
564 .expect_err("a longer argument list must be rejected");
565
566 assert!(matches!(
567 err,
568 TransactionRequestError::ForeignProcedureInputsTooLong { max, actual }
569 if max == MIN_STACK_DEPTH && actual == MIN_STACK_DEPTH + 1
570 ));
571 }
572}