Skip to content

Commit

Permalink
runtime-sdk: Implement message emission gas
Browse files Browse the repository at this point in the history
Previously one had to explicitly configure the maximum number of
messages to consensus layer that can be emitted by a transaction. This
posed a problem for Ethereum-compatible transactions which don't support
this field.

This implementation adds message emission gas which is dynamically
calculated based on MAX_BATCH_GAS and MAX_MESSAGES. Similar to storage
gas, it is only charged in case other use is too small in order to limit
the total number of messages that can be emitted in a batch.

All Ethereum-compatible transactions now use this dynamic limit for the
maximum number of consensus messages to be emitted.
  • Loading branch information
kostko committed Mar 14, 2024
1 parent e7e0b93 commit 7d08b65
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 34 deletions.
6 changes: 2 additions & 4 deletions client-sdk/ts-web/rt/playground/src/consensus.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,7 @@ export const playground = (async function () {
})
.setSignerInfo([siAlice1])
.setFeeAmount(FEE_FREE)
.setFeeGas(0n)
.setFeeConsensusMessages(1);
.setFeeGas(78n); // Enough to emit 1 consensus message (max_batch_gas / max_messages = 10_000 / 128).
await twDeposit.sign([csAlice], consensusChainContext);

const addrAliceBech32 = oasis.staking.addressToBech32(aliceAddr);
Expand Down Expand Up @@ -282,8 +281,7 @@ export const playground = (async function () {
})
.setSignerInfo([siDave2])
.setFeeAmount(FEE_FREE)
.setFeeGas(0n)
.setFeeConsensusMessages(1);
.setFeeGas(78n); // Enough to emit 1 consensus message (max_batch_gas / max_messages = 10_000 / 128).
await twWithdraw.sign([csDave], consensusChainContext);

/** @type {oasisRT.types.ConsensusAccountsWithdrawEvent} */
Expand Down
3 changes: 1 addition & 2 deletions runtime-sdk/modules/evm/src/raw_tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,7 @@ pub fn decode(
fee: transaction::Fee {
amount: token::BaseUnits(resolved_fee_amount, token::Denomination::NATIVE),
gas: gas_limit,
// TODO: Allow customization, maybe through call data?
consensus_messages: 1,
consensus_messages: 0, // Dynamic number of consensus messages, limited by gas.
},
..Default::default()
},
Expand Down
45 changes: 33 additions & 12 deletions runtime-sdk/src/modules/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1106,38 +1106,59 @@ impl<Cfg: Config> module::TransactionHandler for Module<Cfg> {
}

