From 7e20eab377630aa3bcb66f71050f7d8130638a1a Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Tue, 18 Aug 2026 17:01:21 +0400 Subject: [PATCH 1/5] fix(consensus): force the devnet constants to be const-evaluated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEVNET` is initialized by a call to `devnet`, and an associated const whose initializer is a `const fn` call is not evaluated by a runtime use of it — so `devnet`'s body escaped the compile-time check the other three networks get by being const items their constructors return. A rate above the ceiling in `devnet` compiled clean while the same edit to `MAINNET` failed the build. A `const _` item of the type forces the evaluation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMgCpP2Ef2xh3FRqpMku7E --- crates/consensus/src/consensus_constants.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/consensus/src/consensus_constants.rs b/crates/consensus/src/consensus_constants.rs index 6c9764d366..c296e9b2a1 100644 --- a/crates/consensus/src/consensus_constants.rs +++ b/crates/consensus/src/consensus_constants.rs @@ -119,10 +119,9 @@ pub struct ConsensusConstants { impl ConsensusConstants { /// Committee size used when `TARI_DEVNET_COMMITTEE_SIZE` is unset, and the size [`Self::DEVNET`] - /// is const-evaluated at. + /// is built at. pub const DEFAULT_DEVNET_COMMITTEE_SIZE: u32 = 7; - /// Const-evaluates [`Self::devnet`], so every shipped network's constants are checked at compile - /// time whether they are a const item or a const function of one argument. + /// Devnet at the default committee size. pub const DEVNET: Self = Self::devnet(Self::DEFAULT_DEVNET_COMMITTEE_SIZE); pub const ESMERALDA: Self = Self { base_layer_confirmations: 100, @@ -300,17 +299,20 @@ impl ConsensusConstants { } } +/// Forces [`ConsensusConstants::DEVNET`] to be evaluated, and with it `devnet`'s body. The other +/// networks are const items their constructors return, so they are evaluated wherever they are +/// built; `devnet` takes an argument and builds inline, so nothing else reaches it at compile time. +const _: ConsensusConstants = ConsensusConstants::DEVNET; + impl From for ConsensusConstants { fn from(network: Network) -> Self { match network { Network::MainNet => Self::mainnet(), // Allow committee size to be overridden for LocalNet - Network::LocalNet => Self::devnet( - env::var("TARI_DEVNET_COMMITTEE_SIZE") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(Self::DEFAULT_DEVNET_COMMITTEE_SIZE), - ), + Network::LocalNet => env::var("TARI_DEVNET_COMMITTEE_SIZE") + .ok() + .and_then(|s| s.parse().ok()) + .map_or(Self::DEVNET, Self::devnet), Network::Esmeralda => Self::esmeralda(), Network::StageNet | Network::NextNet | Network::Igor => Self::testnet(), } From 57d2ab3c367f5ae341f0512dc52fd96531e09de7 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Tue, 18 Aug 2026 16:57:27 +0400 Subject: [PATCH 2/5] fix(engine)!: cap the fee intent's compute at a flat credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payment raised the compute allowance inside the fee intent, but the fee checkpoint — the only thing that makes a fee collectable — is taken after the last fee instruction. An abort before that leaves no checkpoint to fall back to, so the transaction settles as a rejection that collects nothing. Fee-intent compute was therefore payment-funded but not payment-bound: a larger `max_fee` raised the ceiling on work that was never paid for, repeatably, with the same funds. That made the main intent pointless. The same work costs nothing when it fails in the fee intent and costs a fee-intent commit when it fails in the main instructions, for an allowance that was no smaller. The fee intent now runs on a flat `FREE_COMPUTE_GRACE_POINTS`, whatever it has paid — the credit is sized to source a fee and that is all it authorizes. Payments raise the allowance only past the checkpoint, where a failure still commits the fee intent and collects. Unpaid work per transaction is bounded by a constant again rather than by the fee the sender declares. `StateTracker::compute_allowance` replaces `wasm_point_allowance` and reports what authorizes the allowance alongside the points, so exceeding it in the fee intent is reported as the credit being exhausted — paying more will not help, and the message says to move the work to the main instructions — rather than as underpayment. BREAKING CHANGE: compute in the fee intent is capped at `FREE_COMPUTE_GRACE_POINTS` regardless of the fee paid, so a transaction that ran more than that in its fee instructions is now rejected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMgCpP2Ef2xh3FRqpMku7E --- crates/engine/src/fees/fee_table.rs | 6 ++ crates/engine/src/runtime/error.rs | 10 +++ crates/engine/src/runtime/impl.rs | 6 +- crates/engine/src/runtime/mod.rs | 4 +- crates/engine/src/runtime/tracker.rs | 87 +++++++++++++------- crates/engine/src/wasm/error.rs | 6 ++ crates/engine/src/wasm/process.rs | 45 ++++++---- crates/engine/tests/compute_fee_budget.rs | 57 +++++++++++-- crates/engine/tests/native_compute_budget.rs | 10 +-- crates/engine_types/src/limits.rs | 28 +++---- 10 files changed, 182 insertions(+), 77 deletions(-) diff --git a/crates/engine/src/fees/fee_table.rs b/crates/engine/src/fees/fee_table.rs index 93109d965f..fee31be0f6 100644 --- a/crates/engine/src/fees/fee_table.rs +++ b/crates/engine/src/fees/fee_table.rs @@ -217,6 +217,12 @@ impl WasmMeteringRate { } } + /// Whether this rate prices WASM execution at all. A rate that does not carries no compute + /// bound beyond the per-transaction hard cap, since there is nothing for a payment to fund. + pub fn prices_execution(&self) -> bool { + self.per_point_cost > 0 + } + /// The WASM metering points that `fees_paid` microtari pre-fund: the inverse of the fee module's /// charge (`points / divisor * per_point_cost`). `None` when WASM execution is not priced /// (`per_point_cost == 0`) — payment cannot fund what is not charged, so no payment-derived diff --git a/crates/engine/src/runtime/error.rs b/crates/engine/src/runtime/error.rs index 60a3dd027f..abb5de852d 100644 --- a/crates/engine/src/runtime/error.rs +++ b/crates/engine/src/runtime/error.rs @@ -225,6 +225,16 @@ pub enum RuntimeError { TemplateNotFound { template_address: TemplateAddress }, #[error("Insufficient fees paid: required {required_fee}, paid {fees_paid}")] InsufficientFeesPaid { required_fee: u64, fees_paid: u64 }, + #[error( + "Native verification requiring {required_points} points exceeds the fee intent's {credit_points} points of \ + compute credit: {consumed_points} already consumed. The credit is a flat allowance for sourcing a fee and \ + does not rise with the fee paid — move this work to the main instructions." + )] + FeeIntentComputeExceeded { + required_points: u64, + consumed_points: u64, + credit_points: u64, + }, #[error( "Insufficient fees to fund native verification requiring {required_points} points: {consumed_points} of \ {allowance} allowance points already consumed" diff --git a/crates/engine/src/runtime/impl.rs b/crates/engine/src/runtime/impl.rs index 15e2afcabb..6f4481fc15 100644 --- a/crates/engine/src/runtime/impl.rs +++ b/crates/engine/src/runtime/impl.rs @@ -169,7 +169,7 @@ use crate::{ locking::{LockError, LockedSubstate}, pay_fee::PayFee, scope::PushCallFrame, - tracker::{FinalizedState, StateTracker}, + tracker::{ComputeAllowance, FinalizedState, StateTracker}, }, state_store::StateReader, template::LoadedTemplate, @@ -4009,8 +4009,8 @@ where self.tracker.accumulated_native_points() } - fn wasm_point_allowance(&self) -> Option { - self.tracker.wasm_point_allowance() + fn compute_allowance(&self) -> Option { + self.tracker.compute_allowance() } fn resolve_args( diff --git a/crates/engine/src/runtime/mod.rs b/crates/engine/src/runtime/mod.rs index 72a9b3113c..dc0b6da344 100644 --- a/crates/engine/src/runtime/mod.rs +++ b/crates/engine/src/runtime/mod.rs @@ -110,7 +110,7 @@ use tari_template_lib::{ stealth::StealthTransferStatement, }, }; -pub use tracker::{FinalizedState, StateTracker}; +pub use tracker::{ComputeAllowance, ComputeFunding, FinalizedState, StateTracker}; pub use working_state::ChargeableState; use crate::runtime::{locking::LockedSubstate, scope::PushCallFrame}; @@ -284,7 +284,7 @@ pub trait RuntimeInterface { /// The maximum Wasmer metering points the transaction may consume given the fees paid so far, /// used by `WasmProcess::invoke` to cap each call's allowance so unpaid compute cannot exceed /// the grace. `None` means no payment-funded bound applies (only the per-transaction hard cap). - fn wasm_point_allowance(&self) -> Option; + fn compute_allowance(&self) -> Option; fn resolve_args( &self, diff --git a/crates/engine/src/runtime/tracker.rs b/crates/engine/src/runtime/tracker.rs index 0ad5bf46c1..29aef59c0c 100644 --- a/crates/engine/src/runtime/tracker.rs +++ b/crates/engine/src/runtime/tracker.rs @@ -102,6 +102,25 @@ impl FinalizedState { } } +/// The compute a transaction may still consume, and what authorizes it. +#[derive(Debug, Clone, Copy)] +pub struct ComputeAllowance { + /// Metering points, counting WASM execution and native verification together. + pub points: u64, + pub funding: ComputeFunding, +} + +/// What is paying for the compute an allowance authorizes. Which one binds decides what a +/// transaction that exceeds it is told: a payment-funded allowance rises with the fee, the fee +/// intent's credit does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComputeFunding { + /// The flat credit the fee intent runs on, sized to source a fee and nothing more. + FeeIntentCredit, + /// What the payment has left after the charges standing against it. + Payment, +} + #[derive(Debug)] pub struct StateTracker { working_state: Option>, @@ -148,24 +167,25 @@ impl StateTracker { self.transaction_weight } - /// The maximum WASM metering points this transaction may consume given the fees it has paid so - /// far, used by `WasmProcess::invoke` to cap each call's metering allowance. Returns `None` when - /// no payment-funded bound applies (WASM execution is not priced, or this is a dry run) — only - /// the per-transaction hard cap then constrains compute. + /// The compute this transaction may still consume, and what authorizes it. `None` when no bound + /// applies beyond the per-transaction hard cap — WASM execution is not priced, or this is a dry + /// run. Used by `WasmProcess::invoke` to cap each call's metering allowance, and by native + /// verification to pre-charge against the same figure. /// - /// Within the fee intent the allowance is the points the fees paid can cover plus - /// [`limits::FREE_COMPUTE_GRACE_POINTS`] of credit, so a transaction can run its fee-sourcing - /// instructions before it pays, while a transaction that never pays cannot consume more than the - /// grace. Past the fee checkpoint the credit no longer applies: the transaction has paid what its - /// fee intent charged, and the compute it may still run is funded by that payment alone. + /// The fee intent runs on a flat [`limits::FREE_COMPUTE_GRACE_POINTS`] of credit, whatever it + /// has paid. Sourcing a fee is the whole reason a transaction may run anything before paying, + /// and that is all the credit is sized for. Letting a payment raise this allowance would make + /// the fee intent the only place worth executing anything: a failure there leaves no checkpoint + /// to fall back to, so the transaction settles as a rejection that collects nothing, and the + /// work would be done and never paid for — repeatably, with the same funds. Work that needs + /// more than the credit belongs in the main intent, where the fee already paid funds it and a + /// failure still commits the fee intent. /// - /// Compute is funded by what the payment has *left*, not by the whole of it. A transaction that - /// cannot pay for the state it commits commits only its fee intent, and that state was priced - /// onto the charges when the checkpoint was taken — so the charges standing here already - /// include what the fallback costs, bar the compute being authorized. Funding compute out of the - /// full payment instead would let a transaction spend the whole of it on execution and leave its - /// own fee-intent commit unaffordable, which settles as a rejection that collects nothing: the - /// work would be done and never paid for. + /// Past the checkpoint the credit ends and compute is funded by what the payment has *left*, + /// not by the whole of it. The charges standing at the checkpoint are what the fallback commit + /// costs, so funding compute out of the full payment would let a transaction spend the lot on + /// execution and leave its own fee-intent commit unaffordable — a rejection that again collects + /// nothing. /// /// This is measured against the charges *standing when it is asked*. Anything charged after the /// last call — a host call inside the final invocation — is outside the figure, so it bounds the @@ -173,24 +193,26 @@ impl StateTracker { /// /// The exhaust burn is taken over whatever the charges come to, so the charges themselves can /// only spend the payment net of it. - pub fn wasm_point_allowance(&self) -> Option { + pub fn compute_allowance(&self) -> Option { let rate = self.wasm_metering_rate; - // The credit exists only so a transaction can source its fee before paying. Taking the fee - // checkpoint is precisely the point at which that need has been met, so the credit ends there. let is_fee_intent = self.fee_checkpoint.is_none(); self.read_with(|state| { let fee_state = state.fee_state(); - if fee_state.is_dry_run() { + if fee_state.is_dry_run() || !rate.prices_execution() { return None; } - let unspent = spendable_on_charges(fee_state.total_payments(), fee_state.burn_rate_bps()) - .saturating_sub(fee_state.total_charges()); - let funded = rate.points_funded_by(unspent)?; if is_fee_intent { - Some(funded.saturating_add(limits::FREE_COMPUTE_GRACE_POINTS)) - } else { - Some(funded) + return Some(ComputeAllowance { + points: limits::FREE_COMPUTE_GRACE_POINTS, + funding: ComputeFunding::FeeIntentCredit, + }); } + let unspent = spendable_on_charges(fee_state.total_payments(), fee_state.burn_rate_bps()) + .saturating_sub(fee_state.total_charges()); + Some(ComputeAllowance { + points: rate.points_funded_by(unspent)?, + funding: ComputeFunding::Payment, + }) }) } @@ -388,15 +410,22 @@ impl StateTracker { max_points: limits::MAX_NATIVE_POINTS_PER_TRANSACTION, }); } - if let Some(allowance) = self.wasm_point_allowance() { + if let Some(allowance) = self.compute_allowance() { let consumed = self .accumulated_wasm_points() .saturating_add(self.accumulated_native_points()); - if consumed.saturating_add(points) > allowance { + if consumed.saturating_add(points) > allowance.points { + if allowance.funding == ComputeFunding::FeeIntentCredit { + return Err(RuntimeError::FeeIntentComputeExceeded { + required_points: points, + consumed_points: consumed, + credit_points: allowance.points, + }); + } return Err(RuntimeError::InsufficientFeesForNativeExecution { required_points: points, consumed_points: consumed, - allowance, + allowance: allowance.points, }); } } diff --git a/crates/engine/src/wasm/error.rs b/crates/engine/src/wasm/error.rs index 46e36b2a67..a4abeb055a 100644 --- a/crates/engine/src/wasm/error.rs +++ b/crates/engine/src/wasm/error.rs @@ -28,6 +28,12 @@ pub enum WasmExecutionError { the paid fees cover. Increase the transaction fee." )] InsufficientFeesForCompute { consumed_points: u64 }, + #[error( + "The fee intent exceeded its {credit_points} points of compute credit after consuming {consumed_points} WASM \ + metering points. The credit is a flat allowance for sourcing a fee and does not rise with the fee paid — \ + move this work to the main instructions." + )] + FeeIntentComputeExceeded { consumed_points: u64, credit_points: u64 }, #[error("Expected function {function} to return a pointer")] ExpectedPointerReturn { function: String }, #[error("Memory access error: {0}")] diff --git a/crates/engine/src/wasm/process.rs b/crates/engine/src/wasm/process.rs index 959a0811cb..07d1d26604 100644 --- a/crates/engine/src/wasm/process.rs +++ b/crates/engine/src/wasm/process.rs @@ -48,7 +48,7 @@ use wasmer::{AsStoreMut, Function, FunctionEnv, FunctionEnvMut, Instance, Store, use wasmer_middlewares::metering::{MeteringPoints, get_remaining_points, set_remaining_points}; use crate::{ - runtime::Runtime, + runtime::{ComputeFunding, Runtime}, traits::Invokable, wasm::{ LoadedWasmTemplate, @@ -374,20 +374,22 @@ impl Invokable for WasmProcess { // The allowance is shared with native verification (which pre-charges its point cost), so // it is reduced by the combined consumption; the hard cap above bounds WASM work only. let native_consumed = self.env.state().interface().native_points_consumed(); - let paid_allowance_remaining = self - .env - .state() - .interface() - .wasm_point_allowance() - .map(|allowance| allowance.saturating_sub(consumed.saturating_add(native_consumed))); - let points_before = match paid_allowance_remaining { + let allowance = self.env.state().interface().compute_allowance(); + let allowance_remaining = allowance.map(|allowance| { + allowance + .points + .saturating_sub(consumed.saturating_add(native_consumed)) + }); + let points_before = match allowance_remaining { Some(remaining) => per_call_cap.min(budget_remaining).min(remaining), None => per_call_cap.min(budget_remaining), }; - // Whether the paid-fee allowance — not the per-transaction hard cap — is what bounds this - // call. Used to report an out-of-gas trap here as insufficient fees rather than a hit cap. - let fee_allowance_is_binding = - paid_allowance_remaining.is_some_and(|remaining| remaining < budget_remaining && remaining <= per_call_cap); + // Whether the fee allowance — not the per-transaction hard cap — is what bounds this call. + // Used to report an out-of-gas trap here against what authorized the allowance rather than + // as a hit cap. + let binding_funding = allowance_remaining + .is_some_and(|remaining| remaining < budget_remaining && remaining <= per_call_cap) + .then(|| allowance.expect("BUG: allowance_remaining is Some").funding); set_remaining_points(store, &self.instance, points_before); // Expose the in-flight meter to host calls: consumption inside this invocation must be // visible to budget/allowance checks made mid-call (native verification pre-charges, @@ -446,10 +448,21 @@ impl Invokable for WasmProcess { runtime_error: err, }); } - if exhausted && fee_allowance_is_binding { - return Err(WasmExecutionError::InsufficientFeesForCompute { - consumed_points: consumed.saturating_add(points_consumed), - }); + if exhausted { + match binding_funding { + Some(ComputeFunding::FeeIntentCredit) => { + return Err(WasmExecutionError::FeeIntentComputeExceeded { + consumed_points: consumed.saturating_add(points_consumed), + credit_points: limits::FREE_COMPUTE_GRACE_POINTS, + }); + }, + Some(ComputeFunding::Payment) => { + return Err(WasmExecutionError::InsufficientFeesForCompute { + consumed_points: consumed.saturating_add(points_consumed), + }); + }, + None => {}, + } } error!(target: LOG_TARGET, "Error calling function: {}", err); Err(err.into()) diff --git a/crates/engine/tests/compute_fee_budget.rs b/crates/engine/tests/compute_fee_budget.rs index 62c74236da..fa6591faa4 100644 --- a/crates/engine/tests/compute_fee_budget.rs +++ b/crates/engine/tests/compute_fee_budget.rs @@ -1,12 +1,13 @@ // Copyright 2026 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -//! The per-transaction WASM compute budget is bounded by the fees paid. A transaction may run up to -//! `FREE_COMPUTE_GRACE_POINTS` of compute on credit — enough to source its fee (withdraw, claim-burn, -//! AMM swap, …) before it calls `pay_fee` — and beyond that each WASM call's metering allowance is -//! capped to the points the fees paid so far can cover. These tests prove a transaction cannot -//! extract more than the grace of unpaid compute, that paying more raises the allowance, and that -//! fee-sourcing compute within the grace is allowed in the fee intent. +//! The per-transaction WASM compute budget is bounded by the fees paid, except in the fee intent, +//! which runs on a flat `FREE_COMPUTE_GRACE_POINTS` of credit — enough to source its fee (withdraw, +//! claim-burn, AMM swap, …) before it calls `pay_fee`. Past the fee checkpoint each WASM call's +//! metering allowance is capped to the points the fees paid so far can cover. These tests prove a +//! transaction cannot extract more than the credit of unpaid compute, that paying more raises the +//! allowance in the main instructions but not in the fee intent, and that fee-sourcing compute +//! within the credit is allowed. use tari_crypto::ristretto::RistrettoSecretKey; use tari_engine::fees::FeeTable; @@ -83,6 +84,13 @@ fn assert_insufficient_fees(reason: &RejectReason) { ); } +fn assert_fee_intent_credit_exceeded(reason: &RejectReason) { + assert!( + matches!(reason, RejectReason::ExecutionFailure(msg) if msg.contains("compute credit")), + "expected the fee intent's compute credit to be the binding limit, got {reason:?}", + ); +} + /// A transaction that pays only a tiny fee cannot run more compute than that fee funds, even though /// the call is well under the per-transaction hard cap and would otherwise succeed. The fee intent /// still commits and the whole payment is collected, and because the credit does not extend past the @@ -236,5 +244,40 @@ fn fee_intent_cannot_exceed_grace_compute() { .build_and_seal(&key); let reason = test.execute_expect_failure(tx, vec![owner]); - assert_insufficient_fees(&reason); + assert_fee_intent_credit_exceeded(&reason); +} + +/// The fee intent's credit is flat: paying first does not buy more compute inside it. A payment +/// that comfortably funds the call in the main instructions still leaves it trapped when the call +/// runs in the fee intent. +/// +/// Without this, the fee intent would be the only place worth executing anything. A failure there +/// leaves no checkpoint to fall back to, so the transaction settles as a rejection that collects +/// nothing — the same work in the main instructions falls back to a fee-intent commit and is paid +/// for. +#[test] +fn paying_first_does_not_raise_the_fee_intents_allowance() { + let Harness { + mut test, + bench, + account, + owner, + key, + per_round, + } = setup(); + + let rounds = ABOVE_GRACE_POINTS / per_round; + // The payment `paying_more_raises_the_compute_allowance` proves is enough to run this call in + // the main instructions. + let fee_payment = ABOVE_GRACE_POINTS + 10_000_000; + let tx = Transaction::builder_localnet(Epoch(1)) + .with_fee_instructions_builder(|builder| { + builder + .pay_fee_from_component(account, fee_payment) + .call_function(bench, "bench_div_u64", args![rounds]) + }) + .build_and_seal(&key); + + let reason = test.execute_expect_failure(tx, vec![owner]); + assert_fee_intent_credit_exceeded(&reason); } diff --git a/crates/engine/tests/native_compute_budget.rs b/crates/engine/tests/native_compute_budget.rs index 67e0c5d9ae..0bd10440cc 100644 --- a/crates/engine/tests/native_compute_budget.rs +++ b/crates/engine/tests/native_compute_budget.rs @@ -118,10 +118,10 @@ fn enable_point_priced_fees(test: &mut TemplateTest) { test.enable_fees(); } -/// A non-paying transaction whose fee intent carries a statement priced above the grace is rejected -/// by the allowance pre-charge — before any of its crypto runs. The statement's range proof is +/// A transaction whose fee intent carries a statement priced above the credit is rejected by the +/// allowance pre-charge — before any of its crypto runs. The statement's range proof is /// deliberately corrupted: were the verification performed, the failure would be a range-proof -/// error, so the insufficient-fees rejection proves the charge fired first. +/// error, so the credit rejection proves the charge fired first. #[test] fn unpaid_native_verification_traps_before_the_crypto_runs() { let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); @@ -142,7 +142,7 @@ fn unpaid_native_verification_traps_before_the_crypto_runs() { vec![], ); - assert_reject_reason(reason, "Insufficient fees to fund native verification"); + assert_reject_reason(reason, "points of compute credit"); } /// Revealed-only statements (no stealth/confidential inputs or outputs) short-circuit every @@ -230,7 +230,7 @@ fn in_flight_wasm_counts_toward_the_native_allowance() { vec![], ); - assert_reject_reason(reason, "Insufficient fees to fund native verification"); + assert_reject_reason(reason, "points of compute credit"); } /// A paying transaction's native verification is charged under `FeeSource::NativeExecution` at the diff --git a/crates/engine_types/src/limits.rs b/crates/engine_types/src/limits.rs index 1ab62f5361..32e7e7d313 100644 --- a/crates/engine_types/src/limits.rs +++ b/crates/engine_types/src/limits.rs @@ -48,22 +48,20 @@ pub const MAX_WASM_POINTS_PER_TRANSACTION: u64 = 100_000_000; /// admit can trip this one. Tightening it means tightening those caps first. pub const MAX_NATIVE_POINTS_PER_TRANSACTION: u64 = 2_400_000_000; -/// Execution metering points a transaction may consume *before* its fee payments cover them. A -/// transaction sources its fee in the fee intent (withdraw, claim-burn, AMM swap to TARI, stealth -/// transfer, …) and only then calls `pay_fee`, so it must be allowed to run some compute on credit; -/// this bounds that credit. Beyond it, each WASM call's metering allowance is capped to the points -/// the fees paid so far can cover (`WasmProcess::invoke`), and native verification pre-charges its -/// point cost against the same allowance, so a transaction that does not pay traps out-of-gas here -/// rather than consuming the full [`MAX_WASM_POINTS_PER_TRANSACTION`] (or unmetered native crypto) -/// for free. This is the bound on total free compute — WASM and native — a non-paying transaction -/// can extract from a validator. Payments raise the allowance above this value proportionally to -/// the WASM fee rate. +/// Execution metering points the fee intent may consume, whatever it has paid. A transaction sources +/// its fee in the fee intent (withdraw, claim-burn, AMM swap to TARI, stealth transfer, …) and only +/// then calls `pay_fee`, so it must be allowed to run some compute on credit; this bounds that +/// credit. Each WASM call's metering allowance is capped to what remains of it +/// (`WasmProcess::invoke`), and native verification pre-charges its point cost against the same +/// figure, so a fee intent that exceeds it traps out-of-gas rather than consuming the full +/// [`MAX_WASM_POINTS_PER_TRANSACTION`] (or unmetered native crypto). This is the bound on total free +/// compute — WASM and native — a transaction can extract from a validator. /// -/// The credit applies to the fee intent only. Sourcing a fee is the whole reason a transaction may -/// run anything before paying, so once the fee checkpoint is taken the credit ends and the remaining -/// instructions are funded by the payment alone (`StateTracker::wasm_point_allowance`). Extending it -/// past the checkpoint would hand every transaction this many points of compute it never pays for, -/// on top of what it bought. +/// The credit is flat: a payment does not raise it. A fee intent that fails leaves no checkpoint to +/// fall back to, so the transaction settles as a rejection that collects nothing — compute funded by +/// a payment there would be work done and never paid for, repeatably, with the same funds. Anything +/// needing more than the credit belongs in the main instructions, where the fee already paid funds +/// it (`StateTracker::compute_allowance`) and a failure still commits the fee intent. /// /// Sized at ~3x the most expensive legitimate fee-sourcing flow: paying a fee from stealth UTXOs /// (one transfer: fixed cost + 1 stealth change output + up to 64 dust inputs ≈ 10.8M points at From 1059d993508623f7c093cc171119460e3bece78c Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Tue, 18 Aug 2026 17:08:06 +0400 Subject: [PATCH 3/5] docs: the fee intent's compute credit no longer rises with the payment Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMgCpP2Ef2xh3FRqpMku7E --- .../src/content/docs/reference/fees.mdx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/developer-docs/src/content/docs/reference/fees.mdx b/docs/developer-docs/src/content/docs/reference/fees.mdx index 0c539f6ffd..52442afb5c 100644 --- a/docs/developer-docs/src/content/docs/reference/fees.mdx +++ b/docs/developer-docs/src/content/docs/reference/fees.mdx @@ -49,7 +49,7 @@ ended on, and the validator is still paid for the work it did. 1 · Fee intent The fee instructions run: withdraw, claim a Minotari burn, swap, reveal. Charges accrue per host call, template load and signature check. - Compute allowance = what is paid so far + 32M free grace points. + Compute allowance = a flat 32M credit, whatever has been paid. pay_fee records a payment — TARI only, and never zero. @@ -75,7 +75,7 @@ ended on, and the validator is still paid for the work it did. 3 · Main intent The main instructions run against the checkpointed state. Compute allowance = the part of the payment the charges have not spent. - The grace credit is gone; a further pay_fee is rejected here. + The flat credit is gone; a further pay_fee is rejected here. Charges keep accruing per host call and per verification. @@ -180,10 +180,17 @@ Rules and checks applied to a payment: - Metering points accumulate for WASM and native crypto, but are not priced until finalisation. **Compute on credit.** A transaction cannot pay before it has sourced its funds, so the fee intent -runs with a credit of `FREE_COMPUTE_GRACE_POINTS` (32,000,000 points) on top of whatever its payments -already fund. That is roughly 3× the most expensive legitimate fee-sourcing flow. Exhaust it without -paying and execution traps out of gas — it is the hard bound on free compute a non-paying transaction -can extract from a validator. +runs on a credit of `FREE_COMPUTE_GRACE_POINTS` (32,000,000 points) — roughly 3× the most expensive +legitimate fee-sourcing flow. Exhaust it and execution traps out of gas, reported as +`FeeIntentComputeExceeded`. It is the hard bound on free compute a transaction can extract from a +validator. + +**The credit is flat: paying does not raise it.** A fee intent that fails leaves no checkpoint to +fall back to, so the transaction settles as a rejection that collects nothing — compute funded by a +payment here would be work done and never paid for, repeatably, with the same funds. That would also +make the main intent pointless: identical work, free when it fails. Anything needing more than the +credit belongs in the main instructions, where the fee already paid funds it and a failure still +commits the fee intent. --- @@ -223,7 +230,7 @@ The main instructions run against the post-checkpoint state and keep accruing `R allowance = points_funded_by( spendable(payments) − charges_so_far ) ``` -The free grace credit is gone — sourcing the fee was its only purpose, and that is done. Funding +The flat credit is gone — sourcing the fee was its only purpose, and that is done. Funding compute from the *unspent* part of the payment (rather than from the whole of it) is what keeps a transaction from spending its entire payment on execution and then being unable to afford even its own fee-intent fallback, which would settle as a rejection that collects nothing. @@ -233,6 +240,7 @@ Three ceilings apply on top of each other: | Bound | Value | Effect when hit | |---|---|---| | Payment-funded allowance | derived, see above | out-of-gas reported as `InsufficientFeesForCompute` | +| Fee-intent credit (stage 1 only) | 32,000,000 points, flat | out-of-gas reported as `FeeIntentComputeExceeded` | | `MAX_WASM_POINTS_PER_TRANSACTION` | 100,000,000 | out-of-gas, hard cap | | `MAX_NATIVE_POINTS_PER_TRANSACTION` | 2,400,000,000 | `MaxNativeExecutionPointsExceeded` | From 7aa5a68c9cc2227f1f011a10f8a54caa32fdea30 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Wed, 19 Aug 2026 09:10:34 +0400 Subject: [PATCH 4/5] fix(engine): enforce the fee intent's credit on dry runs too Review follow-ups on #2441. `compute_allowance` returned no bound at all on a dry run, so estimation never surfaced the credit and a wallet learned of it only from the rejected submission. The credit is flat and payment-independent, so a dry run can be metered against it at no cost in accuracy; the dry-run guard now sits in the payment branch, which is the one with no payment to derive a bound from. The payment branch's `?` could only fire on the case `prices_execution` already returned on, and would have widened the allowance to unbounded rather than narrowing it to zero if a second case were ever added. It is now `unwrap_or(0)`. The trap path carries the binding allowance through rather than re-reading the constant and asserting the option is populated, so the reported credit is the one that bound the call. Adds a paying variant of the native pre-charge test: nothing pinned that a payment fails to lift the native allowance inside the fee intent. Also corrects the comments describing the old payment-plus-credit rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMgCpP2Ef2xh3FRqpMku7E --- crates/consensus/src/consensus_constants.rs | 5 ++- crates/engine/src/runtime/tracker.rs | 27 ++++++++----- crates/engine/src/wasm/process.rs | 41 ++++++++++---------- crates/engine/tests/native_compute_budget.rs | 33 ++++++++++++++++ 4 files changed, 76 insertions(+), 30 deletions(-) diff --git a/crates/consensus/src/consensus_constants.rs b/crates/consensus/src/consensus_constants.rs index c296e9b2a1..3523031cb7 100644 --- a/crates/consensus/src/consensus_constants.rs +++ b/crates/consensus/src/consensus_constants.rs @@ -301,7 +301,10 @@ impl ConsensusConstants { /// Forces [`ConsensusConstants::DEVNET`] to be evaluated, and with it `devnet`'s body. The other /// networks are const items their constructors return, so they are evaluated wherever they are -/// built; `devnet` takes an argument and builds inline, so nothing else reaches it at compile time. +/// built; `devnet` takes an argument and builds inline. The runtime reference in `From` +/// below does not force the evaluation — a const whose initializer is a `const fn` call is only +/// evaluated by a const context such as this one. It pins `devnet` at the default committee size, +/// which covers the whole body: nothing in it varies with the argument beyond the field it sets. const _: ConsensusConstants = ConsensusConstants::DEVNET; impl From for ConsensusConstants { diff --git a/crates/engine/src/runtime/tracker.rs b/crates/engine/src/runtime/tracker.rs index 29aef59c0c..d1ed73e28f 100644 --- a/crates/engine/src/runtime/tracker.rs +++ b/crates/engine/src/runtime/tracker.rs @@ -187,30 +187,39 @@ impl StateTracker { /// execution and leave its own fee-intent commit unaffordable — a rejection that again collects /// nothing. /// - /// This is measured against the charges *standing when it is asked*. Anything charged after the - /// last call — a host call inside the final invocation — is outside the figure, so it bounds the - /// unpaid work rather than reducing it to zero. - /// - /// The exhaust burn is taken over whatever the charges come to, so the charges themselves can - /// only spend the payment net of it. + /// That payment-funded figure is measured against the charges *standing when it is asked*. + /// Anything charged after the last call — a host call inside the final invocation — is outside + /// it, so it bounds the unpaid work rather than reducing it to zero. The exhaust burn is taken + /// over whatever the charges come to, so the charges themselves can only spend the payment net + /// of it. pub fn compute_allowance(&self) -> Option { let rate = self.wasm_metering_rate; let is_fee_intent = self.fee_checkpoint.is_none(); self.read_with(|state| { - let fee_state = state.fee_state(); - if fee_state.is_dry_run() || !rate.prices_execution() { + if !rate.prices_execution() { return None; } + let fee_state = state.fee_state(); if is_fee_intent { + // The credit binds a dry run as it binds a real one. It is the same figure either + // way, so estimating against it costs no accuracy and is where a wallet finds out + // that the work has to move to the main instructions. return Some(ComputeAllowance { points: limits::FREE_COMPUTE_GRACE_POINTS, funding: ComputeFunding::FeeIntentCredit, }); } + // A dry run is metered at whatever `max_fee` the caller submitted, so past the + // checkpoint there is no payment to derive a bound from. + if fee_state.is_dry_run() { + return None; + } let unspent = spendable_on_charges(fee_state.total_payments(), fee_state.burn_rate_bps()) .saturating_sub(fee_state.total_charges()); Some(ComputeAllowance { - points: rate.points_funded_by(unspent)?, + // `prices_execution` above is the only case that yields no figure, so nothing here + // may widen the allowance by failing to produce one. + points: rate.points_funded_by(unspent).unwrap_or(0), funding: ComputeFunding::Payment, }) }) diff --git a/crates/engine/src/wasm/process.rs b/crates/engine/src/wasm/process.rs index 07d1d26604..1dc076afb8 100644 --- a/crates/engine/src/wasm/process.rs +++ b/crates/engine/src/wasm/process.rs @@ -368,28 +368,29 @@ impl Invokable for WasmProcess { }; let consumed = self.env.state().interface().wasm_points_consumed(); let budget_remaining = limits::MAX_WASM_POINTS_PER_TRANSACTION.saturating_sub(consumed); - // Cap further to the compute the fees paid so far can cover (plus the free-compute grace). - // This bounds the compute an under-paying transaction can extract: it traps out-of-gas once - // it exhausts the paid allowance rather than running up to the per-transaction hard cap. - // The allowance is shared with native verification (which pre-charges its point cost), so - // it is reduced by the combined consumption; the hard cap above bounds WASM work only. + // Cap further to the compute the transaction is authorized to run: the fee intent's flat + // credit, or past the checkpoint what the fees paid can cover. This bounds the compute an + // under-paying transaction can extract: it traps out-of-gas once it exhausts the allowance + // rather than running up to the per-transaction hard cap. The allowance is shared with + // native verification (which pre-charges its point cost), so it is reduced by the combined + // consumption; the hard cap above bounds WASM work only. let native_consumed = self.env.state().interface().native_points_consumed(); - let allowance = self.env.state().interface().compute_allowance(); - let allowance_remaining = allowance.map(|allowance| { - allowance + let allowance_remaining = self.env.state().interface().compute_allowance().map(|allowance| { + let remaining = allowance .points - .saturating_sub(consumed.saturating_add(native_consumed)) + .saturating_sub(consumed.saturating_add(native_consumed)); + (allowance, remaining) }); let points_before = match allowance_remaining { - Some(remaining) => per_call_cap.min(budget_remaining).min(remaining), + Some((_, remaining)) => per_call_cap.min(budget_remaining).min(remaining), None => per_call_cap.min(budget_remaining), }; - // Whether the fee allowance — not the per-transaction hard cap — is what bounds this call. - // Used to report an out-of-gas trap here against what authorized the allowance rather than - // as a hit cap. - let binding_funding = allowance_remaining - .is_some_and(|remaining| remaining < budget_remaining && remaining <= per_call_cap) - .then(|| allowance.expect("BUG: allowance_remaining is Some").funding); + // The allowance when it — not the per-transaction hard cap — is what bounds this call. Used + // to report an out-of-gas trap here against what authorized the allowance rather than as a + // hit cap. + let binding_allowance = allowance_remaining + .filter(|(_, remaining)| *remaining < budget_remaining && *remaining <= per_call_cap) + .map(|(allowance, _)| allowance); set_remaining_points(store, &self.instance, points_before); // Expose the in-flight meter to host calls: consumption inside this invocation must be // visible to budget/allowance checks made mid-call (native verification pre-charges, @@ -449,14 +450,14 @@ impl Invokable for WasmProcess { }); } if exhausted { - match binding_funding { - Some(ComputeFunding::FeeIntentCredit) => { + match binding_allowance.map(|allowance| (allowance.funding, allowance.points)) { + Some((ComputeFunding::FeeIntentCredit, credit_points)) => { return Err(WasmExecutionError::FeeIntentComputeExceeded { consumed_points: consumed.saturating_add(points_consumed), - credit_points: limits::FREE_COMPUTE_GRACE_POINTS, + credit_points, }); }, - Some(ComputeFunding::Payment) => { + Some((ComputeFunding::Payment, _)) => { return Err(WasmExecutionError::InsufficientFeesForCompute { consumed_points: consumed.saturating_add(points_consumed), }); diff --git a/crates/engine/tests/native_compute_budget.rs b/crates/engine/tests/native_compute_budget.rs index 0bd10440cc..a215a13760 100644 --- a/crates/engine/tests/native_compute_budget.rs +++ b/crates/engine/tests/native_compute_budget.rs @@ -145,6 +145,39 @@ fn unpaid_native_verification_traps_before_the_crypto_runs() { assert_reject_reason(reason, "points of compute credit"); } +/// Paying first does not buy more native verification inside the fee intent. The credit is flat, so +/// a payment that would fund the statement several times over past the checkpoint still leaves it +/// rejected by the pre-charge — with the same corrupted proof, so the rejection again proves the +/// charge fired before any crypto ran. +#[test] +fn paying_first_does_not_raise_the_fee_intents_native_allowance() { + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let mint = stealth::generate_mint_statement([100, 1000], 0u64, None); + let (_faucet, faucet_resx) = setup_faucet(&mut test, &mint, None); + let (account, owner, key) = test.create_funded_account(); + enable_point_priced_fees(&mut test); + + // 8 outputs price above the 32M credit (fixed + 8 × per-output ≈ 50M points). + let mut garbage = stealth::generate_mint_statement(vec![100u64; 8], 0u64, None); + let mut rp = garbage.statement.outputs_statement.agg_range_proof.clone().into_vec(); + rp[100] ^= 0xFF; + garbage.statement.outputs_statement.agg_range_proof = rp.try_into().unwrap(); + + // 1 fee unit per point, so this funds the statement's ~50M points four times over. + let reason = test.execute_expect_failure( + Transaction::builder_localnet(Epoch(1)) + .with_fee_instructions_builder(|builder| { + builder + .pay_fee_from_component(account, 200_000_000u64) + .stealth_transfer(faucet_resx, garbage.statement) + }) + .build_and_seal(&key), + vec![owner], + ); + + assert_reject_reason(reason, "points of compute credit"); +} + /// Revealed-only statements (no stealth/confidential inputs or outputs) short-circuit every /// verifier — no balance proof, no range proof — so they must price at zero: free-coins claims and /// revealed→revealed transfers keep their pre-metering fees. From f7cbcb90c57c70258d4100a365182ad2c4d96c78 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Wed, 19 Aug 2026 09:15:46 +0400 Subject: [PATCH 5/5] test(engine): pin that a dry run is bound by the fee intent's credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #2441. The dry-run enforcement was the behavioural change of the previous commit and nothing asserted it, so the guard could drift back to its old placement with every test still green. `TemplateTest::set_dry_run` gives the harness the indexer's estimation path, and the new case fails when the guard moves back. Narrows `charge_native_execution`'s doc, which still listed dry runs among the cases where no allowance applies, and says in the fee reference that estimation enforces the credit — the reason the change is worth anything to a sender. The `const _` comment no longer reads as a rule about when rustc evaluates a const: what rustc was observed to do is why the item is there, and the item holds regardless. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMgCpP2Ef2xh3FRqpMku7E --- crates/consensus/src/consensus_constants.rs | 7 +++-- crates/engine/src/runtime/tracker.rs | 12 ++++---- crates/engine/src/wasm/process.rs | 6 ++-- crates/engine/tests/compute_fee_budget.rs | 28 +++++++++++++++++++ .../src/template_test.rs | 11 +++++++- .../ootle-rs/examples/stealth_transfer.rs | 2 +- .../src/content/docs/reference/fees.mdx | 5 ++++ 7 files changed, 58 insertions(+), 13 deletions(-) diff --git a/crates/consensus/src/consensus_constants.rs b/crates/consensus/src/consensus_constants.rs index 3523031cb7..86fea2af77 100644 --- a/crates/consensus/src/consensus_constants.rs +++ b/crates/consensus/src/consensus_constants.rs @@ -302,9 +302,10 @@ impl ConsensusConstants { /// Forces [`ConsensusConstants::DEVNET`] to be evaluated, and with it `devnet`'s body. The other /// networks are const items their constructors return, so they are evaluated wherever they are /// built; `devnet` takes an argument and builds inline. The runtime reference in `From` -/// below does not force the evaluation — a const whose initializer is a `const fn` call is only -/// evaluated by a const context such as this one. It pins `devnet` at the default committee size, -/// which covers the whole body: nothing in it varies with the argument beyond the field it sets. +/// below is not enough on its own: rustc was observed not to evaluate `DEVNET` for it, the +/// initializer being a `const fn` call. This item does not depend on that — a const context +/// evaluates what it names. It pins `devnet` at the default committee size, which covers the whole +/// body: nothing in it varies with the argument beyond the field it sets. const _: ConsensusConstants = ConsensusConstants::DEVNET; impl From for ConsensusConstants { diff --git a/crates/engine/src/runtime/tracker.rs b/crates/engine/src/runtime/tracker.rs index d1ed73e28f..fd26d5a329 100644 --- a/crates/engine/src/runtime/tracker.rs +++ b/crates/engine/src/runtime/tracker.rs @@ -404,11 +404,13 @@ impl StateTracker { self.read_with(|state| state.fee_state().accumulated_native_points()) } - /// Charges native verification work (priced in WASM-point equivalents) against the - /// payment-funded compute allowance, *before* the work is performed. Errors when the charge - /// would exceed the allowance, so a non-paying transaction traps here — having done none of the - /// priced crypto — rather than extracting it for free. No allowance applies (dry runs, unpriced - /// WASM execution) ⇒ the charge only accumulates, so dry-run fee estimates stay accurate. + /// Charges native verification work (priced in WASM-point equivalents) against the compute + /// allowance, *before* the work is performed. Errors when the charge would exceed the + /// allowance, so a transaction that cannot cover it traps here — having done none of the priced + /// crypto — rather than extracting it for free. Where no allowance applies — unpriced WASM + /// execution, or a dry run past the fee checkpoint — the charge only accumulates, so those + /// estimates stay accurate. A dry run's fee intent is bound by the credit as a real one is, + /// since the credit is the same figure either way. pub fn charge_native_execution(&mut self, points: u64) -> Result<(), RuntimeError> { // Hard per-transaction ceiling, independent of what the transaction pays: it bounds how far a block may // overshoot the propose-time execution budget, which the validation budget has to leave room for. diff --git a/crates/engine/src/wasm/process.rs b/crates/engine/src/wasm/process.rs index 1dc076afb8..0f00a8a598 100644 --- a/crates/engine/src/wasm/process.rs +++ b/crates/engine/src/wasm/process.rs @@ -385,9 +385,9 @@ impl Invokable for WasmProcess { Some((_, remaining)) => per_call_cap.min(budget_remaining).min(remaining), None => per_call_cap.min(budget_remaining), }; - // The allowance when it — not the per-transaction hard cap — is what bounds this call. Used - // to report an out-of-gas trap here against what authorized the allowance rather than as a - // hit cap. + // The allowance, kept when it — rather than the per-transaction hard cap — is what bounds + // this call, so an out-of-gas trap here is reported against whatever authorized it rather + // than as a hit cap. let binding_allowance = allowance_remaining .filter(|(_, remaining)| *remaining < budget_remaining && *remaining <= per_call_cap) .map(|(allowance, _)| allowance); diff --git a/crates/engine/tests/compute_fee_budget.rs b/crates/engine/tests/compute_fee_budget.rs index fa6591faa4..f0cb57b19c 100644 --- a/crates/engine/tests/compute_fee_budget.rs +++ b/crates/engine/tests/compute_fee_budget.rs @@ -281,3 +281,31 @@ fn paying_first_does_not_raise_the_fee_intents_allowance() { let reason = test.execute_expect_failure(tx, vec![owner]); assert_fee_intent_credit_exceeded(&reason); } + +/// The credit binds a dry run as it binds a real run, so an above-credit fee intent fails +/// estimation instead of first appearing at submission — where the sender has no remediation +/// beyond restructuring the transaction. +#[test] +fn a_dry_run_is_bound_by_the_fee_intents_credit() { + let Harness { + mut test, + bench, + account, + owner, + key, + per_round, + } = setup(); + + let rounds = ABOVE_GRACE_POINTS / per_round; + let tx = Transaction::builder_localnet(Epoch(1)) + .with_fee_instructions_builder(|builder| { + builder + .call_function(bench, "bench_div_u64", args![rounds]) + .pay_fee_from_component(account, 50_000_000u64) + }) + .build_and_seal(&key); + + test.set_dry_run(true); + let reason = test.execute_expect_failure(tx, vec![owner]); + assert_fee_intent_credit_exceeded(&reason); +} diff --git a/crates/template_test_tooling/src/template_test.rs b/crates/template_test_tooling/src/template_test.rs index 3a00a5ba27..18b2139462 100644 --- a/crates/template_test_tooling/src/template_test.rs +++ b/crates/template_test_tooling/src/template_test.rs @@ -111,6 +111,7 @@ pub struct TemplateTest { name_to_template: HashMap, state_store: MemoryStateStore, enable_fees: bool, + dry_run: bool, fee_table: FeeTable, burn_rate_bps: u16, virtual_substates: HashMap, @@ -239,6 +240,7 @@ impl TemplateTest { virtual_substates, transaction_seq: Cell::new(0), enable_fees: false, + dry_run: false, fee_table: FeeTable { per_transaction_weight_cost: 1, per_module_call_cost: 1, @@ -302,6 +304,13 @@ impl TemplateTest { /// Enables fee charging for subsequent transaction executions. /// By default, fees are disabled in tests. + /// Executes subsequent transactions as a dry run: fees are metered but not settled, as the + /// indexer's fee estimation does. + pub fn set_dry_run(&mut self, dry_run: bool) -> &mut Self { + self.dry_run = dry_run; + self + } + pub fn enable_fees(&mut self) -> &mut Self { self.enable_fees = true; self @@ -748,7 +757,7 @@ impl TemplateTest { Arc::new(AlwaysPassesProofVerifier), wasm_metering_rate, self.burn_rate_bps, - false, + self.dry_run, ); let mut wrapped_transaction = WrappedTransaction::new(transaction); diff --git a/crates/wallet/ootle-rs/examples/stealth_transfer.rs b/crates/wallet/ootle-rs/examples/stealth_transfer.rs index a376b3df87..87164377b9 100644 --- a/crates/wallet/ootle-rs/examples/stealth_transfer.rs +++ b/crates/wallet/ootle-rs/examples/stealth_transfer.rs @@ -32,7 +32,7 @@ async fn main() { // .init(); // This is the address that we will transfer to (Feel free to change this another address!) - let recipient = address!( "otl_loc_1c62vh8e5cx3uwyypdp2gsxvywa97vy26z3mk4337ajhqw5fhqgm0cwcc07fn6gs34sqkeddzhcjwnsc6g3eeeyhv5heatuwg8l7lzmsk2kzfy" ); + let recipient = address!( "otl_loc_162dtv4375eg54pn2g7c3tgu7j89e96hes5hvrxac4qxex6g4v3q7fsantdmgrs7mlg3hc9v4kdaktkp5l8t495fmkdvgpyz4whe6qvckjl8v6" ); let indexer_api_url = default_indexer_url(recipient.network()); diff --git a/docs/developer-docs/src/content/docs/reference/fees.mdx b/docs/developer-docs/src/content/docs/reference/fees.mdx index 52442afb5c..76d8e47778 100644 --- a/docs/developer-docs/src/content/docs/reference/fees.mdx +++ b/docs/developer-docs/src/content/docs/reference/fees.mdx @@ -362,6 +362,11 @@ Submit a transaction as a **dry run** and the engine meters it exactly as it wou aborts for insufficient payment. Read `FinalizeResult::required_fees()` from the result and use it as the `max_fee`. +The one bound a dry run *does* enforce is the fee intent's compute credit. It is a flat figure rather +than one derived from a payment, so it is the same for an estimate as for a real run — a fee intent +above it fails estimation with `FeeIntentComputeExceeded` instead of first surfacing at submission, +where the only remedy is to restructure the transaction. + That figure is `total_fees_required + FEE_ESTIMATE_ALLOWANCE` (25 µT). The allowance exists because `max_fee` is itself an input to the cost, so the real run meters *slightly* differently from the dry run that estimated it: