Skip to content

feat(standards): add spending limits to AuthMultisigSmart - #4081

Merged
mmagician merged 30 commits into
nextfrom
feat-spending-limits
Oct 30, 2026
Merged

mmagician merged 30 commits into
nextfrom
feat-spending-limits

Conversation

@onurinanc

Copy link
Copy Markdown
Collaborator

Summary

Closes #4064.

Adds spending limits to AuthMultisigSmart: a per-faucet budget in the asset's own unit and an optional combined budget priced through the oracle from #3561, both reset every N blocks. A transaction that would exceed a budget fails no matter how many approvers signed it. Design and examples are in the issue. Second feature split out of #2973 after #3044, following its conventions.

Changes

  • spending_limits.masm meters every fungible asset in the output notes against its faucet's budget and, when the oracle prices it, the combined budget.
  • The check runs at the end of auth_tx after the threshold assertion, so a violation surfaces in the signed execution rather than in the unsigned probe.
  • Every oracle read tightens the expiration by max_oracle_block_age, and a zero or overflowing rate fails the transaction.
  • Four configuration procedures reject settings that would lock the account out, and limits are read from the initial state so a raise never applies to its own transaction.
  • SpendingLimitsPolicy is optional on AuthMultisigSmartConfig, and without it metering is off.

The auth_tx_multisig_smart MAST root and the component's procedure set and storage layout change, so smart multisig code commitments and account IDs change. AuthMultisig, the kernel and miden-protocol are untouched.

@mmagician
mmagician self-requested a review October 21, 2026 06:19

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MASM logic looks largely good. Left a few suggestions and noted one attack.

One other concern is that each iteration of the double loop (for every asset in every note) does 11 syscalls in the worst case. I think a refactor to fix this would be quite significant and I don't have any hard numbers what the improvement would be, in time or cycles, and my hunch is not that much, so probably fine to leave as-is.

I'm also curious about a rolling window design to avoid an attack around a window boundary.

Comment on lines +424 to +426
#! - Amount-Based Thresholds: `spending_limits::enforce_spending_limits` will additionally return a
#! spending-derived threshold, combined via `u32max` with `policy_threshold` before the final
#! `compute_tx_threshold` fallback, and a `spending_requires_delay` flag for the delayed path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spending_requires_delay shouldn't be needed since we removed the requires_delay flag as part of #3043 right?

Comment on lines +39 to +42
# Map entries: [BUDGET_KEY] -> [limit, 0, 0, 0]
# BUDGET_KEY is a faucet key [faucet_id_suffix, faucet_id_prefix, 0, 0] or COMBINED_BUDGET_KEY. An
# absent entry, equivalently a zero limit, means the budget is not configured.
const SPENDING_LIMITS_SLOT = word("miden::standards::auth::multisig_smart::spending_limits")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC the faucet key identifies an asset, right? If so, I'd change this to an ASSET_ID as it is the canonical id of an asset and makes it clearer what this represents. Additionally, it would seamlessly support future extensions like #3075.

Comment on lines +55 to +57
const UNCOVERED_ASSET_REJECT = 1

const UNCOVERED_ASSET_POLICY_MAX = 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const UNCOVERED_ASSET_REJECT = 1
const UNCOVERED_ASSET_POLICY_MAX = 1
const UNCOVERED_ASSET_REJECT = 1
const UNCOVERED_ASSET_POLICY_MAX = UNCOVERED_ASSET_REJECT

nit: define the max as the highest variant

Comment on lines +62 to +63
# Largest delta `tx::update_expiration_block_delta` accepts (u16).
const MAX_EXPIRATION_BLOCK_DELTA = 65535

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import from miden::protocol::tx instead.

Comment on lines +157 to +163
dup.2 neq
# => [is_new_window_length, old_generation, window_blocks, uncovered_asset_policy]

if.true
add.1
end
# => [config_generation, window_blocks, uncovered_asset_policy]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid the branch by setting config_generation = old_generation + is_new_window_length.

