diff --git a/crates/miden-protocol/asm/protocol/src/tx.masm b/crates/miden-protocol/asm/protocol/src/tx.masm index f960b5329b..bd200c6d88 100644 --- a/crates/miden-protocol/asm/protocol/src/tx.masm +++ b/crates/miden-protocol/asm/protocol/src/tx.masm @@ -79,7 +79,7 @@ end #! Returns the timestamp of the reference block for this transaction. #! #! WARNING: the returned timestamp is not guaranteed to be precise (i.e., could be several seconds -#! off) or recent, unless recency is separately enforced by setting transaction expiration delta. +#! off) or recent, unless an expiration delta bounds the age of the reference block. #! #! Specifically, the reference blocks (and therefore the corresponding block timestamp) can be #! chosen somewhat arbitrarily by the transaction executor. While this does not allow executors to @@ -92,8 +92,8 @@ end #! to consume the note when referencing the latest block. This is not necessarily a problem in all #! cases, but must be taken into consideration by script developers. #! -#! If the above is undesired, then one possible countermeasure is to set a transaction expiration -#! delta. For example, with a delta of 3, the oldest block account X could reference is the one at +#! If the above is undesired, then one possible countermeasure is to set an expiration delta. For +#! example, with an expiration delta of 3, the oldest block account X could reference is the one at #! time 8. This still allows for consumption by both accounts during a period of time, but shortens #! that window. #! @@ -232,12 +232,16 @@ end #! and is not revalidated against the foreign account's current on-chain state at inclusion, so the #! returned values may be outdated. #! -#! If a foreign account holds time-sensitive data, it is the responsibility of that account to set a -#! transaction expiration delta according to how time-sensitive the data is. The delta bounds how old -#! the reference block can be relative to the block the transaction is included in. For example, if an -#! oracle price is updated every 5 blocks, the oracle account should set an expiration delta of 5 (or -#! smaller): if the current block is 40 and the delta is 5, the reference block must be block 35 or -#! newer, so a value from block 20 could not be read. +#! Any FPI-callable procedure or asset callback that reads mutable, security-sensitive state must +#! call [`tx::update_expiration_block_delta`] in the execution path that reads that state. This is +#! the foreign account's responsibility because the caller chooses the transaction reference block. +#! No expiration delta is required when a procedure reads only immutable data or when stale data is +#! acceptable. +#! +#! The expiration delta bounds the age of the reference block relative to the block that includes +#! the transaction. For example, if an oracle price is updated every 5 blocks, the oracle account +#! should set an expiration delta of 5 or less. If the current block is 40 and the delta is 5, the +#! reference block must be block 35 or newer, so a value from block 20 cannot be read. #! #! Inputs: [foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, foreign_procedure_inputs(16)] #! Outputs: [foreign_procedure_outputs(16)] @@ -299,21 +303,20 @@ pub proc execute_foreign_procedure( # => [foreign_procedure_outputs(16)] end -#! Updates the transaction expiration delta. +#! Updates the expiration delta. #! -#! The transaction expiration delta specifies how close to the transaction's reference block the -#! transaction must be included into the chain. For example, if the transaction's reference block is -#! 100 and transaction expiration delta is 10, the transaction can be included into the chain by -#! block 110. If this does not happen, the transaction is considered expired and cannot be included -#! into the chain. +#! The expiration delta specifies how close to the transaction's reference block the transaction +#! must be included in the chain. For example, if the transaction's reference block is 100 and the +#! expiration delta is 10, the transaction can be included in the chain by block 110. If this does +#! not happen, the transaction is considered expired and cannot be included in the chain. #! -#! Once set, transaction expiration delta can be decreased, but not increased. +#! Once set, the expiration delta can be decreased, but not increased. #! #! Inputs: [block_height_delta, ...] #! Output: [...] #! #! Where: -#! - block_height_delta is the desired expiration time delta (1 to 0xFFFF). +#! - block_height_delta is the desired expiration delta (1 to 65535). #! #! Annotation hint: is not used anywhere pub proc update_expiration_block_delta(block_height_delta: u16) @@ -330,13 +333,13 @@ pub proc update_expiration_block_delta(block_height_delta: u16) dropw dropw dropw dropw end -#! Returns the transaction expiration delta, or 0 if the delta has not been set. +#! Returns the expiration delta, or 0 if the delta has not been set. #! #! Inputs: [...] #! Output: [block_height_delta, ...] #! #! Where: -#! - block_height_delta is the stored expiration time delta (1 to 0xFFFF). +#! - block_height_delta is the stored expiration delta (1 to 65535). #! #! Annotation hint: is not used anywhere pub proc get_expiration_block_delta() -> u16 diff --git a/crates/miden-standards/asm/standards/expiration.masm b/crates/miden-standards/asm/standards/expiration.masm index 635c028dda..78b22b8324 100644 --- a/crates/miden-standards/asm/standards/expiration.masm +++ b/crates/miden-standards/asm/standards/expiration.masm @@ -1,26 +1,28 @@ # miden::standards::expiration # -# Helpers for applying transaction expiration limits. +# Helpers for applying expiration deltas. use miden::protocol::tx # CONSTANTS # ================================================================================================= -#! The default expiration limit for standards procedures that read mutable state through FPI. +#! The default expiration delta for standards procedures that read mutable, security-sensitive state +#! through FPI. #! At the default three-second block interval, this corresponds to approximately one minute. pub const DEFAULT_EXPIRATION_BLOCK_DELTA = 20 # PROCEDURES # ================================================================================================= -#! Applies the default expiration block delta to the transaction. +#! Applies the default expiration delta to the transaction. #! -#! A procedure that reads mutable or time-sensitive state exposed through FPI should call this as -#! its first action; a procedure needing a custom limit can call -#! [`tx::update_expiration_block_delta`] directly. Policy dispatchers deliberately leave the limit -#! to the invoked policy. The transaction-wide expiration can only decrease, so several procedures -#! may safely apply different limits in the same transaction. +#! A procedure that reads mutable, security-sensitive state exposed through FPI must call +#! this in the execution path that reads the state; a procedure needing a custom expiration delta +#! can call [`tx::update_expiration_block_delta`] directly. Policy dispatchers deliberately leave +#! the expiration delta to the invoked policy. The transaction-wide expiration delta can only +#! decrease, so several procedures may safely apply different expiration deltas in the same +#! transaction. #! #! Inputs: [] #! Outputs: [] diff --git a/crates/miden-standards/src/account/policies/mod.rs b/crates/miden-standards/src/account/policies/mod.rs index 6a98f9eef1..6929eaf367 100644 --- a/crates/miden-standards/src/account/policies/mod.rs +++ b/crates/miden-standards/src/account/policies/mod.rs @@ -27,6 +27,15 @@ //! [`TransferAllowAll`]) install a specific policy procedure on the account so that the //! manager's `dyncall` can dispatch to it. //! +//! Policies that may run through FPI and read mutable, security-sensitive state must set an +//! expiration delta in the same execution path that reads the state. This includes transfer +//! policies reached through asset callbacks and policies that read blocklists, allowlists, pause +//! flags, active policy roots, oracle values, risk parameters, or similar mutable data. The +//! built-in mutable transfer policies apply `miden::standards::expiration::apply_default`; custom +//! policies should call that helper or `tx::update_expiration_block_delta` directly with a custom +//! expiration delta. No expiration delta is required when a policy reads only immutable data or +//! when stale data is acceptable. +//! //! A faucet constructs the manager via [`TokenPolicyManager::builder`], setting the required //! `active_*_policy` for each kind (and optionally any number of reserved `allowed_*_policy` //! entries), then passes the built manager directly to diff --git a/crates/miden-standards/src/account/policies/transfer/mod.rs b/crates/miden-standards/src/account/policies/transfer/mod.rs index 39028d93ae..080256af28 100644 --- a/crates/miden-standards/src/account/policies/transfer/mod.rs +++ b/crates/miden-standards/src/account/policies/transfer/mod.rs @@ -62,6 +62,12 @@ pub enum TransferPolicyError { /// an ordinary send. The bundled blocklist and allowlist policies therefore exempt the issuer by /// comparing the asset's faucet ID against the native account ID. /// +/// Transfer policies reached through asset callbacks run through FPI. Policies that read mutable, +/// security-sensitive state must set an expiration delta in the execution path that reads that +/// state. The built-in blocklist and allowlist policies apply the standards default; custom +/// policies should call `miden::standards::expiration::apply_default` or +/// `tx::update_expiration_block_delta` directly with a custom expiration delta. +/// /// The companion components carried by the descriptor are inlined into the account by the /// [`super::TokenPolicyManager`] when it is converted into account components. #[derive(Debug, Clone)] diff --git a/docs/src/account/components.md b/docs/src/account/components.md index 9dd6240060..6647605501 100644 --- a/docs/src/account/components.md +++ b/docs/src/account/components.md @@ -22,6 +22,14 @@ merged to form the account's `Code` and `Storage`. The component's code defines a library of functions that can perform arbitrary computations, as well as read and write to account storage. +### FPI-callable mutable reads + +Account component procedures can become part of an account's public interface and can be called from note scripts, transaction scripts, and foreign accounts through FPI. If a procedure reads mutable, security-sensitive state, it must set an expiration delta in the execution path that reads that state. + +The component procedure defines the expiration delta because callers can choose an old reference block, so callers cannot be trusted to set it. Standards components can use `miden::standards::expiration::apply_default` for the common expiration delta, or call `tx::update_expiration_block_delta` directly when they need a custom expiration delta. + +For more information, see [Foreign procedure invocation (FPI) and expiration](../transaction#foreign-procedure-invocation-fpi-and-expiration). + ## Component metadata The component metadata describes the account component entirely: its name, description, version, and storage layout. diff --git a/docs/src/asset.md b/docs/src/asset.md index a7bb1135e5..d3aca75c7c 100644 --- a/docs/src/asset.md +++ b/docs/src/asset.md @@ -190,6 +190,10 @@ Outputs: [pad(16)] Both callbacks are invoked via `dyncall`, so they must follow the convention of accepting and returning 16 stack elements (input + padding). +#### Expiration requirement + +Asset callbacks execute against the issuing faucet through FPI. Any callback or callback-dispatched policy that reads mutable, security-sensitive state must set an expiration delta in the execution path that reads that state. Standards components can use `miden::standards::expiration::apply_default` for the common expiration delta, and custom callbacks can call `tx::update_expiration_block_delta` directly. For more information, see [Foreign procedure invocation (FPI) and expiration](transaction#foreign-procedure-invocation-fpi-and-expiration). + #### Callback skipping A callback is not invoked in any of these cases: @@ -207,3 +211,5 @@ All data structures not following the Miden asset model that can be exchanged. ::: Miden is flexible enough to support other `Asset` models. For example, developers can replicate Ethereum’s ERC20 pattern, where fungible `Asset` ownership is recorded in a single account. To transact, users send a note to that account, triggering updates in the global hashmap state. + +Alternative or programmable asset models that expose FPI-callable checks follow the same expiration delta rule as native asset callbacks; see [Foreign procedure invocation (FPI) and expiration](transaction#foreign-procedure-invocation-fpi-and-expiration). diff --git a/docs/src/protocol_library.md b/docs/src/protocol_library.md index 57312d0270..63740f7a7c 100644 --- a/docs/src/protocol_library.md +++ b/docs/src/protocol_library.md @@ -187,13 +187,17 @@ Transaction procedures manage transaction-level operations including note creati | `get_output_notes_commitment` | Returns the output notes commitment hash.

