Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions crates/consensus/src/consensus_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -300,17 +299,23 @@ 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<Network>`
/// 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<Network> 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(),
}
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/fees/fee_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/runtime/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions crates/engine/src/runtime/impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4009,8 +4009,8 @@ where
self.tracker.accumulated_native_points()
}

fn wasm_point_allowance(&self) -> Option<u64> {
self.tracker.wasm_point_allowance()
fn compute_allowance(&self) -> Option<ComputeAllowance> {
self.tracker.compute_allowance()
}

fn resolve_args(
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u64>;
fn compute_allowance(&self) -> Option<ComputeAllowance>;

fn resolve_args(
&self,
Expand Down
106 changes: 72 additions & 34 deletions crates/engine/src/runtime/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ impl<TStore> FinalizedState<TStore> {
}
}

/// 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<TStore> {
working_state: Option<WorkingState<TStore>>,
Expand Down Expand Up @@ -148,49 +167,61 @@ impl<TStore: StateReader> StateTracker<TStore> {
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
/// 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 wasm_point_allowance(&self) -> Option<u64> {
/// 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<ComputeAllowance> {
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| {
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());
let funded = rate.points_funded_by(unspent)?;
if is_fee_intent {
Some(funded.saturating_add(limits::FREE_COMPUTE_GRACE_POINTS))
} else {
Some(funded)
}
Some(ComputeAllowance {
// `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,
})
})
}

Expand Down Expand Up @@ -388,15 +419,22 @@ impl<TStore: StateReader> StateTracker<TStore> {
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,
});
}
}
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/wasm/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
58 changes: 36 additions & 22 deletions crates/engine/src/wasm/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -368,26 +368,29 @@ impl Invokable<Store> 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 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 {
Some(remaining) => per_call_cap.min(budget_remaining).min(remaining),
let allowance_remaining = self.env.state().interface().compute_allowance().map(|allowance| {
let remaining = allowance
.points
.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),
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);
// 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,
Expand Down Expand Up @@ -446,10 +449,21 @@ impl Invokable<Store> 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_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,
});
},
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())
Expand Down
Loading
Loading