Skip to content

Commit 194d391

Browse files
committed
feerates: implement vault_reserve_feerate and feerate_estimate
1 parent 7e91637 commit 194d391

File tree

6 files changed

+366
-42
lines changed

6 files changed

+366
-42
lines changed

src/bitcoind/interface.rs

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -428,26 +428,138 @@ 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(&self, conf_target: i16) -> Result<BlockFeerateEstimate, BitcoindError> {
432432
let result = self.make_node_request("estimatesmartfee", &params!(conf_target));
433433

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

446-
Some(FeeRate { feerate, blocks })
506+
let mut feerates = Vec::with_capacity(window_len);
507+
508+
for i in block_height-window_len as i32..block_height+1 {
509+
feerates.push(self.block_stats(i)?.avgfeerate)
510+
}
511+
512+
let q: f64 = 0.95; // 95th quantile
513+
if let Some(f) = quantile(feerates, q) {
514+
return Ok(Some(f))
515+
} else {
516+
Ok(None)
447517
}
448518
}
449519
}
450520

521+
/// Helper function to compute the value of a collection that sits at or immediately above the
522+
/// given quantile
523+
pub fn quantile(mut collection: Vec<u64>, quantile: f64) -> Option<u64> {
524+
let len = collection.len();
525+
if len == 0 {
526+
return None
527+
}
528+
collection.sort();
529+
for (i,f) in collection.iter().enumerate() {
530+
if i as f64 >= quantile*len as f64 {
531+
return Some(*f)
532+
}
533+
}
534+
None
535+
}
536+
537+
538+
/// TODO: Feerate type
539+
// #[derive(Debug)]
540+
// pub struct Feerate(pub f64);
541+
542+
/// Block Statistics
543+
pub struct BlockStats {
544+
pub block: i32,
545+
pub avgfee: u64,
546+
pub avgfeerate: u64,
547+
pub avgtxsize: u64,
548+
pub feerate_percentiles: Vec<u64>,
549+
pub maxfee: u64,
550+
pub maxfeerate: u64,
551+
pub minfee: u64,
552+
pub minfeerate: u64,
553+
}
554+
555+
556+
/// Feerate estimate at associated block
557+
pub struct BlockFeerateEstimate {
558+
pub feerate: u64,
559+
pub block: i64,
560+
}
561+
562+
451563
/// Info about bitcoind's sync state
452564
pub struct SyncInfo {
453565
pub headers: u64,
@@ -468,10 +580,4 @@ pub struct UtxoInfo {
468580
pub confirmations: i64,
469581
pub bestblock: BlockHash,
470582
pub value: Amount,
471-
}
472-
473-
/// FeeRate information from estimatesmartfee
474-
pub struct FeeRate {
475-
pub feerate: f64,
476-
pub blocks: i64,
477-
}
583+
}

src/database/mod.rs

