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