Comment on lines +745 to +771
# ----- Call the oracle: get_conversion_rate(SOURCE = ASSET_ID, TARGET = BASE_ASSET_ID). -----
# The foreign procedure takes two words; the rest of its 16-element frame is padding.
padw padw
# => [pad(8), BASE_ASSET_ID, ASSET_ID]

swapdw
# => [BASE_ASSET_ID, ASSET_ID, pad(8)]

swapw
# => [ASSET_ID, BASE_ASSET_ID, pad(8)]

procref.price_oracle::get_conversion_rate
# => [GET_CONVERSION_RATE_ROOT, ASSET_ID, BASE_ASSET_ID, pad(8)]

push.SPENDING_ORACLE_SLOT[0..2] exec.native_account::get_initial_item
# => [oracle_id_suffix, oracle_id_prefix, max_oracle_block_age, 0, GET_CONVERSION_RATE_ROOT,
# ASSET_ID, BASE_ASSET_ID, pad(8)]

movup.3 drop movup.2 loc_store.CONVERT_MAX_BLOCK_AGE_LOC
# => [oracle_id_suffix, oracle_id_prefix, GET_CONVERSION_RATE_ROOT, ASSET_ID, BASE_ASSET_ID,
# pad(8)]

exec.tx::execute_foreign_procedure
# => [last_updated, num, den, pad(13)]

swapw.3 dropw dropw dropw movup.3 drop
# => [last_updated, num, den]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we encapsulate the call to price_oracle::get_conversion_rate in a public price_oracle::get_foreign_conversion_rate procedure for better abstraction and future reuse (i.e. the stack padding, invocation and stack truncation)?


# ----- Bound the age of the oracle state just read, in blocks. The kernel keeps the minimum
# of all deltas set, so this can only tighten what the approval expiration set. -----
loc_load.CONVERT_MAX_BLOCK_AGE_LOC exec.tx::update_expiration_block_delta

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: reads a local we store a few lines earlier; keep it on the stack.

loc_load.CONVERT_MAX_BLOCK_AGE_LOC exec.tx::update_expiration_block_delta
# => [last_updated, num, den]

# ----- A zero last_updated means the provider could not, or would not, price the pair. -----

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# ----- A zero last_updated means the provider could not, or would not, price the pair. -----
# ----- A zero last_updated means the provider does not price the pair. -----

#! - `window_blocks` is not a valid `u32` field element (`ERR_WINDOW_BLOCKS_NOT_U32`).
#!
#! Invocation: exec
proc current_window_index(window_blocks: u32) -> u32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exploit Scenario:

  • An attacker controls enough signatures to meet the default threshold, for example stolen keys. These are the signers that the limits must stop. The four procedures that edit the limits have a stronger policy, as the PR docs recommend.
  • The account has a per-faucet USDC limit L and window_blocks = 1000. The last spend was in window 3, and the chain tip is now in window 10.
  • The attacker submits transactions in a chain, one after the other or in one batch. Each one uses a reference block from the next window (4, 5, ..., 10), sets approval expiration to 0, and sends L USDC.
  • Each transaction sees tracked < window_index and gets a new budget. In total, 7·L leaves the account in one real window. If the account was idle for k windows, the attacker can take about k·L.
  • This works for per-faucet-only setups and for base-asset spends. On the oracle path, the attacker gains about max_oracle_block_age / window_blocks extra windows. This is still more than zero when the windows are short.

Recommendation:

  • When the policy is enabled, make enforce_spending_limits call tx::update_expiration_block_delta(min(window_blocks, 65535)). Then the reference block is at most one window old when the transaction is included, so the maximum spend is 2·L across a window boundary. The fixed-window design already allows that much.

Comment on lines +517 to +518
exec.tx::get_reference_block_number
# => [reference_block_num, window_blocks]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider a rolling window design to enforce "more than spending_limit can never be spent in any consecutive window_blocks"? I think this would disallow the window-boundary 2*L attack mentioned in a previous comment. I haven't thought through the other implications of a rolling window, though.

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I skimmed through the spending_limits.masm module - I wonder whether this current design is overcomplicating things.