fn after_handle_call<C: Context>(
_ctx: &C,
ctx: &C,
result: module::CallResult,
) -> Result<module::CallResult, Error> {
// Skip handling for internally generated calls.
if CurrentState::with_env(|env| env.is_internal()) {
return Ok(result);
}

// Charge storage update gas cost if this would be greater than the gas use.
let params = Self::params();
if params.gas_costs.storage_byte > 0 {

// Compute storage update gas cost.
let storage_gas = if params.gas_costs.storage_byte > 0 {
let storage_update_bytes =
CurrentState::with(|state| state.pending_store_update_byte_size());
let storage_gas = params
params
.gas_costs
.storage_byte
.saturating_mul(storage_update_bytes as u64);
let used_gas = Self::used_tx_gas();
.saturating_mul(storage_update_bytes as u64)

Check warning on line 1126 in runtime-sdk/src/modules/core/mod.rs

View check run for this annotation

Codecov / codecov/patch

runtime-sdk/src/modules/core/mod.rs#L1126

Added line #L1126 was not covered by tests
} else {
0
};

if storage_gas > used_gas {
Self::use_tx_gas(storage_gas - used_gas)?;
}
}
// Compute message gas cost.
let message_gas = {

Check warning on line 1132 in runtime-sdk/src/modules/core/mod.rs

View check run for this annotation

Codecov / codecov/patch

runtime-sdk/src/modules/core/mod.rs#L1132

Added line #L1132 was not covered by tests
let emitted_message_count =
CurrentState::with(|state| state.emitted_messages_local_count());

Check warning on line 1134 in runtime-sdk/src/modules/core/mod.rs

View check run for this annotation

Codecov / codecov/patch

runtime-sdk/src/modules/core/mod.rs#L1134

Added line #L1134 was not covered by tests
// Determine how much each message emission costs based on max_batch_gas and the number
// of messages that can be emitted per batch.
let message_gas_cost = params
.max_batch_gas

Check warning on line 1138 in runtime-sdk/src/modules/core/mod.rs

View check run for this annotation

Codecov / codecov/patch

runtime-sdk/src/modules/core/mod.rs#L1138

Added line #L1138 was not covered by tests
.checked_div(ctx.max_messages().into())
.unwrap_or(u64::MAX); // If no messages are allowed, cost is infinite.
message_gas_cost.saturating_mul(emitted_message_count as u64)
};

// Compute the gas amount that the transaction should pay in the end.
let used_gas = Self::used_tx_gas();
let max_gas = std::cmp::max(used_gas, std::cmp::max(storage_gas, message_gas));

// Emit gas used event (if this is not an internally generated call).
if Cfg::EMIT_GAS_USED_EVENTS {
let used_gas = Self::used_tx_gas();
CurrentState::with(|state| {
state.emit_unconditional_event(Event::GasUsed { amount: used_gas });
state.emit_unconditional_event(Event::GasUsed { amount: max_gas });
});
}

// Make sure the transaction actually pays for the maximum gas. Note that failure here is
// fine since the extra resources (storage updates or emitted consensus messages) have not
// actually been spent yet (this happens at the end of the round).
if max_gas > used_gas {
Self::use_tx_gas(max_gas - used_gas)?;
}

Ok(result)
}
}
Expand Down
157 changes: 155 additions & 2 deletions runtime-sdk/src/modules/core/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use once_cell::unsync::Lazy;

use crate::{
context::Context,
core::common::version::Version,
core::{
common::{version::Version, versioned::Versioned},
consensus::{roothash, staking},
},
crypto::multisig,
error::Error,
event::IntoTags,
Expand All @@ -16,7 +19,10 @@ use crate::{
sender::SenderMeta,
state::{self, CurrentState, Options},
testing::{configmap, keys, mock},
types::{address::Address, token, transaction, transaction::CallerAddress},
types::{
address::Address, message::MessageEventHookInvocation, token, transaction,
transaction::CallerAddress,
},
};

use super::{types, Event, Parameters, API as _};
Expand Down Expand Up @@ -206,6 +212,7 @@ impl GasWasterModule {
const METHOD_SPECIFIC_GAS_REQUIRED_HUGE: &'static str = "test.SpecificGasRequiredHuge";
const METHOD_STORAGE_UPDATE: &'static str = "test.StorageUpdate";
const METHOD_STORAGE_REMOVE: &'static str = "test.StorageRemove";
const METHOD_EMIT_CONSENSUS_MESSAGE: &'static str = "test.EmitConsensusMessage";
}

#[sdk_derive(Module)]
Expand Down Expand Up @@ -318,6 +325,27 @@ impl GasWasterModule {
CurrentState::with_store(|store| store.remove(&args));
Ok(())
}

#[handler(call = Self::METHOD_EMIT_CONSENSUS_MESSAGE)]
fn emit_consensus_message<C: Context>(
ctx: &C,
count: u64,
) -> Result<(), <GasWasterModule as module::Module>::Error> {
<C::Runtime as Runtime>::Core::use_tx_gas(2)?;
CurrentState::with(|state| {
for _ in 0..count {
state.emit_message(
ctx,
roothash::Message::Staking(Versioned::new(
0,
roothash::StakingMessage::Transfer(staking::Transfer::default()),
)),
MessageEventHookInvocation::new("test".to_string(), ""),
)?;
}
Ok(())
})
}
}