**Inputs:** `[]`
**Outputs:** `[OUTPUT_NOTES_COMMITMENT]` | Any | | `get_num_input_notes` | Returns the total number of input notes consumed by this transaction.

**Inputs:** `[]`
**Outputs:** `[num_input_notes]` | Any | | `get_num_output_notes` | Returns the current number of output notes created in this transaction.

**Inputs:** `[]`
**Outputs:** `[num_output_notes]` | Any | -| `execute_foreign_procedure` | Executes the provided procedure against the foreign account.

**Inputs:** `[foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, , pad(n)]`
**Outputs:** `[]` | Any | -| `get_expiration_block_delta` | Returns the transaction expiration delta, or 0 if not set.

**Inputs:** `[]`
**Outputs:** `[block_height_delta]` | Any | -| `update_expiration_block_delta` | Updates the transaction expiration delta.

**Inputs:** `[block_height_delta]`
**Outputs:** `[]` | Any | +| `execute_foreign_procedure` | Executes the provided procedure against the foreign account. Values read from the foreign account are authenticated for the transaction reference block, not for the inclusion block.

**Inputs:** `[foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, , pad(n)]`
**Outputs:** `[]` | Any | +| `get_expiration_block_delta` | Returns the expiration delta, or 0 if not set.

**Inputs:** `[]`
**Outputs:** `[block_height_delta]` | Any | +| `update_expiration_block_delta` | Updates the expiration delta.