Specifically, my worry is that per-faucet limits are going to get out of hand.
First of all, they introduce code review and maintenance complexity.
Second of all, configuring them for the account introduces overhead that I don't think is necessary in practice. The main use case, I think, is for an account to configure a total spending limits, denominated in a base currency.

So I'd probably just keep the oracle-based accounting.

@onurinanc

Copy link
Copy Markdown
Collaborator Author

@mmagician I agree we should drop the per-faucet limit since it simplifies the setup. Then, we would just have one that "a priced asset is charged to the budget, everything else falls to the policy."

The case I was thinking about is unpriced assets. I think the policy covers it. An unpriced asset either passes under allow or stops under reject, and the cleanest option, once #3044 is in, is that unpriced assets automatically fall to the delayed execution instead of being rejected.

So I would remove the per-faucet layer because of the configuration overhead and keep the combined budget as the only budget.

# Conflicts:
#	crates/miden-standards/src/account/auth/multisig_smart/component.rs
@mmagician

Copy link
Copy Markdown
Collaborator

So I would remove the per-faucet layer because of the configuration overhead and keep the combined budget as the only budget.

Thank you! We discussed this internally too and agree that this is the preferred approach.

One other requirement to make sure is that if the oracle is stale (or unavailable in some way), then we should still be able to execute the transaction. I think an automatic "disable limits if oracle is stale" is not ideal due to potential attacks on the oracle, so I think we'd have two options:

  1. Change to a different oracle, then execute
  2. Explicitly disable spending limits (via update_spending_limits_policy(window_blocks=0), IIUC?), then execute

IIUC both are already supported - maybe still worth adding a line somewhere that describes the overall approach of what to do in the faulty oracle scenario?

@onurinanc

Copy link
Copy Markdown
Collaborator Author

Yes, both works now.

  • update_spending_oracle switches the oracle, and
  • update_spending_limits_policy with window_blocks = 0 turns the limits off. I will add a short note on what to do when the oracle has a fault..

In addition, I think what is worth more is letting the budget run without an oracle. With the oracle id unset, the base asset is still charged one to one and everything else falls to the uncovered-asset policy. If the oracle fails, the account keeps sending its base asset under the limit as usual.

@mmagician

Copy link
Copy Markdown
Collaborator

In addition, I think what is worth more is letting the budget run without an oracle. With the oracle id unset, the base asset is still charged one to one and everything else falls to the uncovered-asset policy. If the oracle fails, the account keeps sending its base asset under the limit as usual.

That could work, but I'd only implement that if it simplifies things. The fallback of disabling the limits is sounds (an explicit, conscious decision) IMO

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall design looks good to me. Found two medium severity issues.

