Skip to main content

miden_client/transaction/request/
foreign.rs

1//! Contains structures and functions related to FPI (Foreign Procedure Invocation) transactions.
2use 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
33// FPI SCRIPT
34// ================================================================================================
35
36/// Builds a transaction script that invokes the procedure with the given root on a foreign
37/// account.
38///
39/// `args` are the procedure's inputs, pushed so that `args[0]` ends up on top of the stack. The
40/// kernel reads them as a fixed window of [`MIN_STACK_DEPTH`] felts, so no more may be passed.
41///
42/// The script leaves the procedure's outputs on top of the stack and drops the rest.
43pub 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    // Fill the unused input slots with zeros, then push the args on top of them.
61    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// FOREIGN ACCOUNT
87// ================================================================================================
88
89/// Account types for foreign procedure invocation.
90#[derive(Clone, Debug, PartialEq, Eq)]
91#[allow(clippy::large_enum_variant)]
92pub enum ForeignAccount {
93    /// Account with public visibility whose state and
94    /// code will be retrieved from the network at execution time. Declaring it upfront lets you
95    /// specify [`AccountStorageRequirements`] so the correct storage map entries are fetched in a
96    /// single RPC call. If not declared, the account is lazily loaded with empty storage
97    /// requirements, and any storage map accesses will trigger additional RPC calls during
98    /// execution.
99    Public(AccountId, AccountStorageRequirements),
100    /// Private account that requires a [`PartialAccount`] to be provided by the caller. An
101    /// account witness will be retrieved from the network at execution time so that it can be
102    /// used as inputs to the transaction kernel.
103    Private(PartialAccount),
104    /// Account whose state and inclusion witness the caller supplies, so nothing is fetched for it
105    /// at execution time.
106    ///
107    /// The witness opens against the account tree of exactly one block, so inputs fetched at block
108    /// `N` are only valid for a transaction whose reference block is `N`: the anchor's block under
109    /// [`Client::execute_transaction_at`](crate::Client::execute_transaction_at), or the sync
110    /// height at execution time otherwise. Storage map keys and vault assets absent from the
111    /// inputs are still resolved lazily during execution.
112    Prefetched(AccountInputs),
113}
114
115impl ForeignAccount {
116    /// Creates a new [`ForeignAccount::Public`]. The account's components (code, storage header and
117    /// inclusion proof) will be retrieved at execution time, alongside particular storage slot
118    /// maps correspondent to keys passed in `indices`.
119    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    /// Creates a new [`ForeignAccount::Private`]. A proof of the account's inclusion will be
131    /// retrieved at execution time.
132    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    /// Returns the foreign account's [`AccountId`].
153    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
221/// Converts an [`AccountProof`] to [`AccountInputs`].
222pub(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        // discard slot indices - not needed for execution
235        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                    // Keep the entry list only if it hashes to the slot's root in the storage
241                    // header. Otherwise skip the map (the header alone carries its root) and let
242                    // map reads resolve lazily as per-key witnesses during execution, rather than
243                    // carry a map at a root the account never committed.
244                    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                // The node carries no entries for an oversize map, so only the slot's root in the
262                // storage header is known. Reads resolve lazily as per-key witnesses.
263                StorageMapEntries::LimitExceeded => continue,
264            };
265            storage_map_proofs.push(partial_storage);
266        }
267
268        // Keep the asset list only if it hashes to the header's vault root; otherwise carry the
269        // root alone and let asset reads resolve lazily as per-asset witnesses.
270        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
290/// Rebuilds a [`PartialStorageMap`] from the raw keys the node covered and the partial SMT
291/// covering them.
292///
293/// An empty key list keeps the root alone, since there is no opening to derive it from.
294fn 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// TESTS
314// ================================================================================================
315
316#[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    /// `IfChangedFrom` with a matching root makes the node omit the asset list, which must
338    /// degrade to a root-only vault rather than be kept as an empty one.
339    #[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    /// An asset list that hashes to the header's vault root is kept in full.
368    #[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    /// Builds a chain with an account holding a three-entry storage map, returning the account,
414    /// the map's slot name and root, and an RPC client over the chain.
415    fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
416        chain_with_map_account_capped(usize::MAX)
417    }
418
419    /// Same as [`chain_with_map_account`], with the mock node reporting any map larger than
420    /// `oversize_threshold` as oversize.
421    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    /// Requests the given keys of the account's map slot and returns the resulting inputs.
445    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    /// An entry list that hashes to the slot's root in the storage header is kept as a full map.
464    #[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    /// The requested keys come back as a partial map, which must be carried with the slot's root
479    /// and every requested value readable from it.
480    #[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    /// A map the node reports as oversize carries no entries at all, so it must degrade to a
503    /// root-only map (absent from the partial storage, served lazily during execution) rather
504    /// than fail the conversion.
505    #[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    /// An entry list that does not hash to the slot's root in the storage header must likewise
518    /// degrade to a root-only map instead of being carried at a root the account never committed.
519    #[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    /// The inputs are read as a fixed window, so a longer argument list is rejected.
559    #[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}