Skip to content

Commit

Permalink
feature: gas meter (#795)
Browse files Browse the repository at this point in the history
* feat: add gas meter to working set

This commit introduces `GasMeter`, encapsulated by `WorkingSet`.

It will allow the user to consume scalar gas from the working set, and
define arbitrary price parsed from a constants.json manifest file at
compilation. At each compilation, the `ModuleInfo` derive macro will
parse such file, and set the gas price configuration.

* fix lint fmt

* fix ci test expected error string

* update default context to 2 dimensions
  • Loading branch information
vlopes11 authored and dubbelosix committed Sep 29, 2023
1 parent a45fab2 commit 261e6cb
Show file tree
Hide file tree
Showing 14 changed files with 442 additions and 106 deletions.
11 changes: 10 additions & 1 deletion constants.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
{
"comment": "Sovereign SDK constants"
"comment": "Sovereign SDK constants",
"gas": {
"Bank": {
"create_token": [4, 4],
"transfer": [5, 5],
"burn": [2, 2],
"mint": [2, 2],
"freeze": [1, 1]
}
}
}
5 changes: 0 additions & 5 deletions examples/demo-prover/methods/guest-celestia/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 0 additions & 5 deletions examples/demo-prover/methods/guest-mock/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions module-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,111 @@ This has several consequences. First, it means that modules are always cheap to
always yields the same result as calling `MyModule::new()`. Finally, it means that every method of the module which reads or
modifies state needs to take a `WorkingSet` as an argument.

### Gas configuration

The module might contain a field for the gas configuration. If annotated with `#[gas]` under a struct that derives `ModuleInfo`, it will attempt to read a `constants.json` file from the root of the project, and inject it into the `Default::default()` implementation of the module.

Here is an example `constants.json` file:

```json
{
"gas": {
"create_token": 4,
"transfer": 5,
"burn": 2,
"mint": 2,
"freeze": 1
}
}
```

The `ModuleInfo` macro will look for a `gas` field inside the JSON, that must be an object, and will look for the name of the module inside of the `gas` object. If present, it will parse that object as gas configuration; otherwise, it will parse the `gas` object directly. On the example above, it will attempt to parse a structure that looks like this:

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BankGasConfig<GU: GasUnit> {
pub create_token: GU,
pub transfer: GU,
pub burn: GU,
pub mint: GU,
pub freeze: GU,
}
```

The `GasUnit` generic type will be defined by the runtime `Context`. For `DefaultContext`, we use `TupleGasUnit<2>` - that is, a gas unit with a two dimensions. The same setup is defined for `ZkDefaultContext`. Here is an example of a `constants.json` file, specific to the `Bank` module:

```json
{
"gas": {
"comment": "this field will be ignored, as there is a matching module field",
"Bank": {
"create_token": [4, 19],
"transfer": [5, 25],
"burn": [2, 7],
"mint": [2, 6],
"freeze": [1, 4]
}
}
}
```

As you can see above, the fields can be either array, numeric, or boolean. If boolean, it will be converted to either `0` or `1`. If array, each element is expected to be either a numeric or boolean. The example above will create a gas unit of two dimensions. If the `Context` requires less dimensions than available, it will pick the first ones of relevance, and ignore the rest. That is: with a `Context` of one dimension, , the effective config will be expanded to:

```rust
BankGasConfig {
create_token: [4],
transfer: [5],
burn: [2],
mint: [2],
freeze: [1],
}
```

In order to charge gas from the working set, the function `charge_gas` can be used.

```rust
fn call(
&self,
msg: Self::CallMessage,
context: &Self::Context,
working_set: &mut WorkingSet<C>,
) -> Result<sov_modules_api::CallResponse, Error> {
match msg {
call::CallMessage::CreateToken {
salt,
token_name,
initial_balance,
minter_address,
authorized_minters,
} => {
self.charge_gas(working_set, &self.gas.create_token)?;
// Implementation elided...
}
```

On the example above, we charge the configured unit from the working set. Concretely, we will charge a unit of `[4, 19]` from both `DefaultContext` and `ZkDefaultContext`. The working set will be the responsible to perform a scalar conversion from the dimensions to a single funds value. It will perform an inner product of the loaded price, with the provided unit.

Let's assume we have a working set with the loaded price `[3, 2]`. The charged gas of the operation above will be `[3] · [4] = 3 × 4 = 12` for a single dimension context, and `[3, 2] · [4, 19] = 3 × 4 + 2 × 19 = 50` for both `DefaultContext` and `ZkDefaultContext`. This approach is intended to unlock [Dynamic Pricing](https://arxiv.org/abs/2208.07919).

The aforementioned `Bank` struct, with the gas configuration, will look like this:

```rust
#[derive(ModuleInfo)]
pub struct Bank<C: sov_modules_api::Context> {
/// The address of the bank module.
#[address]
pub(crate) address: C::Address,

/// The gas configuration of the sov-bank module.
#[gas]
pub(crate) gas: BankGasConfig<C::GasUnit>,

/// A mapping of addresses to tokens in the bank.
#[state]
pub(crate) tokens: sov_state::StateMap<C::Address, Token<C>>,
}
```

### Public Functions: The Module-to-Module Interface

The first interface that modules expose is defined by the public methods from the rollup's `impl`. These methods are
Expand Down
30 changes: 29 additions & 1 deletion module-system/module-implementations/sov-bank/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub mod utils;
/// Specifies the call methods using in that module.
pub use call::CallMessage;
use serde::{Deserialize, Serialize};
use sov_modules_api::{CallResponse, Error, ModuleInfo, WorkingSet};
use sov_modules_api::{CallResponse, Error, GasUnit, ModuleInfo, WorkingSet};
use token::Token;
/// Specifies an interface to interact with tokens.
pub use token::{Amount, Coins};
Expand Down Expand Up @@ -41,6 +41,25 @@ pub struct BankConfig<C: sov_modules_api::Context> {
pub tokens: Vec<TokenConfig<C>>,
}

/// Gas configuration for the bank module
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BankGasConfig<GU: GasUnit> {
/// Gas price multiplier for the create token operation
pub create_token: GU,

/// Gas price multiplier for the transfer operation
pub transfer: GU,

/// Gas price multiplier for the burn operation
pub burn: GU,

/// Gas price multiplier for the mint operation
pub mint: GU,

/// Gas price multiplier for the freeze operation
pub freeze: GU,
}

/// The sov-bank module manages user balances. It provides functionality for:
/// - Token creation.
/// - Token transfers.
Expand All @@ -52,6 +71,10 @@ pub struct Bank<C: sov_modules_api::Context> {
#[address]
pub(crate) address: C::Address,

/// The gas configuration of the sov-bank module.
#[gas]
pub(crate) gas: BankGasConfig<C::GasUnit>,

/// A mapping of addresses to tokens in the sov-bank.
#[state]
pub(crate) tokens: sov_modules_api::StateMap<C::Address, Token<C>>,
Expand Down Expand Up @@ -82,6 +105,7 @@ impl<C: sov_modules_api::Context> sov_modules_api::Module for Bank<C> {
minter_address,
authorized_minters,
} => {
self.charge_gas(working_set, &self.gas.create_token)?;
self.create_token(
token_name,
salt,
Expand All @@ -95,22 +119,26 @@ impl<C: sov_modules_api::Context> sov_modules_api::Module for Bank<C> {
}

call::CallMessage::Transfer { to, coins } => {
self.charge_gas(working_set, &self.gas.create_token)?;
Ok(self.transfer(to, coins, context, working_set)?)
}

call::CallMessage::Burn { coins } => {
self.charge_gas(working_set, &self.gas.burn)?;
Ok(self.burn_from_eoa(coins, context, working_set)?)
}

call::CallMessage::Mint {
coins,
minter_address,
} => {
self.charge_gas(working_set, &self.gas.mint)?;
self.mint_from_eoa(&coins, &minter_address, context, working_set)?;
Ok(CallResponse::default())
}

call::CallMessage::Freeze { token_address } => {
self.charge_gas(working_set, &self.gas.freeze)?;
Ok(self.freeze(token_address, context, working_set)?)
}
}
Expand Down
6 changes: 6 additions & 0 deletions module-system/sov-modules-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ crate:
- Interaction with user messages: The module must define the `call` method and the `CallMessage` type, which handle
user messages. These messages typically result in changes to the module's state.

- Gas configuration: The module may use a `GasConfig` type, annotated by `#[gas]`, that will be loaded from the
constants manifest configuration.

1. The `ModuleInfo` trait: Provides additional information related to a module. This trait is automatically derived.

1. The `Spec` trait: It defines all the types that modules are generic over. This separation allows the module logic to
Expand All @@ -29,3 +32,6 @@ crate:

1. The `DispatchCall` trait: Defines how messages are forwarded to the appropriate module and how the call message is
executed. The implementation of this trait can be generated automatically using a macro.

1. The `GasUnit` trait: Defines how the scalar gas value is deducted from the working set. This is implemented for
`[u64; N]`, and can be customized by the user.
6 changes: 5 additions & 1 deletion module-system/sov-modules-api/src/default_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use sov_state::{ArrayWitness, DefaultStorageSpec, ZkStorage};
#[cfg(feature = "native")]
use crate::default_signature::private_key::DefaultPrivateKey;
use crate::default_signature::{DefaultPublicKey, DefaultSignature};
use crate::{Address, Context, PublicKey, Spec};
use crate::{Address, Context, PublicKey, Spec, TupleGasUnit};

#[cfg(feature = "native")]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
Expand All @@ -31,6 +31,8 @@ impl Spec for DefaultContext {

#[cfg(feature = "native")]
impl Context for DefaultContext {
type GasUnit = TupleGasUnit<2>;

fn sender(&self) -> &Self::Address {
&self.sender
}
Expand Down Expand Up @@ -58,6 +60,8 @@ impl Spec for ZkDefaultContext {
}

impl Context for ZkDefaultContext {
type GasUnit = TupleGasUnit<2>;

fn sender(&self) -> &Self::Address {
&self.sender
}
Expand Down
92 changes: 92 additions & 0 deletions module-system/sov-modules-api/src/gas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
use core::fmt;

use anyhow::Result;

/// A gas unit that provides scalar conversion from complex, multi-dimensional types.
pub trait GasUnit: fmt::Debug + Clone {
/// A zeroed instance of the unit.
const ZEROED: Self;

/// Creates a unit from a multi-dimensional unit with arbitrary dimension.
fn from_arbitrary_dimensions(dimensions: &[u64]) -> Self;

/// Converts the unit into a scalar value, given a price.
fn value(&self, price: &Self) -> u64;
}

/// A multi-dimensional gas unit.
pub type TupleGasUnit<const N: usize> = [u64; N];

impl<const N: usize> GasUnit for TupleGasUnit<N> {
const ZEROED: Self = [0; N];

fn from_arbitrary_dimensions(dimensions: &[u64]) -> Self {
// as demonstrated on the link below, the compiler can easily optimize the conversion as if
// it is a transparent type.
//
// https://rust.godbolt.org/z/rPhaxnPEY
let mut unit = Self::ZEROED;
unit.iter_mut()
.zip(dimensions.iter().copied())
.for_each(|(a, b)| *a = b);
unit
}

fn value(&self, price: &Self) -> u64 {
self.iter()
.zip(price.iter().copied())
.map(|(a, b)| a.saturating_mul(b))
.fold(0, |a, b| a.saturating_add(b))
}
}

/// A gas meter.
pub struct GasMeter<GU>
where
GU: GasUnit,
{
remaining_funds: u64,
gas_price: GU,
}

impl<GU> Default for GasMeter<GU>
where
GU: GasUnit,
{
fn default() -> Self {
Self {
remaining_funds: 0,
gas_price: GU::ZEROED,
}
}
}

impl<GU> GasMeter<GU>
where
GU: GasUnit,
{
/// Creates a new instance of the gas meter with the provided price.
pub fn new(remaining_funds: u64, gas_price: GU) -> Self {
Self {
remaining_funds,
gas_price,
}
}

/// Returns the remaining gas funds.
pub const fn remaining_funds(&self) -> u64 {
self.remaining_funds
}

/// Deducts the provided gas unit from the remaining funds, computing the scalar value of the
/// funds from the price of the instance.
pub fn charge_gas(&mut self, gas: &GU) -> Result<()> {
let gas = gas.value(&self.gas_price);
self.remaining_funds = self
.remaining_funds
.checked_sub(gas)
.ok_or_else(|| anyhow::anyhow!("Not enough gas"))?;

Ok(())
}
}
Loading

0 comments on commit 261e6cb

Please sign in to comment.