Comment on lines +54 to +60
# Word layout: [window_blocks, unpriced_asset_policy, config_generation, 0]
type = [
{ name = "window_blocks", type = "u32" },
{ name = "unpriced_asset_policy", type = "u8" },
{ name = "config_generation", type = "felt" },
{ type = "void" },
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's avoid duplicating the layout here. The type already specifies that in a stronger way. Applies across the PR.

Suggested change
# Word layout: [window_blocks, unpriced_asset_policy, config_generation, 0]
type = [
{ name = "window_blocks", type = "u32" },
{ name = "unpriced_asset_policy", type = "u8" },
{ name = "config_generation", type = "felt" },
{ type = "void" },
]
type = [
{ name = "window_blocks", type = "u32" },
{ name = "unpriced_asset_policy", type = "u8" },
{ name = "config_generation", type = "felt" },
{ type = "void" },
]

Comment on lines +227 to +231
push.MAX_AMOUNT swap
# => [sum, max_amount]

dup dup.2 lt
# => [is_below_max, sum, max_amount]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
push.MAX_AMOUNT swap
# => [sum, max_amount]
dup dup.2 lt
# => [is_below_max, sum, max_amount]
push.MAX_AMOUNT swap
# => [sum, max_amount]
dup lt.MAX_AMOUNT
# => [is_below_max, sum, max_amount]

nit: this is easier to read

Comment on lines +490 to +496
# A hard gate on the fungible assets this transaction sends out. It runs after the signature
# check for the same reason `multisig::record_and_assert_new_tx` and `apply_approval_expiration`
# do: the host verifies, when the unauthorized event fires, that the summary's account delta and
# expiration delta still match the kernel's, so the budget writes and the expiration tightening
# must stay out of the signed summary and past that check. An approval therefore
# survives a window rollover, and a spend over a budget fails here with the spending-limit error.
exec.spending_limits::enforce_spending_limits

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# A hard gate on the fungible assets this transaction sends out. It runs after the signature
# check for the same reason `multisig::record_and_assert_new_tx` and `apply_approval_expiration`
# do: the host verifies, when the unauthorized event fires, that the summary's account delta and
# expiration delta still match the kernel's, so the budget writes and the expiration tightening
# must stay out of the signed summary and past that check. An approval therefore
# survives a window rollover, and a spend over a budget fails here with the spending-limit error.
exec.spending_limits::enforce_spending_limits
# Runs after tx summary creation so its budget writes and expiration tightening stay out of it.
# This makes an approval survive a window rollover.
exec.spending_limits::enforce_spending_limits

I find this a bit confusing as-is:

  • "so the budget writes and the expiration tightening must stay out of the signed summary and past that check". This isn't really true. We could also write the budget before the tx summary is created and then the host wouldn't complain. So I don't think this is an actual reason for doing this. The real reason is that, similar to the approval expiration, we don't this state to be part of the tx summary as it would make later execution potentially fail. This is the window rollover rationale which is a good idea.
  • "for the same reason multisig::record_and_assert_new_tx". I don't think this is accurate because that procedure has no choice but to run after tx summary creation because it needs the commitment itself, so it can't run before. This is not quite the same rationale as for the approval expiration, so I find this more confusing than helpful.

Comment on lines +16 to +32
# Spending limits (a window budget in a base asset) for the multisig smart account.
#
# Every fungible asset a transaction sends out through an output note is priced into the base asset
# and charged to the account's budget. The base asset is charged one to one; any other asset is
# priced through the configured oracle and, without a rate for it, falls to the unpriced-asset
# policy. The budget resets at fixed window boundaries derived from the reference block number. A
# transaction that would push the budget over its limit fails regardless of how many approvers
# signed it. The policy never changes how many signatures a transaction needs, it only decides
# whether the transaction is allowed at all. Non-fungible assets are outside the policy.
#
# Window binding: while the policy is enabled, every transaction expires `window_blocks` blocks (at
# most 65535) after its reference block. A reference block is therefore at most one window old at
# inclusion, so an idle window cannot be reopened later by pointing an older reference block at it.
#
# Faulty oracle: the base asset is priced without reading the oracle, so a faulty oracle never
# blocks it. Other assets stop under `reject` until governance points the budget at another oracle
# with `update_spending_oracle` or disables the limits with `update_spending_limits_policy`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice overview 👍

Comment on lines +253 to +254
push.0.0.0 movdn.3 movdn.3 movdn.3
# => [limit, 0, 0, 0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
push.0.0.0 movdn.3 movdn.3 movdn.3
# => [limit, 0, 0, 0]
push.0.0.0 movup.3
# => [limit, 0, 0, 0]

Comment on lines +631 to +635
# => [last_updated, num, den, amount]

# ----- A zero last_updated means the provider does not price the pair. -----
eq.0
# => [is_unpriced, num, den, amount]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're dropping last_updated here meaning we don't enforce any staleness on the oracle's state. This seems ok, but want to double-check that this is intentional.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, intentional. The configured block age bounds how old the oracle read can be at inclusion, not how old the rate is. The rate's own age (last_updated is a unix timestamp) is left to the oracle operator, on chain a zero last_updated just means unpriced.

Comment on lines +644 to +645
movup.2 exec.fee::convert_amount
# => [converted_amount]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The running total can wrap the field and skip the limit.

  • saturating_add_amount requires both inputs to be at most MAX_AMOUNT, which is 0x7fffffff80000000 (2^63 - 2^31). Only then can the sum not wrap.
  • fee::convert_amount does not keep to that limit. It only asserts that the result is below 2^63 (MAX_CONVERTED_AMOUNT_HI_LIMB = 0x80000000). So a converted amount can land between MAX_AMOUNT + 1 and 2^63 - 1.

Fix convert_amount itself, because its doc says "overflows a fungible asset amount" but it checks 2^63. It should check fungible_asset::MAX_AMOUNT.

@mmagician do you recall why we used "bound the high limb so the recombined amount stays below 2^63" in #3298?

#!
#! Invocation: call
@account_procedure
pub proc set_spending_limit(limit: AssetAmount)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not limit limit to MAX_AMOUNT, but the Rust SpendingBudget::new does.

#! - window_index is floor(reference_block_num / window_blocks).
#!
#! Invocation: exec
proc current_window_index(window_blocks: u32) -> u32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Medium: an idle window can be reused, so the burst is 3x the limit, not 2x

We accept 2x the limit at a window boundary. This attack goes further. The expiration delta is min(window_blocks, 65535), so a reference block can be in the previous window when the transaction is included. If window k had no spends:

  1. Transaction A uses a reference block at the end of window k and is included early in window k+1. The tracker is older than k, so the budget is fresh and A spends L. The tracker is now at window k.
  2. Transaction B uses a reference block in window k+1. The tracker (k) is older, so the budget is fresh again and B spends L.
  3. Transaction C uses a reference block in window k+2, just after the boundary, and spends L.

If A and B are included just before the boundary and C just after it, the attacker spends 3L within a few blocks. Also, at any time in window k+1, steps 1 and 2 give 2L without waiting for a boundary.

Fix: make the transaction expire at the end of the window of its reference block: delta = min(window_blocks - ref % window_blocks, 65535). Then the reference window is always the inclusion window, and the worst case is 2L. The cost is that a transaction built near the end of a window stays valid for only a short time. If you keep the current design, document 3L as the real bound.


Expiration at the end of the window is bad for UX, so we may have to accept the 3L bound. There are some middlegrounds, but afaiu none of them remove the 3L bound, they only require a shorter time window for it.

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the direction this is going with the simplifications made to drop per-asset budgets.
In terms of functionality, there is one more refactor that would slightly change how the user interfaces with the limit windows, but which would lead to a) implementation that's easier to reason about; b) actually simpler configuration for the users. See below for the details of this idea.

