Skip to content

Commit 459b515

Browse files
committed
feat(gno): support simulated and dynamic gas pricing for tx fees
1 parent 031785b commit 459b515

6 files changed

Lines changed: 684 additions & 123 deletions

File tree

lib/gno-client/src/gas.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1+
use gno_rpc::rpc_types::TxFee;
12
use num_rational::BigRational;
2-
use unionlabs::cosmos::tx::fee::Fee;
33

4+
pub mod any;
5+
pub mod dynamic;
46
pub mod fixed;
57

68
pub trait GasFillerT {
79
async fn max_gas(&self) -> u64;
810

9-
async fn mk_fee(&self, gas: u64) -> Fee;
11+
async fn mk_fee(&self, gas: u64) -> Result<TxFee, crate::BroadcastTxCommitError>;
1012
}
1113

1214
impl<T: GasFillerT> GasFillerT for &T {
1315
async fn max_gas(&self) -> u64 {
1416
(*self).max_gas().await
1517
}
1618

17-
async fn mk_fee(&self, gas: u64) -> Fee {
19+
async fn mk_fee(&self, gas: u64) -> Result<TxFee, crate::BroadcastTxCommitError> {
1820
(*self).mk_fee(gas).await
1921
}
2022
}

lib/gno-client/src/gas/any.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use gno_rpc::rpc_types::TxFee;
2+
use serde::{Deserialize, Serialize};
3+
4+
use crate::gas::{GasFillerT, dynamic, fixed};
5+
6+
#[derive(Debug, Clone)]
7+
pub enum GasFiller {
8+
Fixed(TxFee),
9+
// reuses fixed's pricing formula, but fed the simulated gas_used
10+
Simulate(fixed::GasFiller),
11+
Dynamic(dynamic::GasFiller),
12+
}
13+
14+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15+
#[serde(rename_all = "snake_case", tag = "type", content = "config")]
16+
pub enum Config {
17+
Fixed(TxFee),
18+
Simulate(fixed::GasFiller),
19+
Dynamic(dynamic::Config),
20+
}
21+
22+
impl Config {
23+
pub fn into_gas_filler(self, client: gno_rpc::Client) -> GasFiller {
24+
match self {
25+
Config::Fixed(fee) => GasFiller::Fixed(fee),
26+
Config::Simulate(filler) => GasFiller::Simulate(filler),
27+
Config::Dynamic(config) => GasFiller::Dynamic(dynamic::GasFiller::new(config, client)),
28+
}
29+
}
30+
}
31+
32+
impl GasFillerT for GasFiller {
33+
async fn max_gas(&self) -> u64 {
34+
match self {
35+
GasFiller::Fixed(fee) => fee.gas_wanted.try_into().unwrap_or(u64::MAX),
36+
GasFiller::Simulate(f) => f.max_gas().await,
37+
GasFiller::Dynamic(f) => f.max_gas().await,
38+
}
39+
}
40+
41+
async fn mk_fee(&self, gas: u64) -> Result<TxFee, crate::BroadcastTxCommitError> {
42+
match self {
43+
GasFiller::Fixed(fee) => Ok(fee.clone()),
44+
GasFiller::Simulate(f) => f.mk_fee(gas).await,
45+
GasFiller::Dynamic(f) => f.mk_fee(gas).await,
46+
}
47+
}
48+
}

lib/gno-client/src/gas/dynamic.rs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
use gno_rpc::rpc_types::TxFee;
2+
use num_rational::BigRational;
3+
use serde::{Deserialize, Serialize};
4+
use tracing::{debug, instrument};
5+
6+
use crate::gas::{GasFillerT, u128_saturating_mul_f64};
7+
8+
#[derive(Debug, Clone)]
9+
pub struct GasFiller {
10+
max_gas: u64,
11+
gas_multiplier: f64,
12+
denom_override: Option<String>,
13+
client: gno_rpc::Client,
14+
}
15+
16+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17+
pub struct Config {
18+
pub max_gas: u64,
19+
#[serde(with = "::serde_utils::string_opt")]
20+
pub gas_multiplier: Option<f64>,
21+
pub denom: Option<String>,
22+
}
23+
24+
#[derive(Debug, Deserialize)]
25+
struct GasPriceResponse {
26+
#[serde(with = "::serde_utils::string")]
27+
gas: i64,
28+
price: String,
29+
}
30+
31+
/// gno's `GasPrice{ Gas, Price }`; price per unit of gas is `amount / gas`, not `amount` alone.
32+
#[derive(Debug, Clone, PartialEq)]
33+
pub(crate) struct GasPrice {
34+
pub gas: i64,
35+
pub amount: u128,
36+
pub denom: String,
37+
}
38+
39+
impl GasFiller {
40+
pub fn new(config: Config, client: gno_rpc::Client) -> Self {
41+
Self {
42+
max_gas: config.max_gas,
43+
gas_multiplier: config.gas_multiplier.unwrap_or(1.0),
44+
denom_override: config.denom,
45+
client,
46+
}
47+
}
48+
49+
pub(crate) async fn get_gas_price(&self) -> Result<GasPrice, crate::BroadcastTxCommitError> {
50+
let response = self
51+
.client
52+
.abci_query("auth/gasprice", &[], None, false)
53+
.await?;
54+
55+
if let Some(error) = response.response.response_base.error {
56+
return Err(crate::BroadcastTxCommitError::TxFailed {
57+
error,
58+
log: response.response.response_base.log,
59+
});
60+
}
61+
62+
let value = response.response.response_base.data.unwrap_or_default();
63+
64+
parse_gas_price_response(&value)
65+
}
66+
}
67+
68+
fn parse_gas_price_response(value: &[u8]) -> Result<GasPrice, crate::BroadcastTxCommitError> {
69+
let gas_price = serde_json::from_slice::<GasPriceResponse>(value)?;
70+
71+
let split_at = gas_price
72+
.price
73+
.find(|c: char| !c.is_ascii_digit())
74+
.unwrap_or(gas_price.price.len());
75+
76+
let (amount, denom) = gas_price.price.split_at(split_at);
77+
78+
let amount: u128 = if gas_price.price.is_empty() {
79+
0
80+
} else {
81+
amount
82+
.parse()
83+
.map_err(|_| crate::BroadcastTxCommitError::InvalidGasPrice {
84+
price: gas_price.price.clone(),
85+
})?
86+
};
87+
88+
if amount > 0 && gas_price.gas <= 0 {
89+
return Err(crate::BroadcastTxCommitError::InvalidGasPrice {
90+
price: gas_price.price,
91+
});
92+
}
93+
94+
Ok(GasPrice {
95+
gas: gas_price.gas,
96+
amount,
97+
denom: denom.to_owned(),
98+
})
99+
}
100+
101+
impl GasFillerT for GasFiller {
102+
async fn max_gas(&self) -> u64 {
103+
self.max_gas
104+
}
105+
106+
#[instrument(
107+
skip_all,
108+
fields(
109+
self.max_gas = %self.max_gas,
110+
self.gas_multiplier = %self.gas_multiplier,
111+
gas = %gas,
112+
)
113+
)]
114+
async fn mk_fee(&self, gas: u64) -> Result<TxFee, crate::BroadcastTxCommitError> {
115+
// gas limit = provided gas * multiplier, clamped to max_gas
116+
let gas_limit = u128_saturating_mul_f64(gas.into(), self.gas_multiplier)
117+
.try_into()
118+
.unwrap_or(self.max_gas)
119+
.min(self.max_gas);
120+
121+
let price = self.get_gas_price().await?;
122+
123+
let fee = compute_fee(gas_limit, &price, self.denom_override.as_deref())?;
124+
125+
debug!(gas_limit, gas_fee = %fee.gas_fee, "computed fee from dynamic gas price");
126+
127+
Ok(fee)
128+
}
129+
}
130+
131+
fn compute_fee(
132+
gas_limit: u64,
133+
price: &GasPrice,
134+
denom_override: Option<&str>,
135+
) -> Result<TxFee, crate::BroadcastTxCommitError> {
136+
let denom = if price.denom.is_empty() {
137+
denom_override.ok_or(crate::BroadcastTxCommitError::MissingGasDenom)?
138+
} else {
139+
price.denom.as_str()
140+
};
141+
142+
let price_per_gas = BigRational::new(price.amount.into(), price.gas.max(1).into());
143+
144+
let amount = price_per_gas * BigRational::from_integer(gas_limit.into());
145+
let amount = amount.ceil().to_integer().try_into().unwrap_or(u128::MAX);
146+
147+
Ok(TxFee {
148+
gas_wanted: gas_limit.try_into().unwrap_or(i64::MAX),
149+
gas_fee: format!("{amount}{denom}"),
150+
})
151+
}
152+
153+
#[cfg(test)]
154+
mod tests {
155+
use super::*;
156+
157+
#[test]
158+
fn parses_normal_price() {
159+
let price = parse_gas_price_response(br#"{"gas": "1000", "price": "5ugnot"}"#).unwrap();
160+
161+
assert_eq!(
162+
price,
163+
GasPrice {
164+
gas: 1000,
165+
amount: 5,
166+
denom: "ugnot".to_owned()
167+
}
168+
);
169+
}
170+
171+
#[test]
172+
fn parses_zero_price_as_no_price_set() {
173+
let price = parse_gas_price_response(br#"{"gas": "0", "price": ""}"#).unwrap();
174+
175+
assert_eq!(
176+
price,
177+
GasPrice {
178+
gas: 0,
179+
amount: 0,
180+
denom: String::new()
181+
}
182+
);
183+
}
184+
185+
#[test]
186+
fn rejects_malformed_price() {
187+
let err = parse_gas_price_response(br#"{"gas": "1000", "price": "abcugnot"}"#).unwrap_err();
188+
189+
assert!(matches!(
190+
err,
191+
crate::BroadcastTxCommitError::InvalidGasPrice { price } if price == "abcugnot"
192+
));
193+
}
194+
195+
#[test]
196+
fn rejects_nonzero_price_with_non_positive_gas() {
197+
let err = parse_gas_price_response(br#"{"gas": "0", "price": "5ugnot"}"#).unwrap_err();
198+
199+
assert!(matches!(
200+
err,
201+
crate::BroadcastTxCommitError::InvalidGasPrice { price } if price == "5ugnot"
202+
));
203+
204+
let err = parse_gas_price_response(br#"{"gas": "-1", "price": "5ugnot"}"#).unwrap_err();
205+
206+
assert!(matches!(
207+
err,
208+
crate::BroadcastTxCommitError::InvalidGasPrice { price } if price == "5ugnot"
209+
));
210+
}
211+
212+
fn gas_price(gas: i64, amount: u128, denom: &str) -> GasPrice {
213+
GasPrice {
214+
gas,
215+
amount,
216+
denom: denom.to_owned(),
217+
}
218+
}
219+
220+
#[test]
221+
fn compute_fee_prices_gas_limit_at_reported_price() {
222+
let fee = compute_fee(1000, &gas_price(1000, 5, "ugnot"), None).unwrap();
223+
224+
assert_eq!(fee.gas_wanted, 1000);
225+
assert_eq!(fee.gas_fee, "5ugnot");
226+
}
227+
228+
#[test]
229+
fn compute_fee_never_overrides_a_denom_the_chain_reported() {
230+
let fee = compute_fee(1000, &gas_price(1000, 5, "ugnot"), Some("uatom")).unwrap();
231+
232+
assert_eq!(fee.gas_fee, "5ugnot");
233+
}
234+
235+
#[test]
236+
fn compute_fee_falls_back_to_override_when_chain_reports_no_denom() {
237+
let fee = compute_fee(1000, &gas_price(0, 0, ""), Some("ugnot")).unwrap();
238+
239+
assert_eq!(fee.gas_fee, "0ugnot");
240+
}
241+
242+
#[test]
243+
fn compute_fee_errors_instead_of_producing_a_denomless_fee() {
244+
let err = compute_fee(1000, &gas_price(0, 0, ""), None).unwrap_err();
245+
246+
assert!(matches!(
247+
err,
248+
crate::BroadcastTxCommitError::MissingGasDenom
249+
));
250+
}
251+
252+
#[tokio::test]
253+
#[ignore = "hits a live gno node"]
254+
async fn get_gas_price_against_live_node() {
255+
let client = gno_rpc::Client::new("https://sapphire.rpc.onbloc.xyz:443")
256+
.await
257+
.unwrap();
258+
259+
let filler = GasFiller::new(
260+
Config {
261+
max_gas: 1_000_000_000,
262+
gas_multiplier: None,
263+
denom: None,
264+
},
265+
client,
266+
);
267+
268+
let price = filler.get_gas_price().await.unwrap();
269+
270+
println!("{price:?}");
271+
272+
assert!(price.gas > 0);
273+
assert!(!price.denom.is_empty());
274+
}
275+
}

0 commit comments

Comments
 (0)