1use alloc::string::{String, ToString};
3use core::cmp::Ordering;
4use core::fmt::Write as _;
5
6use miden_protocol::account::{AccountId, PartialAccount};
7use miden_protocol::transaction::{AccountInputs, TransactionScript};
8use miden_protocol::vm::MIN_STACK_DEPTH;
9use miden_protocol::{Felt, Word};
10use miden_standards::code_builder::CodeBuilder;
11use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
12
13use super::TransactionRequestError;
14use crate::rpc::domain::account::AccountFetch;
15
16pub fn build_fpi_script(
26 code_builder: CodeBuilder,
27 foreign_account_id: AccountId,
28 procedure_root: Word,
29 args: &[Felt],
30) -> Result<TransactionScript, TransactionRequestError> {
31 if args.len() > MIN_STACK_DEPTH {
32 return Err(TransactionRequestError::ForeignProcedureInputsTooLong {
33 max: MIN_STACK_DEPTH,
34 actual: args.len(),
35 });
36 }
37
38 let mut script = String::from(
39 "use miden::protocol::tx\nuse miden::core::sys\n\n@transaction_script\npub proc main\n",
40 );
41
42 let pad_count = MIN_STACK_DEPTH - args.len();
44 for _ in 0..pad_count / 4 {
45 script.push_str(" padw\n");
46 }
47 for _ in 0..pad_count % 4 {
48 script.push_str(" push.0\n");
49 }
50 for arg in args.iter().rev() {
51 writeln!(script, " push.{arg}").expect("writing to a string never fails");
52 }
53
54 writeln!(script, " push.{}", procedure_root.to_hex())
55 .expect("writing to a string never fails");
56 writeln!(script, " push.{}", foreign_account_id.prefix().as_u64())
57 .expect("writing to a string never fails");
58 writeln!(script, " push.{}", foreign_account_id.suffix())
59 .expect("writing to a string never fails");
60
61 script.push_str(" exec.tx::execute_foreign_procedure\n");
62 script.push_str(" exec.sys::truncate_stack\n");
63 script.push_str("end\n");
64
65 Ok(code_builder.compile_tx_script(&script)?)
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
73#[allow(clippy::large_enum_variant)]
74pub enum ForeignAccount {
75 Public(AccountId, AccountFetch),
79 Private(PartialAccount),
83 Prefetched(AccountInputs),
92}
93
94impl ForeignAccount {
95 pub fn public(
97 account_id: AccountId,
98 fetch: AccountFetch,
99 ) -> Result<Self, TransactionRequestError> {
100 if !account_id.is_public() {
101 return Err(TransactionRequestError::InvalidForeignAccountId(account_id));
102 }
103
104 Ok(Self::Public(account_id, fetch))
105 }
106
107 pub fn private(account: impl Into<PartialAccount>) -> Result<Self, TransactionRequestError> {
110 let partial_account: PartialAccount = account.into();
111 if partial_account.id().is_public() {
112 return Err(TransactionRequestError::InvalidForeignAccountId(partial_account.id()));
113 }
114
115 Ok(Self::Private(partial_account))
116 }
117
118 pub fn fetch(&self) -> Option<&AccountFetch> {
120 match self {
121 ForeignAccount::Public(_, fetch) => Some(fetch),
122 ForeignAccount::Private(_) | ForeignAccount::Prefetched(_) => None,
123 }
124 }
125
126 pub fn account_id(&self) -> AccountId {
128 match self {
129 ForeignAccount::Public(account_id, _) => *account_id,
130 ForeignAccount::Private(partial_account) => partial_account.id(),
131 ForeignAccount::Prefetched(inputs) => inputs.id(),
132 }
133 }
134}
135
136impl From<AccountInputs> for ForeignAccount {
137 fn from(inputs: AccountInputs) -> Self {
138 Self::Prefetched(inputs)
139 }
140}
141
142impl Ord for ForeignAccount {
143 fn cmp(&self, other: &Self) -> Ordering {
144 self.account_id().cmp(&other.account_id())
145 }
146}
147
148impl PartialOrd for ForeignAccount {
149 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
150 Some(self.cmp(other))
151 }
152}
153
154impl Serializable for ForeignAccount {
155 fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
156 match self {
157 ForeignAccount::Public(account_id, fetch) => {
158 target.write(0u8);
159 account_id.write_into(target);
160 fetch.write_into(target);
161 },
162 ForeignAccount::Private(partial_account) => {
163 target.write(1u8);
164 partial_account.write_into(target);
165 },
166 ForeignAccount::Prefetched(inputs) => {
167 target.write(2u8);
168 inputs.write_into(target);
169 },
170 }
171 }
172}
173
174impl Deserializable for ForeignAccount {
175 fn read_from<R: miden_tx::utils::serde::ByteReader>(
176 source: &mut R,
177 ) -> Result<Self, miden_tx::utils::serde::DeserializationError> {
178 let account_type: u8 = source.read_u8()?;
179 match account_type {
180 0 => {
181 let account_id = AccountId::read_from(source)?;
182 let fetch = AccountFetch::read_from(source)?;
183 Ok(ForeignAccount::Public(account_id, fetch))
184 },
185 1 => {
186 let foreign_inputs = PartialAccount::read_from(source)?;
187 Ok(ForeignAccount::Private(foreign_inputs))
188 },
189 2 => Ok(ForeignAccount::Prefetched(AccountInputs::read_from(source)?)),
190 _ => Err(DeserializationError::InvalidValue("Invalid account type".to_string())),
191 }
192 }
193}
194
195#[cfg(all(test, feature = "testing"))]
199mod foreign_vault_tests {
200 use alloc::sync::Arc;
201
202 use miden_protocol::Word;
203 use miden_protocol::account::{
204 Account,
205 AccountStorageHeader,
206 PartialAccount,
207 StorageSlotHeader,
208 StorageSlotName,
209 StorageSlotType,
210 };
211 use miden_protocol::asset::FungibleAsset;
212 use miden_testing::{Auth, MockChainBuilder};
213
214 use crate::rpc::domain::account::{AccountFetch, GetAccountRequest, VaultFetch};
215 use crate::rpc::{NodeRpcClient, RpcError};
216 use crate::test_utils::mock::MockRpcApi;
217
218 fn chain_with_funded_account() -> (Account, Arc<dyn NodeRpcClient>) {
219 let mut builder = MockChainBuilder::new();
220 let account = builder
221 .add_existing_wallet_with_assets(Auth::IncrNonce, [FungibleAsset::mock(500)])
222 .unwrap();
223 (account, Arc::new(MockRpcApi::new(builder.build().unwrap())))
224 }
225
226 #[tokio::test]
229 async fn omitted_asset_list_degrades_to_a_root_only_vault() {
230 let (account, rpc) = chain_with_funded_account();
231 let committed_root = account.vault().root();
232
233 let (_block, proof) = rpc
234 .get_account(GetAccountRequest::new(account.id()).with_fetch(
235 AccountFetch::new().with_vault(VaultFetch::IfChangedFrom(committed_root)),
236 ))
237 .await
238 .unwrap();
239
240 let details = proof.vault_details().expect("public account must carry vault details");
241 assert!(
242 details.assets().is_some_and(<[_]>::is_empty),
243 "the node omits the asset list when the sent root matches"
244 );
245
246 let inputs = proof.into_account_inputs().unwrap();
247
248 assert_eq!(inputs.vault().root(), committed_root);
249 assert!(
250 inputs.vault().assets().next().is_none(),
251 "an omitted list must not be kept as an empty vault"
252 );
253 }
254
255 #[tokio::test]
257 async fn matching_asset_list_is_kept_as_a_full_vault() {
258 let (account, rpc) = chain_with_funded_account();
259 let committed_root = account.vault().root();
260
261 let (_block, proof) = rpc
262 .get_account(
263 GetAccountRequest::new(account.id())
264 .with_fetch(AccountFetch::new().with_vault(VaultFetch::Always)),
265 )
266 .await
267 .unwrap();
268
269 let inputs = proof.into_account_inputs().unwrap();
270
271 assert_eq!(inputs.vault().root(), committed_root);
272 assert!(
273 inputs.vault().assets().next().is_some(),
274 "a verified asset list must be kept in the partial vault"
275 );
276 }
277
278 #[tokio::test]
279 async fn account_details_convert_to_a_partial_account() {
280 let (account, rpc) = chain_with_funded_account();
281 let (_, proof) = rpc
282 .get_account(
283 GetAccountRequest::new(account.id())
284 .with_fetch(AccountFetch::new().with_vault(VaultFetch::Always)),
285 )
286 .await
287 .unwrap();
288 let partial = PartialAccount::try_from(proof.into_details().unwrap()).unwrap();
289 assert_eq!(partial.to_commitment(), account.to_commitment());
290 assert_eq!(
291 partial.vault().open(FungibleAsset::mock(500).id()).unwrap(),
292 account.vault().open(FungibleAsset::mock(500).id())
293 );
294 }
295
296 #[tokio::test]
297 async fn account_details_reject_a_mismatched_storage_header() {
298 let (account, rpc) = chain_with_funded_account();
299 let (_, proof) = rpc.get_account(GetAccountRequest::new(account.id())).await.unwrap();
300 let mut details = proof.into_details().unwrap();
301 details.storage_details.header = AccountStorageHeader::new(vec![StorageSlotHeader::new(
302 StorageSlotName::new("miden::testing::wrong_storage").unwrap(),
303 StorageSlotType::Value,
304 Word::from([1u32, 2, 3, 4]),
305 )])
306 .unwrap();
307 assert!(matches!(PartialAccount::try_from(details), Err(RpcError::InvalidResponse(_))));
308 }
309}
310
311#[cfg(all(test, feature = "testing"))]
312mod foreign_storage_map_tests {
313 use alloc::sync::Arc;
314
315 use miden_protocol::Word;
316 use miden_protocol::account::{
317 Account,
318 StorageMap,
319 StorageMapKey,
320 StorageSlot,
321 StorageSlotName,
322 };
323 use miden_protocol::transaction::AccountInputs;
324 use miden_testing::{Auth, MockChainBuilder};
325
326 use crate::rpc::NodeRpcClient;
327 use crate::rpc::domain::account::{
328 AccountFetch,
329 AccountStorageRequirements,
330 GetAccountRequest,
331 StorageMapEntries,
332 StorageMapFetch,
333 };
334 use crate::test_utils::mock::MockRpcApi;
335
336 fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
339 chain_with_map_account_capped(usize::MAX)
340 }
341
342 fn chain_with_map_account_capped(
345 oversize_threshold: usize,
346 ) -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
347 let slot_name = StorageSlotName::new("miden::testing::map").unwrap();
348 let mut map = StorageMap::new();
349 for i in 1..=3u32 {
350 map.insert(StorageMapKey::new(Word::from([i; 4])), Word::from([i * 10; 4]))
351 .unwrap();
352 }
353 let map_root = map.root();
354
355 let mut builder = MockChainBuilder::new();
356 let account = builder
357 .add_existing_mock_account_with_storage(
358 Auth::IncrNonce,
359 [StorageSlot::with_map(slot_name.clone(), map)],
360 )
361 .unwrap();
362 let rpc =
363 MockRpcApi::new(builder.build().unwrap()).with_oversize_threshold(oversize_threshold);
364 (account, slot_name, map_root, Arc::new(rpc))
365 }
366
367 async fn inputs_for_keys(
369 rpc: &Arc<dyn NodeRpcClient>,
370 account: &Account,
371 slot_name: &StorageSlotName,
372 keys: &[StorageMapKey],
373 ) -> AccountInputs {
374 let requirements = AccountStorageRequirements::new([(slot_name.clone(), keys.iter())]);
375 let (_block, proof) =
376 rpc.get_account(GetAccountRequest::new(account.id()).with_fetch(
377 AccountFetch::new().with_storage(StorageMapFetch::Slots(requirements)),
378 ))
379 .await
380 .unwrap();
381
382 proof.into_account_inputs().unwrap()
383 }
384
385 #[tokio::test]
387 async fn matching_map_entries_are_kept_as_a_full_map() {
388 let (account, slot_name, map_root, rpc) = chain_with_map_account();
389
390 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
391
392 let map = inputs
393 .storage()
394 .maps()
395 .next()
396 .expect("a verified entry list must be kept in the partial storage");
397 assert_eq!(map.root(), map_root);
398 }
399
400 #[tokio::test]
403 async fn requested_keys_are_kept_as_a_partial_map() {
404 let (account, slot_name, map_root, rpc) = chain_with_map_account();
405 let present_key = StorageMapKey::new(Word::from([2u32; 4]));
406 let absent_key = StorageMapKey::new(Word::from([99u32; 4]));
407
408 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[present_key, absent_key]).await;
409
410 let map = inputs
411 .storage()
412 .maps()
413 .next()
414 .expect("a partial map must be carried in the partial storage");
415 assert_eq!(map.root(), map_root, "the partial map must prove the committed slot root");
416 assert_eq!(map.get(&present_key), Some(Word::from([20u32; 4])));
417 assert_eq!(
418 map.get(&absent_key),
419 Some(Word::empty()),
420 "a requested key that is absent from the map must be proven absent, not untracked"
421 );
422 }
423
424 #[tokio::test]
428 async fn oversize_map_degrades_to_a_root_only_map() {
429 let (account, slot_name, _map_root, rpc) = chain_with_map_account_capped(1);
430
431 let inputs = inputs_for_keys(&rpc, &account, &slot_name, &[]).await;
432
433 assert!(
434 inputs.storage().maps().next().is_none(),
435 "an oversize map must not be carried in the partial storage"
436 );
437 }
438
439 #[tokio::test]
442 async fn mismatched_map_entries_are_rejected() {
443 let (account, slot_name, _map_root, rpc) = chain_with_map_account();
444
445 let requirements =
446 AccountStorageRequirements::all_entries(core::slice::from_ref(&slot_name));
447 let (_block, mut proof) =
448 rpc.get_account(GetAccountRequest::new(account.id()).with_fetch(
449 AccountFetch::new().with_storage(StorageMapFetch::Slots(requirements)),
450 ))
451 .await
452 .unwrap();
453
454 let map_details = &mut proof
455 .details_mut()
456 .expect("public account must carry details")
457 .storage_details
458 .map_details;
459 let StorageMapEntries::AllEntries(entries) = &mut map_details[0].entries else {
460 panic!("the mock returns all entries when none are named");
461 };
462 entries.pop();
463
464 assert!(matches!(
465 proof.into_account_inputs(),
466 Err(crate::rpc::RpcError::InvalidResponse(_))
467 ));
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
474
475 use super::*;
476
477 #[test]
479 fn build_fpi_script_rejects_more_args_than_the_input_window() {
480 let foreign_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
481 let arg = Felt::new(1).expect("one is a valid field element");
482 let args = vec![arg; MIN_STACK_DEPTH + 1];
483
484 let err = build_fpi_script(CodeBuilder::new(), foreign_id, Word::empty(), &args)
485 .expect_err("a longer argument list must be rejected");
486
487 assert!(matches!(
488 err,
489 TransactionRequestError::ForeignProcedureInputsTooLong { max, actual }
490 if max == MIN_STACK_DEPTH && actual == MIN_STACK_DEPTH + 1
491 ));
492 }
493}