if.true
drop drop drop
# => []
else

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternative: I wonder if we could move one level of nesting out of this procedure, and wrap enforce_spending_limits in it.

So that in crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm we'd have:

exec.get_initial_spending_config
# => [window_blocks, unpriced_asset_policy, config_generation]

dup eq.0
# => [is_disabled, window_blocks, unpriced_asset_policy, config_generation]

if.true
    exec.spending_limits::enforce_spending_limits
    # => []
else
    drop drop drop
end

and here, enforce_spending_limits(window_blocks, unpriced_asset_policy, config_generation)

TBH, I'm not 100% convinced about this change - overall I'd like to reduce nesting (we already go two further levels deeper here) but also, having the initial if/else coming from get_initial_spending_config does belong nicely in enforce_spending_limits

Comment on lines +726 to +769
proc load_window_spend(window_index: u32, current_config_generation: felt) -> (AssetAmount, u32)
push.SPENDING_TRACKER_SLOT[0..2]
# => [slot_suffix, slot_prefix, window_index, current_config_generation]

exec.active_account::get_item
# => [spent, tracked_window_index, tracked_generation, 0, window_index, current_config_generation]

movup.3 drop
# => [spent, tracked_window_index, tracked_generation, window_index, current_config_generation]

movup.2 movup.4 eq
# => [is_current_generation, spent, tracked_window_index, window_index]

