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 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
16// FPI SCRIPT
17// ================================================================================================
18
19/// Builds a transaction script that invokes the procedure with the given root on a foreign account.
20///
21/// `args` are the procedure's inputs, pushed so that `args[0]` ends up on top of the stack. The
22/// kernel reads them as a fixed window of [`MIN_STACK_DEPTH`] felts, so no more may be passed.
23///
24/// The script leaves the procedure's outputs on top of the stack and drops the rest.
25pub 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    // Fill the unused input slots with zeros, then push the args on top of them.
43    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// FOREIGN ACCOUNT
69// ================================================================================================
70
71/// Account types for foreign procedure invocation.
72#[derive(Clone, Debug, PartialEq, Eq)]
73#[allow(clippy::large_enum_variant)]
74pub enum ForeignAccount {
75    /// Account with public visibility whose state and code will be retrieved from the network at
76    /// execution time. [`AccountFetch`] selects storage map entries and vault assets to fetch in
77    /// one RPC call. Missing entries and assets are loaded during execution.
78    Public(AccountId, AccountFetch),
79    /// Private account that requires a [`PartialAccount`] to be provided by the caller. An account
80    /// witness will be retrieved from the network at execution time so that it can be used as
81    /// inputs to the transaction kernel.
82    Private(PartialAccount),
83    /// Account whose state and inclusion witness the caller supplies, so nothing is fetched for it
84    /// at execution time.
85    ///
86    /// The witness opens against the account tree of exactly one block, so inputs fetched at block
87    /// `N` are only valid for a transaction whose reference block is `N`: the anchor's block under
88    /// [`Client::execute_transaction_at`](crate::Client::execute_transaction_at), or the sync
89    /// height at execution time otherwise. Storage map keys and vault assets absent from the inputs
90    /// are still resolved lazily during execution.
91    Prefetched(AccountInputs),
92}
93
94impl ForeignAccount {
95    /// Creates a public foreign account declaration with a storage and vault fetch policy.
96    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    /// Creates a new [`ForeignAccount::Private`]. A proof of the account's inclusion will be
108    /// retrieved at execution time.
109    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    /// Returns the fetch policy of a public foreign account.
119    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    /// Returns the foreign account's [`AccountId`].
127    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// TESTS
196// ================================================================================================
197
198#[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    /// `IfChangedFrom` with a matching root makes the node omit the asset list, which must degrade
227    /// to a root-only vault rather than be kept as an empty one.
228    #[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    /// An asset list that hashes to the header's vault root is kept in full.
256    #[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    /// Builds a chain with an account holding a three-entry storage map, returning the account, the
337    /// map's slot name and root, and an RPC client over the chain.
338    fn chain_with_map_account() -> (Account, StorageSlotName, Word, Arc<dyn NodeRpcClient>) {
339        chain_with_map_account_capped(usize::MAX)
340    }
341
342    /// Same as [`chain_with_map_account`], with the mock node reporting any map larger than
343    /// `oversize_threshold` as oversize.
344    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    /// Requests the given keys of the account's map slot and returns the resulting inputs.
368    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    /// An entry list that hashes to the slot's root in the storage header is kept as a full map.
386    #[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    /// The requested keys come back as a partial map, which must be carried with the slot's root
401    /// and every requested value readable from it.
402    #[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    /// A map the node reports as oversize carries no entries at all, so it must degrade to a
425    /// root-only map (absent from the partial storage, served lazily during execution) rather than
426    /// fail the conversion.
427    #[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    /// An entry list that does not hash to the slot's root in the storage header is rejected,
440    /// because only an inconsistent response produces it.
441    #[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    /// The inputs are read as a fixed window, so a longer argument list is rejected.
478    #[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}