Skip to content

Commit 6dc871a

Browse files
committed
feerates: implement vault_reserve_feerate and feerate_estimate
1 parent 7e91637 commit 6dc871a

File tree

6 files changed

+394
-44
lines changed

6 files changed

+394
-44
lines changed

src/bitcoind/interface.rs

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -428,24 +428,164 @@ impl BitcoinD {
428428

429429
/// Get fee-rate estimate
430430
/// TODO: limit conf_target to range 1 to 1008
431-
pub fn feerate_estimate(&self, conf_target: i16) -> Option<FeeRate> {
431+
pub fn feerate_estimate(
432+
&self,
433+
conf_target: i16,
434+
) -> Result<BlockFeerateEstimate, BitcoindError> {
432435
let result = self.make_node_request("estimatesmartfee", &params!(conf_target));
433436

434437
if let Some(_) = result.get("errors") {
435-
None
438+
// Fallback feerate estimate, using "85Q1H" strategy
439+
let height = self
440+
.make_node_request("getblockcount", &params!())
441+
.as_i64()
442+
.expect("Invalid height value");
443+
let window_len = 6; // 1 hour of blocks
444+
let mut feerates = Vec::with_capacity(window_len);
445+
for i in height - window_len as i64..height {
446+
feerates.push(self.block_stats(i as i32)?.avgfeerate);
447+
}
448+
let q: f64 = 0.85;
449+
if let Some(f) = quantile(feerates, q) {
450+
Ok(BlockFeerateEstimate {
451+
feerate: f,
452+
block: height,
453+
})
454+
} else {
455+
// FIXME: return Error instead of constant feerate?
456+
Ok(BlockFeerateEstimate {
457+
feerate: 5,
458+
block: height,
459+
})
460+
}
436461
} else {
437-
let feerate = result
462+
// estimatesmartfee succeeded, feerate units of the response are BTC/kb
463+
let mut f = result
438464
.get("feerate")
439465
.and_then(|f| f.as_f64())
440-
.expect("'estimatesmartfee' didn't return a 'feerate' entry");
441-
let blocks = result
442-
.get("blocks")
443-
.and_then(|n| n.as_i64())
444-
.expect("'estimatesmartfee' didn't return a 'blocks' entry");
466+
.expect("'estimatesmartfee' didn't return a valid 'feerate' entry")
467+
* 100000.0_f64; // convert to sat/vB
468+
f = f.round();
469+
470+
Ok(BlockFeerateEstimate {
471+
feerate: f as u64,
472+
block: result
473+
.get("blocks")
474+
.and_then(|n| n.as_i64())
475+
.expect("'estimatesmartfee' didn't return a 'blocks' entry"),
476+
})
477+
}
478+
}
479+
480+
/// Get block stats
481+
pub fn block_stats(&self, block_height: i32) -> Result<BlockStats, BitcoindError> {
482+
let res = self.make_node_request_failible("getblockstats", &params!(block_height))?;
483+
484+
Ok(BlockStats {
485+
block: block_height,
486+
avgfee: res
487+
.get("avgfee")
488+
.and_then(|f| f.as_u64())
489+
.expect("'getblockstats' didn't return a valid entry"),
490+
avgfeerate: res
491+
.get("avgfeerate")
492+
.and_then(|f| f.as_u64())
493+
.expect("'getblockstats' didn't return a valid entry"),
494+
avgtxsize: res
495+
.get("avgtxsize")
496+
.and_then(|f| f.as_u64())
497+
.expect("'getblockstats' didn't return a valid entry"),
498+
feerate_percentiles: res
499+
.get("feerate_percentiles")
500+
.expect("'getblockstats' didn't return a valid entry for 'feerate_percentiles'")
501+
.as_array()
502+
.expect("'getblockstats' didn't return a valid array for 'feerate_percentiles'")
503+
.into_iter()
504+
.map(|f| f.as_u64().expect("Failed to cast to u64"))
505+
.collect(),
506+
maxfee: res
507+
.get("maxfee")
508+
.and_then(|f| f.as_u64())
509+
.expect("'getblockstats' didn't return a valid entry"),
510+
maxfeerate: res
511+
.get("maxfeerate")
512+
.and_then(|f| f.as_u64())
513+
.expect("'getblockstats' didn't return a valid entry"),
514+
minfee: res
515+
.get("minfee")
516+
.and_then(|f| f.as_u64())
517+
.expect("'getblockstats' didn't return a valid entry"),
518+
minfeerate: res
519+
.get("minfeerate")
520+
.and_then(|f| f.as_u64())
521+
.expect("'getblockstats' didn't return a valid entry"),
522+
})
523+
}
524+
525+
/// Get feerate reserve per vault with 95Q90D
526+
pub fn feerate_reserve_per_vault(
527+
&self,
528+
block_height: i32,
529+
) -> Result<Option<u64>, BitcoindError> {
530+
let window_len: usize = 144 * 90; // 90 days of blocks
531+
532+
// Not enough blocks to perform the computation, return None
533+
if block_height < window_len as i32 {
534+
return Ok(None);
535+
}
536+
537+
let mut feerates = Vec::with_capacity(window_len);
445538

446-
Some(FeeRate { feerate, blocks })
539+
for i in block_height - window_len as i32..block_height + 1 {
540+
feerates.push(self.block_stats(i)?.avgfeerate)
541+
}
542+
543+
let q: f64 = 0.95; // 95th quantile
544+
if let Some(f) = quantile(feerates, q) {
545+
return Ok(Some(f));
546+
} else {
547+
Ok(None)
548+
}
549+
}
550+
}
551+
552+
/// Helper function to compute the value of a collection that sits at or immediately above the
553+
/// given quantile
554+
pub fn quantile(mut collection: Vec<u64>, quantile: f64) -> Option<u64> {
555+
let len = collection.len();
556+
if len == 0 {
557+
return None;
558+
}
559+
collection.sort();
560+
for (i, f) in collection.iter().enumerate() {
561+
if i as f64 >= quantile * len as f64 {
562+
return Some(*f);
447563
}
448564
}
565+
None
566+
}
567+
568+
/// TODO: Feerate type
569+
// #[derive(Debug)]
570+
// pub struct Feerate(pub f64);
571+
572+
/// Block Statistics
573+
pub struct BlockStats {
574+
pub block: i32,
575+
pub avgfee: u64,
576+
pub avgfeerate: u64,
577+
pub avgtxsize: u64,
578+
pub feerate_percentiles: Vec<u64>,
579+
pub maxfee: u64,
580+
pub maxfeerate: u64,
581+
pub minfee: u64,
582+
pub minfeerate: u64,
583+
}
584+
585+
/// Feerate estimate at associated block
586+
pub struct BlockFeerateEstimate {
587+
pub feerate: u64,
588+
pub block: i64,
449589
}
450590

451591
/// Info about bitcoind's sync state
@@ -469,9 +609,3 @@ pub struct UtxoInfo {
469609
pub bestblock: BlockHash,
470610
pub value: Amount,
471611
}
472-
473-
/// FeeRate information from estimatesmartfee
474-
pub struct FeeRate {
475-
pub feerate: f64,
476-
pub blocks: i64,
477-
}

src/database/mod.rs

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ use revault_tx::{
44
bitcoin::{secp256k1, util::bip32, Amount, BlockHash, Network, OutPoint},
55
scripts::{CpfpDescriptor, DepositDescriptor, UnvaultDescriptor},
66
};
7-
use schema::{DbInstance, DbSignature, DbVault, SigTxType, SCHEMA};
7+
use schema::{DbFeerate, DbInstance, DbSignature, DbVault, SigTxType, SCHEMA};
88

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

1111
use rusqlite::params;
1212

1313
pub const DB_VERSION: u32 = 0;
14+
// FIXME: Use correct values
15+
pub const INIT_VAULT_RESERVE_FEERATE: u64 = 1000;
16+
pub const INIT_VAULT_RESERVE_HEIGHT: i32 = 680000;
1417

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

2732
impl std::fmt::Display for DatabaseError {
@@ -43,6 +48,9 @@ impl std::fmt::Display for DatabaseError {
4348
"Operation requested on vault at '{:?}' but no such vault exist in database.",
4449
*id
4550
),
51+
Self::VaultReserveFeerateNotFound(ref e) => {
52+
write!(f, "Vault reserve feerate not found: {}", e)
53+
}
4654
}
4755
}
4856
}
@@ -412,6 +420,41 @@ pub fn db_cancel_signatures(
412420
db_sigs_by_type(db_path, vault_id, SigTxType::Cancel)
413421
}
414422

