Repository navigation
feat(standards): add spending limits to AuthMultisigSmart - #4081
Conversation
PhilippGackstatter
left a comment
There was a problem hiding this comment.
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.
| #! - 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. |
There was a problem hiding this comment.
spending_requires_delay shouldn't be needed since we removed the requires_delay flag as part of #3043 right?
| # 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") |
There was a problem hiding this comment.
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.
| const UNCOVERED_ASSET_REJECT = 1 | ||
|
|
||
| const UNCOVERED_ASSET_POLICY_MAX = 1 |
There was a problem hiding this comment.
| 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
| # Largest delta `tx::update_expiration_block_delta` accepts (u16). | ||
| const MAX_EXPIRATION_BLOCK_DELTA = 65535 |
There was a problem hiding this comment.
Import from miden::protocol::tx instead.
| 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] |
There was a problem hiding this comment.
Avoid the branch by setting config_generation = old_generation + is_new_window_length.
| # ----- 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] |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. ----- |
There was a problem hiding this comment.
| # ----- 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 |
There was a problem hiding this comment.
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.
| exec.tx::get_reference_block_number | ||
| # => [reference_block_num, window_blocks] |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
@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
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:
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? |
|
Yes, both works now.
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
left a comment
There was a problem hiding this comment.
Overall design looks good to me. Found two medium severity issues.
| # 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" }, | ||
| ] |
There was a problem hiding this comment.
nit: Let's avoid duplicating the layout here. The type already specifies that in a stronger way. Applies across the PR.
| # 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" }, | |
| ] |
| push.MAX_AMOUNT swap | ||
| # => [sum, max_amount] | ||
|
|
||
| dup dup.2 lt | ||
| # => [is_below_max, sum, max_amount] |
There was a problem hiding this comment.
| 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
| # 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 |
There was a problem hiding this comment.
| # 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.
| # 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`. |
There was a problem hiding this comment.
This is a nice overview 👍
| push.0.0.0 movdn.3 movdn.3 movdn.3 | ||
| # => [limit, 0, 0, 0] |
There was a problem hiding this comment.
| push.0.0.0 movdn.3 movdn.3 movdn.3 | |
| # => [limit, 0, 0, 0] | |
| push.0.0.0 movup.3 | |
| # => [limit, 0, 0, 0] |
| # => [last_updated, num, den, amount] | ||
|
|
||
| # ----- A zero last_updated means the provider does not price the pair. ----- | ||
| eq.0 | ||
| # => [is_unpriced, num, den, amount] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| movup.2 exec.fee::convert_amount | ||
| # => [converted_amount] |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
- 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:
- 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.
- 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.
- 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.
…eir reference window
mmagician
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| # 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] |
There was a problem hiding this comment.
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.
| exec.tx::execute_foreign_procedure | ||
| # => [last_updated, num, den, pad(13)] | ||
|
|
||
| swapw.3 dropw dropw dropw movup.3 drop | ||
| # => [last_updated, num, den] |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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)
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.masmmeters every fungible asset in the output notes against its faucet's budget and, when the oracle prices it, the combined budget.auth_txafter the threshold assertion, so a violation surfaces in the signed execution rather than in the unsigned probe.max_oracle_block_age, and a zero or overflowing rate fails the transaction.SpendingLimitsPolicyis optional onAuthMultisigSmartConfig, and without it metering is off.The
auth_tx_multisig_smartMAST root and the component's procedure set and storage layout change, so smart multisig code commitments and account IDs change.AuthMultisig, the kernel andmiden-protocolare untouched.