**Inputs:** `[block_height_delta]`
**Outputs:** `[]` | Any | | `compute_fee` | Computes the fee required for the current transaction.

**Inputs:** `[num_extra_cycles, EXCLUDE_NOTES_COMMITMENT]`
**Outputs:** `[fee_amount]` | Any | | `get_fee_asset_id` | Returns the ID of the asset that fees are paid in.

**Inputs:** `[]`
**Outputs:** `[FEE_ASSET_ID]` | Any | -Note on `execute_foreign_procedure`: the values it reads reflect the foreign account's state at the transaction reference block, which is chosen by the executor. The foreign account commitment is not a transaction public input and is not revalidated against the foreign account's current on-chain state at inclusion, so a foreign read may be outdated. If a foreign account holds time-sensitive data, it is the responsibility of that account to set the transaction expiration delta according to how time-sensitive the data is, so that the FPI interface cannot be used in unintended ways. +### Foreign procedure invocation and expiration + +`execute_foreign_procedure` reads the foreign account's state at the transaction reference block, which is chosen by the executor. The foreign account commitment is not a transaction public input and is not revalidated against the foreign account's current on-chain state at inclusion, so a foreign read may be outdated. + +Any FPI-callable procedure that reads mutable, security-sensitive state must set an expiration delta in the execution path that reads that state. For the full rule and rationale, see [Foreign procedure invocation (FPI) and expiration](transaction#foreign-procedure-invocation-fpi-and-expiration). ## Faucet Procedures (`miden::protocol::faucet`) diff --git a/docs/src/transaction.md b/docs/src/transaction.md index 77f9914ce6..ea6b6ac0ee 100644 --- a/docs/src/transaction.md +++ b/docs/src/transaction.md @@ -57,6 +57,28 @@ A `Transaction` requires several inputs: The proof together with the corresponding data needed for verification and updates of the global state can then be submitted and processed by the network. +### Foreign procedure invocation (FPI) and expiration + +Note scripts and transaction scripts can read state from foreign accounts by calling public account procedures through foreign procedure invocation (FPI). FPI authenticates the foreign account state against the transaction reference block, but it does not prove that the state is current when the transaction is included in a block. + +The executor chooses the transaction reference block. If no expiration delta is set, a transaction can be proved against an old canonical block where mutable, security-sensitive foreign state still allowed the action. For example, a prover could choose a block from before an account was added to a blocklist, before an account was removed from an allowlist, or before an oracle value or active policy root changed. + +:::warning + +Any FPI-callable procedure or asset callback that reads mutable, security-sensitive state must call `tx::update_expiration_block_delta` in the execution path that reads that state. + +::: + +The expiration block is computed as: + +```text +expiration_block = transaction_reference_block + expiration_delta +``` + +The expiration delta bounds the age of the reference block relative to the block that includes the transaction. For example, if an oracle price is updated every 5 blocks, the oracle account should set an expiration delta of 5 or less. If the current block is 40 and the delta is 5, the reference block must be block 35 or newer, so a value from block 20 cannot be used. + +The FPI-callable procedure sets the expiration delta because the caller controls which valid reference block is used for proving. No expiration delta is required when the procedure reads only immutable data or when stale data is acceptable. + ## Examples To illustrate the `Transaction` protocol, we provide two examples for a basic `Transaction`. We will use references to the existing Miden `Transaction` kernel — the reference implementation of the protocol — and to the methods in Miden Assembly. @@ -129,9 +151,7 @@ The ability to facilitate both, local and network transactions, **is one of the - In Miden, executors can choose arbitrary reference blocks to execute against their state. Hence it is possible to set `Transaction` expiration heights and in doing so, to define a block height until a `Transaction` should be included into a block. If the `Transaction` is expired, the resulting account state change is not valid and the `Transaction` cannot be verified anymore. -- Note and `Transaction` scripts can read the state of foreign accounts during execution. This is called foreign procedure invocation (FPI). A transaction can load at most **63 distinct foreign accounts** in addition to its native account. This does not limit the total number of FPI calls: once a foreign account is loaded, subsequent calls to the same account reuse the loaded data and do not consume another foreign-account slot. For example, the price of an asset for the **Swap** script might depend on a certain value stored in the oracle account. The caller identifies the invoked procedure by its root, which the kernel requires to be part of the code of the foreign account it is called on, so the executed logic is always one the account committed to. - -- Values read from a foreign account through foreign procedure invocation reflect that account's state at the transaction's reference block, which the executor chooses and which may be an older canonical block. Unlike the native account's initial state, a foreign read is bound only to the reference block: the foreign account commitment is not part of the transaction's public inputs and is never revalidated against the foreign account's current on-chain state when the `Transaction` is included in a block. Because the party proving the `Transaction` can anchor it to a past block in which a foreign value was outdated but favorable (for example to bypass cross-account authorization such as roles or allowlists, or to act on a stale oracle price), a foreign account holding time-sensitive data must protect itself: it is the responsibility of that account to set a transaction expiration delta according to how time-sensitive the data is, so the FPI interface cannot be used in unintended ways. The delta bounds how old the reference block can be relative to the block the `Transaction` is included in. For example, if an oracle price is updated every 5 blocks, the oracle account should set an expiration delta of 5 (or smaller): if the current block is 40 and the delta is 5, the reference block must be block 35 or newer, so a value from block 20 could not be read. +- Note and `Transaction` scripts can read the state of foreign accounts during execution. This is called [foreign procedure invocation (FPI)](#foreign-procedure-invocation-fpi-and-expiration). A transaction can load at most **63 distinct foreign accounts** in addition to its native account. This does not limit the total number of FPI calls: once a foreign account is loaded, subsequent calls to the same account reuse the loaded data and do not consume another foreign-account slot. The caller identifies the invoked procedure by its root, which the kernel requires to be part of the code of the foreign account it is called on, so the executed logic is always one the account committed to. - An example of the right usage of `Transaction` arguments is the consumption of a **Swap** note. Those notes allow asset exchange based on predefined conditions. Example: - The note's consumption condition is defined as "anyone can consume this note to take `X` units of asset A if they simultaneously create a note sending Y units of asset B back to the creator." If an executor wants to buy only a fraction `(X-m)` of asset A, they provide this amount via transaction arguments. The executor would provide the value `m`. The note script then enforces the correct transfer: