Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Fee estimation #13

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
161 changes: 161 additions & 0 deletions src/bitcoind/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,167 @@ impl BitcoinD {
self.make_node_request_failible("sendrawtransaction", &params!(tx_hex))
.map(|_| ())
}

/// Get fee-rate estimate
/// TODO: limit conf_target to range 1 to 1008
pub fn feerate_estimate(
&self,
conf_target: i16,
) -> Result<BlockFeerateEstimate, BitcoindError> {
let result = self.make_node_request("estimatesmartfee", &params!(conf_target));

if let Some(_) = result.get("errors") {
// Fallback feerate estimate, using "85Q1H" strategy
let height = self
.make_node_request("getblockcount", &params!())
.as_i64()
.expect("Invalid height value");
let window_len = 6; // 1 hour of blocks
let mut feerates = Vec::with_capacity(window_len);
for i in height - window_len as i64..height {
feerates.push(self.block_stats(i as i32)?.avgfeerate);
}
let q: f64 = 0.85;
if let Some(f) = quantile(feerates, q) {
Ok(BlockFeerateEstimate {
feerate: f,
block: height,
})
} else {
// FIXME: return Error instead of constant feerate?
Ok(BlockFeerateEstimate {
feerate: 5,
block: height,
})
}
} else {
// estimatesmartfee succeeded, feerate units of the response are BTC/kb
let mut f = result
.get("feerate")
.and_then(|f| f.as_f64())
.expect("'estimatesmartfee' didn't return a valid 'feerate' entry")
* 100000.0_f64; // convert to sat/vB
f = f.round();

Ok(BlockFeerateEstimate {
feerate: f as u64,
block: result
.get("blocks")
.and_then(|n| n.as_i64())
.expect("'estimatesmartfee' didn't return a 'blocks' entry"),
})
}
}

/// Get block stats
pub fn block_stats(&self, block_height: i32) -> Result<BlockStats, BitcoindError> {
let res = self.make_node_request_failible("getblockstats", &params!(block_height))?;

Ok(BlockStats {
block: block_height,
avgfee: res
.get("avgfee")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
avgfeerate: res
.get("avgfeerate")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
avgtxsize: res
.get("avgtxsize")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
feerate_percentiles: res
.get("feerate_percentiles")
.expect("'getblockstats' didn't return a valid entry for 'feerate_percentiles'")
.as_array()
.expect("'getblockstats' didn't return a valid array for 'feerate_percentiles'")
.into_iter()
.map(|f| f.as_u64().expect("Failed to cast to u64"))
.collect(),
maxfee: res
.get("maxfee")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
maxfeerate: res
.get("maxfeerate")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
minfee: res
.get("minfee")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
minfeerate: res
.get("minfeerate")
.and_then(|f| f.as_u64())
.expect("'getblockstats' didn't return a valid entry"),
})
}

/// Get feerate reserve per vault with 95Q90D
pub fn feerate_reserve_per_vault(
&self,
block_height: i32,
) -> Result<Option<u64>, BitcoindError> {
let window_len: usize = 144 * 90; // 90 days of blocks

// Not enough blocks to perform the computation, return None
if block_height < window_len as i32 {
return Ok(None);
}

let mut feerates = Vec::with_capacity(window_len);

for i in block_height - window_len as i32..block_height + 1 {
feerates.push(self.block_stats(i)?.avgfeerate)
}

let q: f64 = 0.95; // 95th quantile
if let Some(f) = quantile(feerates, q) {
return Ok(Some(f));
} else {
Ok(None)
}
}
}

/// Helper function to compute the value of a collection that sits at or immediately above the
/// given quantile
pub fn quantile(mut collection: Vec<u64>, quantile: f64) -> Option<u64> {
let len = collection.len();
if len == 0 {
return None;
}
collection.sort();
for (i, f) in collection.iter().enumerate() {
if i as f64 >= quantile * len as f64 {
return Some(*f);
}
}
None
}