if.true
swap
# => [tracked_window_index, spent, window_index]

dup dup.3 u32lt
# => [is_earlier_window, tracked_window_index, spent, window_index]

if.true
# The tracker is from a past window, so this transaction opens a fresh one.
drop drop
# => [window_index]

push.0
# => [spent, effective_window_index]
else
# The tracker is at, or ahead of, the current window; keep accumulating into it.
movup.2 drop
# => [tracked_window_index, spent]

swap
# => [spent, effective_window_index]
end
else
# The tracker predates the current configuration, so it carries no usable total.
drop drop
# => [window_index]

push.0
# => [spent, effective_window_index]
end
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the concept of "generations", and current and tracked and effective windows a little hard to keep track of. Could we drop config_generation by storing the window's end block in the tracker, instead of its index?

Today the generation is needed, AFAIU, because a window index only means something paired with the length it was computed with. Without it, shortening the window would reset the budget (that is probably ok and intended ✅) . But, lengthening it would hit the "ahead" branch in load_window_spend and keep the old spend charged until the new index catches up with the old number - this might be very far ahead into the future. For example, with 1000-block windows, a spend at block 2,000,500 is tracked under index 2000. After switching to 10,000-block windows the current index is 200, and the spend stays charged until block 20,010,000 (that's ~18M blocks more 😱 )

If the tracker stored sth like [spent, window_end_block, 0, 0] instead, load_window_spend would come down to one comparison:

  • ref_block < tracked_end: keep adding to the tracked window
  • else: spent = 0, window_end = (ref_block / window_blocks + 1) * window_blocks

That removes the generation, the bump in update_spending_limits_policy and some nested branches. A length change would also stop resetting the budget: what was already spent keeps counting until its window ends (and later windows follow the new length already).

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

IIUC, a tx executed with all required signatures just before the end of the window does mean the tx is very likely to expire before being included in a block, but it "only" means the tx needs to be re-executed and re-proven with a block from the new window if it failed to go through. Or a client could have a min number of blocks before the end of a window below which it does not execute and prove but instead waits for the new window to "start", i.e. its first ref block coming available. This is not ideal, but it can be automatic rather than requiring all approvers to re-sign.

I think one fix is something like a grace period that every tx gets at minimum, that is much lower than the window length, but I'm not sure if it's necessary.

Comment on lines +238 to +241
# a fungible asset amount is capped below 2^63; a running total of amounts up to this cap
# cannot wrap the field, which is what the callers' saturating additions rely on
dup lte.FUNGIBLE_ASSET_MAX_AMOUNT assert.err=ERR_FEE_CONVERTED_AMOUNT_OVERFLOW
# => [converted_amount]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: reads like commentary specific to this PR, but this is a general purpose procedure. Consider just saying that we ensure the fungible asset amount is within bounds, and do not repeat the bound, because it's not 2^63 as stated here.

Comment on lines +120 to +124
exec.tx::execute_foreign_procedure
# => [last_updated, num, den, pad(13)]

swapw.3 dropw dropw dropw movup.3 drop
# => [last_updated, num, den]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think movdnw.3 has the same effect here and is cheaper as I believe swapw is a compound instruction that expands into multiple.

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ✅ I think the new "end-block" approach is cleaner to read.

One extra node: now, a window that was set as too long can no longer be fixed by shortening it or by disabling the policy (that's ok, just worth documenting maybe?)

Also on @PhilippGackstatter's approval comment: I think if such a tx expires, it's an acceptable risk, and as he pointed out, signatures don't need to be re-gathered from the signers. I would not adapt the design to fix this (if needed later, a client can provide a workaround as suggested)

@mmagician
mmagician added this pull request to the merge queue Oct 30, 2026
Merged via the queue into next with commit 0fd3860 Oct 30, 2026
19 checks passed
@mmagician
mmagician deleted the feat-spending-limits branch October 30, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add spending limits to AuthMultisigSmart

3 participants