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