/// TODO: Feerate type
// #[derive(Debug)]
// pub struct Feerate(pub f64);

/// Block Statistics
pub struct BlockStats {
pub block: i32,
pub avgfee: u64,
pub avgfeerate: u64,
pub avgtxsize: u64,
pub feerate_percentiles: Vec<u64>,
pub maxfee: u64,
pub maxfeerate: u64,
pub minfee: u64,
pub minfeerate: u64,
}

/// Feerate estimate at associated block
pub struct BlockFeerateEstimate {
pub feerate: u64,
pub block: i64,
}

/// Info about bitcoind's sync state
Expand Down
102 changes: 89 additions & 13 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ use revault_tx::{
bitcoin::{secp256k1, util::bip32, Amount, BlockHash, Network, OutPoint},
scripts::{CpfpDescriptor, DepositDescriptor, UnvaultDescriptor},
};
use schema::{DbInstance, DbSignature, DbVault, SigTxType, SCHEMA};
use schema::{DbFeerate, DbInstance, DbSignature, DbVault, SigTxType, SCHEMA};

use std::{collections, convert::TryInto, fs, io, os::unix::fs::OpenOptionsExt, path, time};

use rusqlite::params;

pub const DB_VERSION: u32 = 0;
// FIXME: Use correct values
pub const INIT_VAULT_RESERVE_FEERATE: u64 = 1000;
pub const INIT_VAULT_RESERVE_HEIGHT: i32 = 680000;

#[derive(Debug)]
pub enum DatabaseError {
Expand All @@ -22,6 +25,8 @@ pub enum DatabaseError {
DescriptorMismatch(String, String),
/// An operation was requested on a vault that doesn't exist
UnknownVault(Box<dyn std::fmt::Debug>),
/// Vault reserve feerate not found
VaultReserveFeerateNotFound(String),
}

impl std::fmt::Display for DatabaseError {
Expand All @@ -43,6 +48,9 @@ impl std::fmt::Display for DatabaseError {
"Operation requested on vault at '{:?}' but no such vault exist in database.",
*id
),
Self::VaultReserveFeerateNotFound(ref e) => {
write!(f, "Vault reserve feerate not found: {}", e)
}
}
}
}
Expand Down Expand Up @@ -412,6 +420,41 @@ pub fn db_cancel_signatures(
db_sigs_by_type(db_path, vault_id, SigTxType::Cancel)
}

pub fn db_update_vault_reserve_feerate(
db_path: &path::Path,
last_update: i32,
vault_reserve_feerate: u64,
) -> Result<(), DatabaseError> {
db_exec(db_path, |db_tx| {
db_tx.execute(
"UPDATE feerates
SET last_update = (?1),
vault_reserve_feerate = (?2)",
params![last_update, vault_reserve_feerate],
)?;
Ok(())
})
}

pub fn db_vault_reserve_feerate(db_path: &path::Path) -> Result<DbFeerate, DatabaseError> {
let res: Option<DbFeerate> = db_query(
db_path,
"SELECT * FROM feerates ORDER BY last_update DESC LIMIT 1",
[],
|row| row.try_into(),
)?
.pop();

match res {
Some(db_feerate) => Ok(db_feerate),
None => {
return Err(DatabaseError::VaultReserveFeerateNotFound(String::from(
"Feerates table not correctly initialised.",
)))
}
}
}

// Create the db file with RW permissions only for the user
fn create_db_file(db_path: &path::Path) -> Result<(), DatabaseError> {
let mut options = fs::OpenOptions::new();
Expand Down Expand Up @@ -460,7 +503,19 @@ fn create_db(
vec![0u8; 32],
],
)?;