impl module::BlockHandler for GasWasterModule {}
Expand Down Expand Up @@ -1125,6 +1153,7 @@ fn test_module_info() {
MethodHandlerInfo { kind: types::MethodHandlerKind::Call, name: "test.SpecificGasRequiredHuge".to_string() },
MethodHandlerInfo { kind: types::MethodHandlerKind::Call, name: "test.StorageUpdate".to_string() },
MethodHandlerInfo { kind: types::MethodHandlerKind::Call, name: "test.StorageRemove".to_string() },
MethodHandlerInfo { kind: types::MethodHandlerKind::Call, name: "test.EmitConsensusMessage".to_string() },
],
},
}
Expand Down Expand Up @@ -1376,3 +1405,127 @@ fn test_storage_gas() {
assert_eq!(events.len(), 1); // Just one gas used event.
assert_eq!(events[0].amount, expected_gas_use);
}

#[test]
fn test_message_gas() {
let mut mock = mock::Mock::default();
let max_messages = 32;
mock.max_messages = max_messages;

let ctx = mock.create_ctx_for_runtime::<GasWasterRuntime>(false);

GasWasterRuntime::migrate(&ctx);

let max_batch_gas = 10_000;
Core::set_params(Parameters {
max_batch_gas,
gas_costs: super::GasCosts {
tx_byte: 0,
..Default::default()
},
..Core::params()
});

let mut signer = mock::Signer::new(0, keys::alice::sigspec());

// Emit 10 messages which is greater than the transaction compute gas cost.
let num_messages = 10u64;
let mut total_messages = num_messages;
let expected_gas_use = num_messages * (max_batch_gas / (max_messages as u64));
let dispatch_result = signer.call_opts(
&ctx,
GasWasterModule::METHOD_EMIT_CONSENSUS_MESSAGE,
num_messages,
mock::CallOptions {
fee: transaction::Fee {
gas: 10_000,
..Default::default()
},
},
);
assert!(dispatch_result.result.is_success(), "call should succeed");

// Simulate multiple transactions in a batch by not taking any messages.

let tags = &dispatch_result.tags;
assert_eq!(tags.len(), 1, "one event should have been emitted");
assert_eq!(tags[0].key, b"core\x00\x00\x00\x01"); // core.GasUsed (code = 1) event

#[derive(Debug, Default, cbor::Decode)]
struct GasUsedEvent {
amount: u64,
}

let events: Vec<GasUsedEvent> = cbor::from_slice(&tags[0].value).unwrap();
assert_eq!(events.len(), 1); // Just one gas used event.
assert_eq!(events[0].amount, expected_gas_use);

// Emit no messages so just the compute gas cost should be charged.
let num_messages = 0u64;
total_messages += num_messages;
let expected_gas_use = 2; // Just compute gas cost.
let dispatch_result = signer.call_opts(
&ctx,
GasWasterModule::METHOD_EMIT_CONSENSUS_MESSAGE,
num_messages,
mock::CallOptions {
fee: transaction::Fee {
gas: 10_000,
..Default::default()
},
},
);
assert!(dispatch_result.result.is_success(), "call should succeed");

let tags = &dispatch_result.tags;
assert_eq!(tags.len(), 1, "one event should have been emitted");
assert_eq!(tags[0].key, b"core\x00\x00\x00\x01"); // core.GasUsed (code = 1) event

let events: Vec<GasUsedEvent> = cbor::from_slice(&tags[0].value).unwrap();
assert_eq!(events.len(), 1); // Just one gas used event.
assert_eq!(events[0].amount, expected_gas_use);

// Take all messages emitted by the above two transactions.
let messages = CurrentState::with(|state| state.take_messages());
assert_eq!(total_messages as usize, messages.len());

// Ensure gas estimation works.
let num_messages = 10;
let expected_gas_use = num_messages * (max_batch_gas / (max_messages as u64));
let tx = transaction::Transaction {
version: 1,
call: transaction::Call {
format: transaction::CallFormat::Plain,
method: GasWasterModule::METHOD_EMIT_CONSENSUS_MESSAGE.to_owned(),
body: cbor::to_value(num_messages),
..Default::default()
},
auth_info: transaction::AuthInfo {
signer_info: vec![transaction::SignerInfo::new_sigspec(
keys::alice::sigspec(),
0,
)],
fee: transaction::Fee {
amount: token::BaseUnits::new(0, token::Denomination::NATIVE),
gas: u64::MAX,
consensus_messages: 0,
},
..Default::default()
},
};
let estimated_gas: u64 = signer
.query(
&ctx,
"core.EstimateGas",
types::EstimateGasQuery {
caller: None,
tx,
propagate_failures: false,
},
)
.expect("gas estimation should succeed");
assert_eq!(
estimated_gas, expected_gas_use,
"gas should be estimated correctly"
);
}
35 changes: 33 additions & 2 deletions runtime-sdk/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,8 @@ impl State {
self.hidden_block_values = Some(mem::take(&mut self.block_values));
}