Lines changed: 81 additions & 6 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) => write!(
52+
f,
53+
"Vault reserve feerate not found: {}", e),
4654
}
4755
}
4856
}
@@ -412,6 +420,36 @@ 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+
440+
pub fn db_vault_reserve_feerate(db_path: &path::Path) -> Result<DbFeerate, DatabaseError> {
441+
let res: Option<DbFeerate> = db_query(
442+
db_path,
443+
"SELECT * FROM feerates ORDER BY last_update DESC LIMIT 1",
444+
[], |row| row.try_into())?.pop();
445+
446+
match res {
447+
Some(db_feerate) => Ok(db_feerate),
448+
None => return Err(DatabaseError::VaultReserveFeerateNotFound(
449+
String::from("Feerates table not correctly initialised.")))
450+
}
451+
}
452+
415453
// Create the db file with RW permissions only for the user
416454
fn create_db_file(db_path: &path::Path) -> Result<(), DatabaseError> {
417455
let mut options = fs::OpenOptions::new();
@@ -460,7 +498,19 @@ fn create_db(
460498
vec![0u8; 32],
461499
],
462500
)?;
463-
501+
if network == Network::Bitcoin {
502+
tx.execute(
503+
"INSERT INTO feerates (last_update, vault_reserve_feerate)
504+
VALUES (?1,?2)",
505+
params![INIT_VAULT_RESERVE_HEIGHT, INIT_VAULT_RESERVE_FEERATE],
506+
)?;
507+
} else {
508+
tx.execute(
509+
"INSERT INTO feerates (last_update, vault_reserve_feerate)
510+
VALUES (?1,?2)",
511+
params![0, INIT_VAULT_RESERVE_FEERATE],
512+
)?;
513+
}
464514
Ok(())
465515
})
466516
}
@@ -547,7 +597,7 @@ mod tests {
547597
use super::*;
548598

549599
// Create a dummy database and return its path (to be deleted by the caller)
550-
fn get_db() -> path::PathBuf {
600+
fn get_db(network: Network) -> path::PathBuf {
551601
let db_path: path::PathBuf =
552602
format!("scratch_test_{:?}.sqlite3", thread::current().id()).into();
553603
let deposit_desc = DepositDescriptor::from_str("wsh(multi(2,xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*,xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))#n3cj9mhy").unwrap();
@@ -562,7 +612,7 @@ mod tests {
562612
&deposit_desc,
563613
&unvault_desc,
564614
&cpfp_desc,
565-
Network::Bitcoin,
615+
network,
566616
)
567617
.unwrap();
568618

@@ -698,7 +748,7 @@ mod tests {
698748
// Sanity check we can create, delegate and delete a vault
699749
#[test]
700750
fn db_vault_creation() {
701-
let db_path = get_db();
751+
let db_path = get_db(Network::Bitcoin);
702752
let outpoint_a = OutPoint::from_str(
703753
"5bebdb97b54e2268b3fccd4aeea99419d87a92f88f27e906ceea5e863946a731:0",
704754
)
@@ -974,7 +1024,7 @@ mod tests {
9741024

9751025
#[test]
9761026
fn db_tip_update() {
977-
let db_path = get_db();
1027+
let db_path = get_db(Network::Bitcoin);
9781028

9791029
let height = 21;
9801030
let hash =
@@ -997,4 +1047,29 @@ mod tests {
9971047
// Cleanup
9981048
fs::remove_file(&db_path).unwrap();
9991049
}
1050+
1051+
#[test]
1052+
fn db_feerates_table() {
1053+
let db_path = get_db(Network::Testnet);
1054+
let vault_reserve_feerate = 1001;
1055+
let test_feerate = 1337;
1056+
1057+
let init_feerate = db_vault_reserve_feerate(&db_path).unwrap().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).unwrap();
1063+
} else {
1064+
db_update_vault_reserve_feerate(&db_path, last_update, test_feerate).unwrap();
1065+
}
1066+
}
1067+
1068+
let row = db_vault_reserve_feerate(&db_path).unwrap();
1069+
1070+
assert_eq!(row.last_update, 10);
1071+
assert_eq!(row.vault_reserve_feerate, test_feerate);
1072+
1073+
fs::remove_file(&db_path).unwrap();
1074+
}
10001075
}

src/database/schema.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ CREATE TABLE signatures (
7373
ON UPDATE RESTRICT
7474
ON DELETE RESTRICT
7575
);
76+
77+
/* Track vault_feerate_reserve value - the cumulative maximum of the 95-th
78+
quantile of mean block feerates over a 90 day window
79+
*/
80+
CREATE TABLE feerates (
81+
id INTEGER PRIMARY KEY NOT NULL,
82+
last_update INTEGER NOT NULL,
83+
vault_reserve_feerate INTEGER NOT NULL
84+
);
7685
";
7786

7887
/// A row in the "instances" table
@@ -247,3 +256,27 @@ impl TryFrom<&rusqlite::Row<'_>> for DbSignature {
247256
})
248257
}
249258
}
259+
260+
/// A row in the "feerates" table
261+
#[derive(Clone, Debug)]
262+
pub struct DbFeerate {
263+
pub id: i64,
264+
pub last_update: i32,
265+
pub vault_reserve_feerate: u64,
266+
}
267+
268+
impl TryFrom<&rusqlite::Row<'_>> for DbFeerate {
269+
type Error = rusqlite::Error;
270+
271+
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
272+
let id = row.get(0)?;
273+
let last_update = row.get(1)?;
274+
let vault_reserve_feerate = row.get(2)?;
275+
276+
Ok(DbFeerate {
277+
id,
278+
last_update,
279+
vault_reserve_feerate,
280+
})
281+
}
282+
}

0 commit comments

Comments
 (0)