if network == Network::Bitcoin {
tx.execute(
"INSERT INTO feerates (last_update, vault_reserve_feerate)
VALUES (?1,?2)",
params![INIT_VAULT_RESERVE_HEIGHT, INIT_VAULT_RESERVE_FEERATE],
)?;
} else {
tx.execute(
"INSERT INTO feerates (last_update, vault_reserve_feerate)
VALUES (?1,?2)",
params![0, INIT_VAULT_RESERVE_FEERATE],
)?;
}
Ok(())
})
}
Expand Down Expand Up @@ -547,7 +602,7 @@ mod tests {
use super::*;

// Create a dummy database and return its path (to be deleted by the caller)
fn get_db() -> path::PathBuf {
fn get_db(network: Network) -> path::PathBuf {
let db_path: path::PathBuf =
format!("scratch_test_{:?}.sqlite3", thread::current().id()).into();
let deposit_desc = DepositDescriptor::from_str("wsh(multi(2,xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*,xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))#n3cj9mhy").unwrap();
Expand All @@ -557,14 +612,7 @@ mod tests {
// Remove any potential leftover from a previous crashed session
fs::remove_file(&db_path).unwrap_or_else(|_| ());

setup_db(
&db_path,
&deposit_desc,
&unvault_desc,
&cpfp_desc,
Network::Bitcoin,
)
.unwrap();
setup_db(&db_path, &deposit_desc, &unvault_desc, &cpfp_desc, network).unwrap();

db_path
}
Expand Down Expand Up @@ -698,7 +746,7 @@ mod tests {
// Sanity check we can create, delegate and delete a vault
#[test]
fn db_vault_creation() {
let db_path = get_db();
let db_path = get_db(Network::Bitcoin);
let outpoint_a = OutPoint::from_str(
"5bebdb97b54e2268b3fccd4aeea99419d87a92f88f27e906ceea5e863946a731:0",
)
Expand Down Expand Up @@ -974,7 +1022,7 @@ mod tests {

#[test]
fn db_tip_update() {
let db_path = get_db();
let db_path = get_db(Network::Bitcoin);

let height = 21;
let hash =
Expand All @@ -997,4 +1045,32 @@ mod tests {
// Cleanup
fs::remove_file(&db_path).unwrap();
}

#[test]
fn db_feerates_table() {
let db_path = get_db(Network::Testnet);
let vault_reserve_feerate = 1001;
let test_feerate = 1337;

let init_feerate = db_vault_reserve_feerate(&db_path)
.unwrap()
.vault_reserve_feerate;
assert_eq!(init_feerate, INIT_VAULT_RESERVE_FEERATE);

for last_update in 1..=10 {
if last_update < 10 {
db_update_vault_reserve_feerate(&db_path, last_update, vault_reserve_feerate)
.unwrap();
} else {
db_update_vault_reserve_feerate(&db_path, last_update, test_feerate).unwrap();
}
}

let row = db_vault_reserve_feerate(&db_path).unwrap();

assert_eq!(row.last_update, 10);
assert_eq!(row.vault_reserve_feerate, test_feerate);

fs::remove_file(&db_path).unwrap();
}
}
33 changes: 33 additions & 0 deletions src/database/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ CREATE TABLE signatures (
ON UPDATE RESTRICT
ON DELETE RESTRICT
);

/* Track vault_feerate_reserve value - the cumulative maximum of the 95-th
quantile of mean block feerates over a 90 day window
*/
CREATE TABLE feerates (
id INTEGER PRIMARY KEY NOT NULL,
last_update INTEGER NOT NULL,
vault_reserve_feerate INTEGER NOT NULL
);
";

/// A row in the "instances" table
Expand Down Expand Up @@ -247,3 +256,27 @@ impl TryFrom<&rusqlite::Row<'_>> for DbSignature {
})
}
}

/// A row in the "feerates" table
#[derive(Clone, Debug)]
pub struct DbFeerate {
pub id: i64,
pub last_update: i32,
pub vault_reserve_feerate: u64,
}

impl TryFrom<&rusqlite::Row<'_>> for DbFeerate {
type Error = rusqlite::Error;

fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
let id = row.get(0)?;
let last_update = row.get(1)?;
let vault_reserve_feerate = row.get(2)?;

Ok(DbFeerate {
id,
last_update,
vault_reserve_feerate,
})
}
}
Loading