/// Emitted messages count returns the number of messages emitted so far.
/// Emitted messages count returns the number of messages emitted so far across this and all
/// parent states.
pub fn emitted_messages_count(&self) -> usize {
self.messages.len()
+ self
Expand All @@ -493,10 +494,21 @@ impl State {
.unwrap_or_default()
}

/// Emitted messages count returns the number of messages emitted so far in this state, not
/// counting any parent states.
pub fn emitted_messages_local_count(&self) -> usize {
self.messages.len()
}

/// Maximum number of messages that can be emitted.
pub fn emitted_messages_max<C: Context>(&self, ctx: &C) -> u32 {
if self.env.is_transaction() {
self.env.tx_auth_info().fee.consensus_messages
let limit = self.env.tx_auth_info().fee.consensus_messages;
if limit > 0 {
limit
} else {
ctx.max_messages() // Zero means an implicit limit by gas use.
}
} else {
ctx.max_messages()
}
Expand Down Expand Up @@ -1168,6 +1180,9 @@ mod test {
CurrentState::with(|state| {
state.open();

assert_eq!(state.emitted_messages_count(), 0);
assert_eq!(state.emitted_messages_local_count(), 0);

state
.emit_message(
&ctx,
Expand All @@ -1179,10 +1194,13 @@ mod test {
)
.expect("message emission should succeed");
assert_eq!(state.emitted_messages_count(), 1);
assert_eq!(state.emitted_messages_local_count(), 1);
assert_eq!(state.emitted_messages_max(&ctx), max_messages as u32);

state.open(); // Start child state.

assert_eq!(state.emitted_messages_local_count(), 0);

state
.emit_message(
&ctx,
Expand All @@ -1194,6 +1212,7 @@ mod test {
)
.expect("message emission should succeed");
assert_eq!(state.emitted_messages_count(), 2);
assert_eq!(state.emitted_messages_local_count(), 1);
assert_eq!(state.emitted_messages_max(&ctx), max_messages as u32);

state.rollback(); // Rollback.
Expand All @@ -1203,9 +1222,12 @@ mod test {
1,
"emitted message should have been rolled back"
);
assert_eq!(state.emitted_messages_local_count(), 1);

state.open(); // Start child state.

assert_eq!(state.emitted_messages_local_count(), 0);

state
.emit_message(
&ctx,
Expand All @@ -1217,6 +1239,7 @@ mod test {
)
.expect("message emission should succeed");
assert_eq!(state.emitted_messages_count(), 2);
assert_eq!(state.emitted_messages_local_count(), 1);

state.commit(); // Commit.

Expand Down Expand Up @@ -1267,6 +1290,14 @@ mod test {
assert_eq!(state.emitted_messages_max(&ctx), 1);
});
});

let mut tx = mock::transaction();
tx.auth_info.fee.consensus_messages = 0; // Zero means an implicit limit by gas use.
CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || {
CurrentState::with(|state| {
assert_eq!(state.emitted_messages_max(&ctx), max_messages as u32);
});
});
}

#[test]
Expand Down
Loading

0 comments on commit 7d08b65

Please sign in to comment.