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