423+
pub fn db_update_vault_reserve_feerate(
424+
db_path: &path::Path,
425+
last_update: i32,
426+
vault_reserve_feerate: u64,
427+
) -> Result<(), DatabaseError> {
428+
db_exec(db_path, |db_tx| {
429+
db_tx.execute(
430+
"UPDATE feerates
431+
SET last_update = (?1),
432+
vault_reserve_feerate = (?2)",
433+
params![last_update, vault_reserve_feerate],
434+
)?;
435+
Ok(())
436+
})
437+
}
438+
439+
pub fn db_vault_reserve_feerate(db_path: &path::Path) -> Result<DbFeerate, DatabaseError> {
440+
let res: Option<DbFeerate> = db_query(
441+
db_path,
442+
"SELECT * FROM feerates ORDER BY last_update DESC LIMIT 1",
443+
[],
444+
|row| row.try_into(),
445+
)?
446+
.pop();
447+
448+
match res {
449+
Some(db_feerate) => Ok(db_feerate),
450+
None => {
451+
return Err(DatabaseError::VaultReserveFeerateNotFound(String::from(
452+
"Feerates table not correctly initialised.",
453+
)))
454+
}
455+
}
456+
}
457+
415458
// Create the db file with RW permissions only for the user
416459
fn create_db_file(db_path: &path::Path) -> Result<(), DatabaseError> {
417460
let mut options = fs::OpenOptions::new();
@@ -460,7 +503,19 @@ fn create_db(
460503
vec![0u8; 32],
461504
],
462505
)?;
463-
506+
if network == Network::Bitcoin {
507+
tx.execute(
508+
"INSERT INTO feerates (last_update, vault_reserve_feerate)
509+
VALUES (?1,?2)",
510+
params![INIT_VAULT_RESERVE_HEIGHT, INIT_VAULT_RESERVE_FEERATE],
511+
)?;
512+
} else {
513+
tx.execute(
514+
"INSERT INTO feerates (last_update, vault_reserve_feerate)
515+
VALUES (?1,?2)",
516+
params![0, INIT_VAULT_RESERVE_FEERATE],
517+
)?;
518+
}
464519
Ok(())
465520
})
466521
}
@@ -547,7 +602,7 @@ mod tests {
547602
use super::*;
548603

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

560-
setup_db(
561-
&db_path,
562-
&deposit_desc,
563-
&unvault_desc,
564-
&cpfp_desc,
565-
Network::Bitcoin,
566-
)
567-
.unwrap();
615+
setup_db(&db_path, &deposit_desc, &unvault_desc, &cpfp_desc, network).unwrap();
568616

569617
db_path
570618
}
@@ -698,7 +746,7 @@ mod tests {
698746
// Sanity check we can create, delegate and delete a vault
699747
#[test]
700748
fn db_vault_creation() {
701-
let db_path = get_db();
749+
let db_path = get_db(Network::Bitcoin);
702750
let outpoint_a = OutPoint::from_str(
703751
"5bebdb97b54e2268b3fccd4aeea99419d87a92f88f27e906ceea5e863946a731:0",
704752
)
@@ -974,7 +1022,7 @@ mod tests {
9741022

9751023
#[test]
9761024
fn db_tip_update() {
977-
let db_path = get_db();
1025+
let db_path = get_db(Network::Bitcoin);
9781026

9791027
let height = 21;
9801028
let hash =
@@ -997,4 +1045,32 @@ mod tests {
9971045
// Cleanup
9981046
fs::remove_file(&db_path).unwrap();
9991047
}
1048+
1049+
#[test]
1050+
fn db_feerates_table() {
1051+
let db_path = get_db(Network::Testnet);
1052+
let vault_reserve_feerate = 1001;
1053+
let test_feerate = 1337;
1054+
1055+
let init_feerate = db_vault_reserve_feerate(&db_path)
1056+
.unwrap()
1057+
.vault_reserve_feerate;
1058+
assert_eq!(init_feerate, INIT_VAULT_RESERVE_FEERATE);
1059+
1060+
for last_update in 1..=10 {
1061+
if last_update < 10 {
1062+
db_update_vault_reserve_feerate(&db_path, last_update, vault_reserve_feerate)
1063+
.unwrap();
1064+
} else {
1065+
db_update_vault_reserve_feerate(&db_path, last_update, test_feerate).unwrap();
1066+
}
1067+
}
1068+
1069+
let row = db_vault_reserve_feerate(&db_path).unwrap();
1070+
1071+
assert_eq!(row.last_update, 10);
1072+
assert_eq!(row.vault_reserve_feerate, test_feerate);
1073+
1074+
fs::remove_file(&db_path).unwrap();
1075+
}
10001076
}

0 commit comments

Comments
 (0)