Add BCMR indexer

This adds BCMR indexer, including downloading and parsing the actual
BCMR files.
This commit is contained in:
Dagur Valberg Johannsson 2024-05-09 11:25:27 +02:00
parent 49730f27ca
commit e38eaaa26c
No known key found for this signature in database
GPG key ID: FD701804AEE88107
26 changed files with 1994 additions and 451 deletions

108
Cargo.lock generated
View file

@ -154,6 +154,12 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bech32"
version = "0.9.1"
@ -329,6 +335,15 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.3"
@ -418,10 +433,10 @@ dependencies = [
"byteorder",
"libc",
"log",
"rustls",
"rustls 0.21.9",
"serde",
"serde_json",
"webpki-roots",
"webpki-roots 0.25.4",
"winapi",
]
@ -482,6 +497,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "flate2"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -1324,6 +1349,7 @@ dependencies = [
"log",
"r2d2",
"r2d2_sqlite",
"rand",
"rayon",
"riftenlabs-defi",
"rocket",
@ -1334,6 +1360,7 @@ dependencies = [
"serde",
"serde_json",
"stderrlog",
"ureq",
]
[[package]]
@ -1545,10 +1572,30 @@ checksum = "629648aced5775d558af50b2b4c7b02983a04b312126d45eeead26e7caa498b9"
dependencies = [
"log",
"ring",
"rustls-webpki",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.4",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@ -1559,6 +1606,17 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.102.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.14"
@ -1702,9 +1760,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.11.2"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "socket2"
@ -1763,6 +1821,12 @@ dependencies = [
"thread_local",
]
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
[[package]]
name = "syn"
version = "1.0.109"
@ -2111,6 +2175,25 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls 0.22.4",
"rustls-pki-types",
"rustls-webpki 0.102.4",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.1",
]
[[package]]
name = "url"
version = "2.5.0"
@ -2225,6 +2308,15 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -2452,3 +2544,9 @@ dependencies = [
"quote",
"syn 2.0.39",
]
[[package]]
name = "zeroize"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"

View file

@ -29,6 +29,8 @@ r2d2 = "0.8.10"
r2d2_sqlite = "0.24.0"
rust_decimal = "1.34"
rust_decimal_macros = "1.34"
ureq = { version = "2.9", features = ["json"] }
rand = "0.8.5"
[profile.release]
debug = true

16
contrib/volume.py Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env python3
# Copyright (C) 2024 Riften Labs AS,
# This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.,
# A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
from urllib.request import urlopen
import json
url = "http://indexer.cauldron.quest/cauldron/contract/volume?end=9999999999"
with urlopen(url) as response:
data = json.load(response)
total_satoshis = sum(item["total_sats"] for item in data)
print(f"Total BCH: {total_satoshis / 100000000}")

449
src/bcmr/bcmrdownloader.rs Normal file
View file

@ -0,0 +1,449 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use std::{
sync::{atomic::AtomicBool, Arc},
thread::{self, JoinHandle},
time::Duration,
};
use crate::bcmr::parsedbcmr::ParsedBCMR;
use crate::db::bcmr::insert_bcmr_data;
use crate::db::bcmr::update_bcmr_failure;
use crate::{
bcmr::parse_bcmr_from_opreturn,
db::{
bcmr::{get_entries_missing_bcmr_download, AuthChainEntry},
DBPool,
},
};
use anyhow::*;
use bitcoin_hashes::{hex::ToHex, sha256, Hash};
use bitcoincash::{Script, TokenID};
use log::{info, warn};
use rand::thread_rng;
use serde_json::Value;
use ureq::Agent;
use serde_json::json;
use rand::seq::SliceRandom;
use rayon::prelude::*;
use std::result::Result::Ok;
// We are generous on timeout to allow for slow ipfs gateway
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
const IPFS_GATEWAY: &str = "https://ipfs.io/ipfs/";
const MAX_BCMR_SIZE: usize = 1024 * 1024 * 100; // 100 MB
const MAX_PARALLEL_DOWNLOADS: usize = 5;
pub struct BCMRDownloader {
db: DBPool,
keep_running: Arc<AtomicBool>,
download_thread: Option<JoinHandle<()>>,
}
const LOOP_SLEEP_TIME: Duration = Duration::from_secs(10);
fn get_download_candidates(db: &DBPool) -> Vec<AuthChainEntry> {
let conn = match db.get() {
Ok(c) => c,
Err(e) => {
warn!("bcmr: Failed to get a db connection: {}", e);
return Vec::default();
}
};
match get_entries_missing_bcmr_download(&conn) {
Ok(c) => c,
Err(e) => {
warn!("bcmr: Failed to fetch bcmr download candidates: {}", e);
Vec::default()
}
}
}
fn get_url(url: &str, max_download_size: usize) -> Result<(String, String)> {
let agent = Agent::new();
let response = agent.get(url).timeout(DOWNLOAD_TIMEOUT).call()?;
if response.status() != 200 {
bail!("Failed to fetch {}: HTTP {}", url, response.status());
}
let mut reader = response.into_reader();
let mut content = Vec::new();
let mut buffer = [0; 1024]; // Read in chunks of 1KB.
while let Ok(count) = reader.read(&mut buffer) {
if count == 0 {
break;
}
if content.len() + count > max_download_size {
bail!("Download exceeded the maximum allowed size");
}
content.extend_from_slice(&buffer[..count]);
}
let content_str = String::from_utf8(content)?;
let hash = sha256::Hash::hash(content_str.as_bytes());
Ok((content_str, hash.to_hex()))
}
fn fetch_bcmr(
urls: &[String],
expected_hash: &str,
) -> (
Option<(String, String)>,
String, /* error */
bool, /* fatal error (don't try again) */
) {
let mut candidate: Option<(String, String)> = None;
let mut errors: Vec<String> = Vec::new();
for url in urls {
let url = if let Some(stripped) = url.strip_prefix("ipfs://") {
format!("{}{}", IPFS_GATEWAY, stripped)
} else if !url.starts_with("https://") {
format!("https://{}", url)
} else {
url.to_string()
};
let (contents, actual_hash) = match get_url(&url, MAX_BCMR_SIZE) {
Ok(c) => c,
Err(e) => {
errors.push(e.to_string());
continue;
}
};
if actual_hash == expected_hash {
return (Some((contents, actual_hash)), String::default(), false);
} else {
candidate = Some((contents, actual_hash))
}
}
// No URL's with matching exepected hash
if candidate.is_some() {
(candidate, String::default(), false)
} else if errors.is_empty() {
(None, "No URLs to fetch BCMR from".to_string(), true)
} else {
(
None,
format!("Errors fetching BCMR: {}", errors.join("; ")),
false,
)
}
}
pub fn parse_bcmr_json(
bcmr: &Value,
token_id: &TokenID,
actual_hash: String,
expected_hash: String,
) -> Result<ParsedBCMR> {
let identities = bcmr
.get("identities")
.context("BCMR missing 'identities'")?;
let token_history = identities
.get(token_id.to_hex())
.context("'identities' does not contain token ID")?;
let first_entry = token_history
.as_object()
.context("token history is not an object")?
.values()
.next()
.context("no entries in token history")?;
let token = first_entry
.get("token")
.context("'token' missing in token history entry")?;
let symbol = token
.get("symbol")
.context("'symbol' missing in 'token' entry")?;
let symbol = symbol.as_str().context("'symbol' field is not a string")?;
// decimals is optional and defaults to 0
let decimals_default = json!(0);
let decimals = token
.get("decimals")
.unwrap_or(&decimals_default)
.to_owned();
let decimals = {
let d_int = if let Some(d) = decimals.as_i64() {
d
} else if let Some(d_str) = decimals.as_str() {
d_str
.parse::<i64>()
.context("Invalid number in 'decimal' field for 'token")?
} else {
bail!("BCMR contains a 'decimal' field for 'token', but it's not a number")
};
if d_int < 0 {
bail!("'decimal' field for 'token' cannot be negative")
}
d_int as usize
};
let empty_string = json!("");
let name = first_entry
.get("name")
.unwrap_or(&empty_string)
.as_str()
.unwrap_or("");
let description = first_entry
.get("description")
.unwrap_or(&empty_string)
.as_str()
.unwrap_or("");
let (icon, web) = if let Some(uris) = first_entry.get("uris") {
let icon = uris
.get("icon")
.unwrap_or(&empty_string)
.as_str()
.unwrap_or("");
let web = uris
.get("web")
.unwrap_or(&empty_string)
.as_str()
.unwrap_or("");
(icon, web)
} else {
("", "")
};
Ok(ParsedBCMR::new(
&token_id.to_hex(),
symbol,
decimals,
name,
description,
icon,
web,
expected_hash,
actual_hash,
))
}
impl BCMRDownloader {
pub fn new(db: DBPool) -> Self {
Self {
db,
keep_running: Arc::new(AtomicBool::new(true)),
download_thread: None,
}
}
pub fn start(&mut self) -> Result<()> {
let db_cpy = self.db.clone();
let keep_running_cpy = self.keep_running.clone();
self.download_thread = Some(
thread::Builder::new()
.name("bcmr downloader".to_string())
.spawn(move || loop {
if !keep_running_cpy.load(std::sync::atomic::Ordering::Relaxed) {
info!("Exiting bcmr download thread");
return;
}
let mut queue = get_download_candidates(&db_cpy);
let mut rng = thread_rng();
queue.shuffle(&mut rng);
if queue.is_empty() {
thread::sleep(LOOP_SLEEP_TIME);
continue;
}
info!("bcmr: {} tokens need BCMR download", queue.len());
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(MAX_PARALLEL_DOWNLOADS)
.build()
.unwrap();
pool.install(|| {
queue.par_iter().for_each(|entry| {
info!(
"bcmr: Dowloading BCMR for token {}",
entry.token_id.to_hex()
);
let bcmr = parse_bcmr_from_opreturn(&Script::from(
entry.bcmr_data.as_ref().expect("bcmr data missing").clone(),
))
.expect("invalid bcmr entry in db");
let (json_str, error, is_fatal) =
fetch_bcmr(&bcmr.uris, &bcmr.hash.to_hex());
let conn = match db_cpy.get() {
Ok(c) => c,
Err(e) => {
warn!("bcmr: Failed to get BCMR db connection: {}", e);
return;
}
};
let (json_str, actual_hash) = match json_str {
Some(j) => j,
None => {
if let Err(e) = update_bcmr_failure(
&conn,
&entry.utxo,
&format!("Failed to fetch BCMR: {}", error),
is_fatal,
) {
warn!("bcmr: Failed to set BCMR error: {}", e)
}
return;
}
};
let json: Value = match serde_json::from_str(&json_str) {
Ok(b) => b,
Err(e) => {
if let Err(e) = update_bcmr_failure(
&conn,
&entry.utxo,
&format!("BCMR invalid JSON error: {}", e),
true,
) {
warn!("bcmr: Failed to set BCMR error: {}", e)
}
return;
}
};
let bcmr_parsed = match parse_bcmr_json(
&json,
&entry.token_id,
actual_hash,
bcmr.hash.to_hex(),
) {
Ok(b) => b,
Err(err) => {
if let Err(e) = update_bcmr_failure(
&conn,
&entry.utxo,
&format!("BCMR contents error: {}", err),
true,
) {
warn!("bcmr: Failed to set BCMR error: {}", e)
}
return;
}
};
if let Err(err) = insert_bcmr_data(&conn, &entry.utxo, &bcmr_parsed) {
warn!("bcmr: Failed to insert BCMR data {}", err)
}
});
});
thread::sleep(LOOP_SLEEP_TIME)
})
.expect("failed to start bcmr download thread"),
);
Ok(())
}
}
impl Drop for BCMRDownloader {
fn drop(&mut self) {
self.keep_running
.store(false, std::sync::atomic::Ordering::SeqCst);
if let Some(thread) = self.download_thread.take() {
// Wake the thread in case it is sleeping
thread.thread().unpark();
match thread.join() {
Ok(_) => info!("bcmr download thread done"),
Err(e) => warn!("Failed to join bcmr download thread: {:?}", e),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin_hashes::hex::FromHex;
#[test]
fn test_bcmr_parse() {
let bcmr = r#"
{
"$schema": "https://cashtokens.org/bcmr-v2.schema.json",
"version": {
"major": 0,
"minor": 1,
"patch": 0
},
"latestRevision": "2023-05-15T12:18:32.912Z",
"registryIdentity": {
"name": "Fallout Coin",
"description": "One of the earliest coins ever created on BCH",
"uris": {
"icon": "https://c3-soft.com/tokens/icon.ico",
"web": "https://c3-soft.com/tokens/",
"registry": "https://c3-soft.com/tokens/registry.json"
}
},
"identities": {
"83e12eea20b19a9a0906bb0521ff18520db69a4a8136293bafbfca0acb2c2313": {
"2023-05-12T12:00:00.000Z": {
"name": "Fallout Coin",
"description": "Commemorative Coin for the computer game Fallout",
"token": {
"category": "83e12eea20b19a9a0906bb0521ff18520db69a4a8136293bafbfca0acb2c2313",
"symbol": "FC",
"decimals": 2
},
"uris": {
"icon": "https://c3-soft.com/tokens/fallout.ico"
}
}
}
},
"license": "CC0-1.0"
}
"#;
let dummy_hash = TokenID::all_zeros().to_hex();
let parsed = parse_bcmr_json(
&serde_json::from_str(bcmr).unwrap(),
&TokenID::from_hex("83e12eea20b19a9a0906bb0521ff18520db69a4a8136293bafbfca0acb2c2313")
.unwrap(),
dummy_hash.clone(),
dummy_hash,
)
.unwrap();
assert_eq!("FC", parsed.token.symbol);
assert_eq!(2, parsed.token.decimals);
assert_eq!("Fallout Coin", parsed.name);
assert_eq!(
"Commemorative Coin for the computer game Fallout",
parsed.description
);
assert_eq!(
"https://c3-soft.com/tokens/fallout.ico",
parsed.uris.icon.unwrap()
);
assert_eq!(None, parsed.uris.web);
}
}

345
src/bcmr/mod.rs Normal file
View file

@ -0,0 +1,345 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use rayon::prelude::*;
use std::{
cmp,
collections::{HashMap, HashSet, VecDeque},
convert::TryInto,
};
use anyhow::Result;
use bitcoin_hashes::{hex::ToHex, Hash};
use bitcoincash::BlockHash;
use bitcoincash::{Script, TokenID, Transaction, Txid};
use log::debug;
use riftenlabs_defi::chainutil::{compute_outpoint_hash, read_push_from_script, OutPointHash};
use rusqlite::Connection;
use serde::{Deserialize, Serialize, Serializer};
use crate::db::bcmr::{get_matching_autheaders, insert_authheader, AuthChainEntry};
pub mod bcmrdownloader;
pub mod parsedbcmr;
pub const BCMR_PREFIX: &[u8] = &[
0x6a, // OP_RETURN
0x04, // PUSH 4
0x42, // B
0x43, // C
0x4d, // M
0x52, // R
];
// Custom function to serialize [u8; 32] as a hex string
fn as_hex<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&hex::encode(bytes))
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct BCMR {
#[serde(serialize_with = "as_hex")]
pub hash: [u8; 32],
pub uris: Vec<String>,
#[serde(skip)]
pub op_return: Vec<u8>,
}
pub fn parse_bcmr_from_opreturn(bcmr_op_return: &Script) -> Option<BCMR> {
let iter = bcmr_op_return[6..].iter();
let (iter, hash) = read_push_from_script(iter).ok()?;
let hash: [u8; 32] = hash.and_then(|h| h.try_into().ok())?;
let mut uris: Vec<String> = vec![];
let mut uri_element;
let mut uri_iter = iter;
loop {
(uri_iter, uri_element) = match read_push_from_script(uri_iter) {
Ok(r) => r,
Err(_) => break,
};
match uri_element {
Some(url) => {
if let Ok(uri) = String::from_utf8(url) {
uris.push(uri)
}
}
None => break,
}
}
Some(BCMR {
hash,
uris,
op_return: bcmr_op_return.to_bytes(),
})
}
pub fn parse_bcmr(tx: &Transaction) -> Option<BCMR> {
let bcmr_op_return = tx
.output
.iter()
.find(|o| o.script_pubkey.as_bytes().starts_with(BCMR_PREFIX))?;
parse_bcmr_from_opreturn(&bcmr_op_return.script_pubkey)
}
fn is_genesis_tx(tx: &Transaction) -> Option<TokenID> {
let potential_token_ids: HashSet<[u8; 32]> = tx
.input
.iter()
.filter(|i| i.previous_output.vout == 0)
.map(|i| i.previous_output.txid.into_inner())
.collect();
for o in &tx.output {
match &o.token {
Some(token) => {
if potential_token_ids.contains(token.id.as_inner()) {
return Some(token.id);
}
}
None => continue,
}
}
None
}
pub fn ttor_sorted(txs: Vec<Transaction>) -> Vec<Transaction> {
let txs = {
let mut queue: VecDeque<Transaction> = txs.into_iter().collect();
let mut queue_txids: HashSet<Txid> = queue.par_iter().map(|tx| tx.txid()).collect();
let mut txs: Vec<Transaction> = Vec::with_capacity(queue.len());
while let Some(tx) = queue.pop_front() {
let mut has_parent = false;
for i in &tx.input {
if queue_txids.contains(&i.previous_output.txid) {
// depends on parent
has_parent = true;
break;
};
}
if has_parent {
queue.push_back(tx);
} else {
queue_txids.remove(&tx.txid());
txs.push(tx)
}
}
txs
};
txs
}
// TODO: Use when we get scriptpubkey filter for electrum.mempoo.get
#[allow(dead_code)]
pub fn mempool_index_genesis(conn: &Connection, txs: &Vec<Transaction>) -> Result<usize> {
let genesis: Vec<(Transaction, TokenID)> = txs
.par_iter()
.filter_map(|tx| is_genesis_tx(tx).map(|token| (tx.clone(), token)))
.collect();
let mut inserts = 0;
for (tx, token) in genesis {
let txid = tx.txid();
let utxo = compute_outpoint_hash(&txid, 0);
let bcmr_data = parse_bcmr(&tx);
insert_authheader(
conn,
&utxo,
&BlockHash::all_zeros(),
&txid,
&token,
0,
bcmr_data.map(|b| b.op_return),
)?;
inserts += 1;
}
Ok(inserts)
}
pub fn index_bcmr(
conn: &Connection,
blockhash: &BlockHash,
txs: Vec<Transaction>,
) -> Result<usize> {
let sorted = ttor_sorted(txs);
let mut new_tokens: Vec<(Transaction, TokenID)> = Vec::default();
let sorted: Vec<Transaction> = sorted
.into_iter()
.filter_map(|tx| {
if let Some(token_id) = is_genesis_tx(&tx) {
// filter out new; we know it to be start of authchain
new_tokens.push((tx, token_id));
None
} else {
Some(tx)
}
})
.collect();
let mut position_index: HashMap<OutPointHash, usize> = HashMap::default();
// Look for authchain updates. We collect all candidates first.
let mut candidates: HashMap<OutPointHash, Transaction> = sorted
.into_iter()
.enumerate()
.filter_map(|(pos, tx)| {
let prevout = tx.input.first().expect("tx with no inputs").previous_output;
if prevout.vout != 0 {
// authchain needs to spend first output of a previous tx
None
} else {
let utxohash = compute_outpoint_hash(&prevout.txid, prevout.vout);
position_index.insert(utxohash, pos);
Some((utxohash, tx))
}
})
.collect();
let mut inserts = 0;
// Start by inserting new auth chains
for (tx, token_id) in new_tokens {
let bcmr = parse_bcmr(&tx);
debug!(
"Found token gensis for {} in {}; has bcmr: {}",
token_id.to_hex(),
tx.txid().to_hex(),
bcmr.is_some()
);
let txid = tx.txid();
let utxohash = compute_outpoint_hash(&tx.txid(), 0);
insert_authheader(
conn,
&utxohash,
blockhash,
&txid,
&token_id,
0,
bcmr.map(|bcmr| bcmr.op_return),
)?;
inserts += 1;
}
// Finally; we need to loop for remaining updates; as each update has the potential to add more candidates.
loop {
let matches: Vec<AuthChainEntry> = get_matching_autheaders(conn, candidates.keys())?;
if matches.is_empty() {
break;
}
let mut smallest_index = usize::MAX;
for prev_autheader in matches {
let tx = candidates
.remove(&prev_autheader.utxo)
.expect("match not in candidate list");
let bcmr = parse_bcmr(&tx);
let txid = tx.txid();
let utxohash = compute_outpoint_hash(&txid, 0);
debug!(
"Found authheader update {} in {}; has bcmr: {}; new height: {}, utxo: {}",
prev_autheader.token_id.to_hex(),
txid.to_hex(),
bcmr.is_some(),
prev_autheader.height + 1,
utxohash.to_hex()
);
insert_authheader(
conn,
&utxohash,
blockhash,
&txid,
&prev_autheader.token_id,
prev_autheader.height + 1,
bcmr.map(|bcmr| bcmr.op_return),
)?;
smallest_index = cmp::min(
smallest_index,
*position_index
.get(&prev_autheader.utxo)
.expect("entry not in position index"),
);
inserts += 1;
}
// since it's ttor sorted; there can be no chained updates above the first candidate;
// and we can remove all candidates above smallest_index
position_index.retain(|hash, pos| {
if pos > &mut smallest_index {
true
} else {
candidates.remove(hash);
false
}
});
}
Ok(inserts)
}
#[cfg(test)]
mod tests {
use bitcoincash::consensus::deserialize;
use super::*;
#[test]
fn test_parse_bcmr() {
let tx_hex = "0200000001925bd92e424c0f0bc290a794f491abf19e61b6dcdec7e70747fcb54682fc9bb700000000644181707613f45069c8df981b2ef0cbd05158d5623602fac3ae2e13f69d05fa356c278f2f3a4e34f33a79e0f8375abd35051150ec024b3625807cea185528a5de9e412103b4680dffa1e34b3bfdb024fd0a8498eb24f59ba5ead34acfb74f9d8549db6f3a0000000003e8030000000000003eef925bd92e424c0f0bc290a794f491abf19e61b6dcdec7e70747fcb54682fc9bb710fdf40176a914bd4c90f2c64743fc0d3ea6a14973a5d628260b7388ac0000000000000000456a0442434d5220c705cc90a56ac7ef9a15ef90ebbc8ba7e60e4c622e5464d52d8baf7887949fcc1d736f636b2e6361756c64726f6e2e71756573742f62636d722e6a736f6ed9210000000000001976a914bd4c90f2c64743fc0d3ea6a14973a5d628260b7388ac00000000";
let tx: Transaction = deserialize(&hex::decode(&tx_hex).unwrap()).unwrap();
let result = parse_bcmr(&tx);
assert!(result.is_some());
let bcmr = result.unwrap();
assert_eq!(
hex::encode(bcmr.hash),
"c705cc90a56ac7ef9a15ef90ebbc8ba7e60e4c622e5464d52d8baf7887949fcc"
);
assert_eq!(bcmr.uris.len(), 1);
assert_eq!(bcmr.uris[0], "sock.cauldron.quest/bcmr.json");
}
#[test]
fn invalid_bcmr_hash() {
// txid 4e44c45cf1fb956fe4752c6dfa9637f29cf18e9251e20454dd65dbcc298617b6
let tx_hex = "0200000002020056e81cda3b55f6b912acf05131e4f50594dc76702e8022d5522582f74bd40000000064414048069ab85d72e0f195e6587a4ef30360d5304a7d24995e21f8bb3d9c9c52eb5d1408a3a4754fad8da6593dc65fd248e507fe1620004bcb87f3237fcea9679e4121028bb6fdf7137233695237a6fa538827afb4f7490bad82215098b40dc3d9c645fc00000000020056e81cda3b55f6b912acf05131e4f50594dc76702e8022d5522582f74bd4010000006441d501d321b457b2622388efc9290245e7021f082d2914bf1883184ea29f546a2b08d74537f848fba17d41e44ad6f9d6949b44670b379635b00fe69caa5236e47f4121028bb6fdf7137233695237a6fa538827afb4f7490bad82215098b40dc3d9c645fc0000000003e80300000000000042ef020056e81cda3b55f6b912acf05131e4f50594dc76702e8022d5522582f74bd431ff00b402869d7e0100a9147ca730ccf0ba8c552bae1287d01a9e7e5590f0fa870000000000000000d56a0442434d5240646331303939346363323530363731643830383737626639356131613136616164356431343033623365623263373136346366623231383532323363353162664c506e667473746f726167652e6c696e6b2f697066732f6261666b726569673463636d757a7173716d346f796262333337666e627566766b32786975616f7a36776c64726d746833656763736570637278343b6261666b726569673463636d757a7173716d346f796262333337666e627566766b32786975616f7a36776c64726d74683365676373657063727834bb0f0000000000001976a914d948abf743a76f472703b6a91437854dc1d7ced588ac00000000";
let tx: Transaction = deserialize(&hex::decode(&tx_hex).unwrap()).unwrap();
let result = parse_bcmr(&tx);
// The hash value in this BCMR is 64 bytes. Not a valid sha256 hash.
assert!(result.is_none());
}
#[test]
fn test_is_genesis_tx() {
let tx_hex = "01000000032b70d6279de034fea76d4dd329c44a3e162b17a0cebc32eeeebbd14f5e8a97250100000064411b42a9dcb22c59ff0e800b3c63d6b8ac045bd18eb1effc8d2f23ec49ed0abdc316215a469a206d45153eac999d14eb8abc3d33b91c5ac4b61e7868d162d5d5184121021735db91ac477c1007da74c8c117c383eebc664d21f12871ca6e69401f8072f8feffffff2b70d6279de034fea76d4dd329c44a3e162b17a0cebc32eeeebbd14f5e8a972502000000644193e4d5bb533279fbcbebe15ba62f226e53e5c64a1624c755b255b13f0b1011e382182896026575370b5e30d03390d31faabf854a9f41c215b8a5bb57bae221df4121021735db91ac477c1007da74c8c117c383eebc664d21f12871ca6e69401f8072f8feffffff8d80b5567b6a3cac6e19a6934fd7544a02616fd6868a304c93bff6aed3eb12280100000064418b576b5defc8942182d01654cc2a0706b3e86e9031f7a13a694ffd830944ca00ecf5ad3271a242993580cbe78c13a41e35b06dfd68e72c4242dd7ff9e7e039a6412103b666cf8bc6ef2e3c3f100fc883013040f01f7085d75e5ab0811ae00b6a0a6d24feffffff03200300000000000040ef8d80b5567b6a3cac6e19a6934fd7544a02616fd6868a304c93bff6aed3eb1228600401db010076a914bfe9ed4ea9c7830c2ee96c17a3bd5ff563c12f2b88ac200300000000000040ef8d80b5567b6a3cac6e19a6934fd7544a02616fd6868a304c93bff6aed3eb1228600402db010076a914bfe9ed4ea9c7830c2ee96c17a3bd5ff563c12f2b88ac9f117901000000001976a91402a67e8d884367e7a22d6cda96a6df21e56a2f3288ac851b0c00";
let not_genesis_tx: Transaction = deserialize(&hex::decode(&tx_hex).unwrap()).unwrap();
let tx_hex = "0200000001925bd92e424c0f0bc290a794f491abf19e61b6dcdec7e70747fcb54682fc9bb700000000644181707613f45069c8df981b2ef0cbd05158d5623602fac3ae2e13f69d05fa356c278f2f3a4e34f33a79e0f8375abd35051150ec024b3625807cea185528a5de9e412103b4680dffa1e34b3bfdb024fd0a8498eb24f59ba5ead34acfb74f9d8549db6f3a0000000003e8030000000000003eef925bd92e424c0f0bc290a794f491abf19e61b6dcdec7e70747fcb54682fc9bb710fdf40176a914bd4c90f2c64743fc0d3ea6a14973a5d628260b7388ac0000000000000000456a0442434d5220c705cc90a56ac7ef9a15ef90ebbc8ba7e60e4c622e5464d52d8baf7887949fcc1d736f636b2e6361756c64726f6e2e71756573742f62636d722e6a736f6ed9210000000000001976a914bd4c90f2c64743fc0d3ea6a14973a5d628260b7388ac00000000";
let genesis_tx: Transaction = deserialize(&hex::decode(&tx_hex).unwrap()).unwrap();
assert!(!is_genesis_tx(&not_genesis_tx).is_some());
assert!(is_genesis_tx(&genesis_tx).is_some());
}
}

104
src/bcmr/parsedbcmr.rs Normal file
View file

@ -0,0 +1,104 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
// The ParsedBCMR struct mirrors identity struct in the BCMR definition
use serde::Serialize;
const MAX_SYMBOL_LEN: usize = 100;
const MAX_NAME_LEN: usize = 500;
const MAX_DESCRIPTION_LEN: usize = 10000;
const MAX_URL_LEN: usize = 2000;
#[derive(Serialize)]
pub struct Token {
pub category: String,
pub symbol: String,
pub decimals: usize,
}
#[derive(Serialize)]
pub struct Uris {
pub icon: Option<String>,
pub web: Option<String>,
}
#[derive(Serialize)]
pub struct FileMeta {
pub expected_hash: String,
pub actual_hash: String,
}
#[derive(Serialize)]
pub struct ParsedBCMR {
pub name: String,
pub description: String,
pub token: Token,
pub uris: Uris,
pub filemeta: FileMeta,
}
fn trim_str(str: &str, new_size: usize) -> String {
let mut iter = str.char_indices();
let (start, _) = match iter.next() {
Some(i) => i,
None => {
// not sure why this should fail (empty string?)
return str.to_owned();
}
};
let (end, _) = match iter.nth(new_size) {
Some(i) => i,
None => {
return str.to_owned();
}
};
str[start..end].to_owned() + "..."
}
impl ParsedBCMR {
#[allow(clippy::too_many_arguments)]
pub fn new(
category: &str,
symbol: &str,
decimals: usize,
name: &str,
description: &str,
icon: &str,
web: &str,
expected_hash: String,
actual_hash: String,
) -> Self {
// Trim inputs so avoid bogus BCMR files populating our db with garbage
Self {
name: trim_str(name, MAX_NAME_LEN),
description: trim_str(description, MAX_DESCRIPTION_LEN),
token: Token {
category: category.to_owned(),
symbol: trim_str(symbol, MAX_SYMBOL_LEN),
decimals,
},
uris: Uris {
icon: if icon.is_empty() {
None
} else {
Some(trim_str(icon, MAX_URL_LEN))
},
web: if web.is_empty() {
None
} else {
Some(trim_str(web, MAX_URL_LEN))
},
},
filemeta: FileMeta {
expected_hash,
actual_hash,
},
}
}
}

View file

@ -16,7 +16,7 @@ use serde_json::Value;
use std::{collections::HashMap, sync::RwLock};
use crate::{
db::{config::config_set, delete_entries_for_block, DBPool},
db::{self, cauldron::config::config_set, DB},
KEY_LAST_INDEXED,
};
@ -43,22 +43,29 @@ pub trait BlockUndoer {
}
pub struct StoreBlockUndoer {
db: DBPool,
db: DB,
}
impl StoreBlockUndoer {
pub fn new(db: DBPool) -> Result<Self> {
pub fn new(db: DB) -> Result<Self> {
Ok(Self { db })
}
}
impl BlockUndoer for StoreBlockUndoer {
fn undo_block(&self, blockheader: &BlockHeader) -> Result<()> {
let conn = self.db.get().unwrap();
if delete_entries_for_block(&conn, &blockheader.block_hash())? {
let cauldron_conn = self.db.cauldron_w.get().unwrap();
let mut was_indexed =
db::cauldron::delete_entries_for_block(&cauldron_conn, &blockheader.block_hash())?;
let bcmr_conn = self.db.bcmr_w.get().unwrap();
if db::bcmr::delete_entries_for_block(&bcmr_conn, &blockheader.block_hash())? {
was_indexed = true;
}
if was_indexed {
// This block was indexed. Need to update indexing flag.
config_set(
&conn,
&cauldron_conn,
KEY_LAST_INDEXED,
&blockheader.prev_blockhash.to_hex(),
);

337
src/db/bcmr/mod.rs Normal file
View file

@ -0,0 +1,337 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use crate::bcmr::parsedbcmr::{FileMeta, ParsedBCMR, Token, Uris};
use anyhow::*;
use bitcoin_hashes::hex::{FromHex, ToHex};
use bitcoincash::{BlockHash, TokenID, Txid};
use log::info;
use riftenlabs_defi::chainutil::OutPointHash;
use rusqlite::{params, Connection};
// Flag BCMR download as give up after this many attempts.
const MAX_DOWNLOAD_ATTEMPTS: usize = 100;
// BCMR auth chain entry, matches auth_chain_entry table
pub struct AuthChainEntry {
pub utxo: OutPointHash,
pub txid: Txid,
pub token_id: TokenID,
pub height: usize,
pub bcmr_data: Option<Vec<u8>>,
}
pub fn prepare_tables(conn: &Connection) {
conn.execute(
"CREATE TABLE auth_chain_entry (
utxo TEXT PRIMARY KEY,
blockhash TEXT NOT NULL,
txid TEXT NOT NULL,
token_id TEXT NOT NULL,
height INT NOT NULL,
bcmr_data TEXT
)",
[],
)
.expect("failed to create auth_chain_entry table");
conn.execute(
"CREATE TABLE bcmr_data (
utxo TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
decimals INT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
web TEXT NOT NULL,
expected_hash TEXT NOT NULL,
actual_hash TEXT NOT NULL,
FOREIGN KEY (utxo) REFERENCES auth_chain_entry(utxo)
)",
[],
)
.expect("failed to create bcmr_data table");
conn.execute(
"CREATE TABLE bcmr_failure (
utxo TEXT PRIMARY KEY,
last_attempt INT NOT NULL,
attempts INT NOT NULL,
error_message TEXT,
give_up BOOLEAN,
FOREIGN KEY (utxo) REFERENCES auth_chain_entry(utxo)
)",
[],
)
.expect("failed to create bcmr_failure table");
}
pub fn delete_entries_for_block(tx: &Connection, blockhash: &BlockHash) -> Result<bool> {
let mut stmt = tx.prepare("DELETE FROM auth_chain_entry WHERE blockhash = ?")?;
let rows_deleted = stmt.execute(params![blockhash.to_hex()])?;
Ok(rows_deleted != 0)
}
/// Add or update config entry
pub fn insert_authheader(
conn: &Connection,
utxo: &OutPointHash,
blockhash: &BlockHash,
txid: &Txid,
token_id: &TokenID,
height: usize,
bcmr_data: Option<Vec<u8>>,
) -> Result<()> {
let mut stmt = conn
.prepare("INSERT OR REPLACE INTO auth_chain_entry (utxo, blockhash, txid, token_id, height, bcmr_data) VALUES (?, ?, ?, ?, ?, ?)")?;
stmt.execute(params![
utxo.to_hex(),
blockhash.to_hex(),
txid.to_hex(),
token_id.to_hex(),
height,
bcmr_data.map(|bcmr| bcmr.to_hex())
])?;
Ok(())
}
/// Get config entry
#[allow(dead_code)]
pub fn get_authheader(conn: &Connection, utxo: &OutPointHash) -> Result<Option<AuthChainEntry>> {
let mut stmt = conn
.prepare("SELECT token_id, txid, height, bcmr_data FROM auth_chain_entry WHERE utxo = ?")?;
let mut row = stmt.query([utxo.to_hex()])?;
let auth_header = row.next()?;
if let Some(header) = auth_header {
let token_hex: String = header.get(0)?;
let txid_hex: String = header.get(1)?;
let height = header.get(2)?;
let bcmr_data_hex: Option<String> = header.get(3)?;
let bcmr_data = if let Some(data) = bcmr_data_hex {
Some(hex::decode(data).context("failed to decode bcmr data")?)
} else {
None
};
Ok(Some(AuthChainEntry {
utxo: *utxo,
token_id: TokenID::from_hex(&token_hex).context("failed to decode token hex")?,
txid: Txid::from_hex(&txid_hex).context("failed to decode txid")?,
height,
bcmr_data,
}))
} else {
Ok(None)
}
}
pub fn get_matching_autheaders<'a>(
conn: &Connection,
keys: impl Iterator<Item = &'a OutPointHash>,
) -> Result<Vec<AuthChainEntry>> {
// To not exceed SQL query length; we chunk the queries into entries of this size.
let chunk_size = 10000;
let keys: Vec<String> = keys.map(|utxo| utxo.to_hex()).collect();
let mut matches: Vec<AuthChainEntry> = Vec::new();
// Process in chunks
for chunk in keys.chunks(chunk_size) {
let in_clause = chunk.join("','");
let sql = format!(
"SELECT token_id, txid, height, bcmr_data, utxo FROM auth_chain_entry WHERE utxo IN ('{}')",
in_clause
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(header) = rows.next()? {
let token_hex: String = header.get(0)?;
let txid_hex: String = header.get(1)?;
let height = header.get(2)?;
let bcmr_data_hex: Option<String> = header.get(3)?;
let bcmr_data = if let Some(data) = bcmr_data_hex {
Some(hex::decode(&data).context("failed to decode bcmr data")?)
} else {
None
};
let utxo_hex: String = header.get(4)?;
let utxo = OutPointHash::from_hex(&utxo_hex)?;
matches.push(AuthChainEntry {
utxo,
token_id: TokenID::from_hex(&token_hex).context("failed to decode token hex")?,
txid: Txid::from_hex(&txid_hex).context("failed to decode txid")?,
height,
bcmr_data,
})
}
}
Ok(matches)
}
pub fn get_entries_missing_bcmr_download(conn: &Connection) -> Result<Vec<AuthChainEntry>> {
let sql = "WITH MaxHeight AS (
-- CTE to find the highest height for each token_id where bcmr_data is not null initially
SELECT
token_id,
MAX(height) AS max_height
FROM
auth_chain_entry
WHERE
bcmr_data IS NOT NULL
GROUP BY
token_id
)
SELECT
ace.token_id, ace.txid, ace.height, ace.bcmr_data, ace.utxo
FROM
auth_chain_entry AS ace
-- Join with MaxHeight to get the maximum height entries
JOIN MaxHeight mh ON ace.token_id = mh.token_id AND ace.height = mh.max_height
-- Exclude entries that have a corresponding entry in bcmr_data
LEFT JOIN bcmr_data bd ON ace.utxo = bd.utxo
-- Exclude entries that have a corresponding failure entry where give_up = true or last attempt was less than 1800 seconds ago
LEFT JOIN bcmr_failure bf ON ace.utxo = bf.utxo AND (bf.give_up = 1 OR (strftime('%s', 'now') - bf.last_attempt) < 1800)
WHERE
-- Ensure no matching entries in bcmr_data and only include bcmr_failure when filtered conditions aren't met
bd.utxo IS NULL AND
bf.utxo IS NULL;
";
let mut stmt = conn.prepare(sql)?;
let mut rows = stmt.query([])?;
let mut matches: Vec<AuthChainEntry> = Vec::new();
while let Some(header) = rows.next()? {
let token_hex: String = header.get(0)?;
let txid_hex: String = header.get(1)?;
let height = header.get(2)?;
let bcmr_data_hex: Option<String> = header.get(3)?;
let bcmr_data = if let Some(data) = bcmr_data_hex {
Some(hex::decode(&data).context("failed to decode bcmr data")?)
} else {
None
};
let utxo_hex: String = header.get(4)?;
let utxo = OutPointHash::from_hex(&utxo_hex)?;
matches.push(AuthChainEntry {
utxo,
token_id: TokenID::from_hex(&token_hex).context("failed to decode token hex")?,
txid: Txid::from_hex(&txid_hex).context("failed to decode txid")?,
height,
bcmr_data,
})
}
Ok(matches)
}
pub fn insert_bcmr_data(
conn: &rusqlite::Connection,
utxo: &OutPointHash,
bcmr: &ParsedBCMR,
) -> Result<()> {
let sql = "INSERT OR REPLACE INTO bcmr_data (utxo, symbol, decimals, name, description, icon, web, expected_hash, actual_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
info!("Trying to insert {}", utxo.to_hex());
let empty_string: String = "".to_owned();
conn.execute(
sql,
rusqlite::params![
&utxo.to_hex(),
&bcmr.token.symbol,
bcmr.token.decimals,
&bcmr.name,
&bcmr.description,
&bcmr.uris.icon.as_ref().unwrap_or(&empty_string),
&bcmr.uris.web.as_ref().unwrap_or(&empty_string),
&bcmr.filemeta.expected_hash,
&bcmr.filemeta.actual_hash
],
)?;
Ok(())
}
pub fn update_bcmr_failure(
conn: &Connection,
utxo: &OutPointHash,
error_message: &str,
give_up: bool,
) -> Result<()> {
let sql = format!(
"
INSERT INTO bcmr_failure (utxo, last_attempt, attempts, error_message, give_up)
VALUES (?1, strftime('%s', 'now'), 1, ?2, ?3)
ON CONFLICT(utxo) DO UPDATE SET
last_attempt = excluded.last_attempt,
attempts = bcmr_failure.attempts + 1,
error_message = excluded.error_message,
give_up = CASE
WHEN bcmr_failure.attempts + 1 > {} THEN true
ELSE excluded.give_up
END",
MAX_DOWNLOAD_ATTEMPTS
);
conn.execute(&sql, params![&utxo.to_hex(), error_message, give_up])?;
Ok(())
}
pub fn get_token_bcmr(conn: &Connection, token_hex: &str) -> Result<Option<ParsedBCMR>> {
let sql = "SELECT
b.symbol, b.decimals, b.name, b.description, b.icon, b.web, b.actual_hash, b.expected_hash
FROM auth_chain_entry a
JOIN bcmr_data b ON a.utxo = b.utxo
WHERE a.token_id = ?
AND a.bcmr_data IS NOT NULL
ORDER BY a.height DESC
LIMIT 1";
let mut stmt = conn.prepare(sql)?;
let mut row = stmt.query([token_hex])?;
if let Some(r) = row.next()? {
let symbol: String = r.get(0)?;
let decimals: usize = r.get(1)?;
let name = r.get(2)?;
let description = r.get(3)?;
let icon: String = r.get(4)?;
let web: String = r.get(5)?;
let actual_hash: String = r.get(6)?;
let expected_hash: String = r.get(7)?;
Ok(Some(ParsedBCMR {
name,
description,
token: Token {
category: token_hex.to_owned(),
symbol,
decimals,
},
uris: Uris {
web: if web.is_empty() { None } else { Some(web) },
icon: if icon.is_empty() { None } else { Some(icon) },
},
filemeta: FileMeta {
expected_hash,
actual_hash,
},
}))
} else {
Ok(None)
}
}

View file

@ -19,5 +19,9 @@ pub fn config_get(conn: &Connection, key: &str) -> Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?")?;
let mut row = stmt.query([key])?;
Ok(row.next()?.map(|r| r.get(0).unwrap()))
if let Some(r) = row.next()? {
Ok(r.get(0)?)
} else {
Ok(None)
}
}

76
src/db/cauldron/mod.rs Normal file
View file

@ -0,0 +1,76 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::Result;
use bitcoin_hashes::hex::ToHex;
use bitcoincash::BlockHash;
use rusqlite::{params, Connection};
pub mod config;
pub mod header;
pub mod mempool;
pub mod pool;
pub mod tx;
pub mod utxo_funding;
pub mod utxo_spending;
pub fn prepare_tables(conn: &Connection) {
tx::create_table(conn);
utxo_funding::create_table(conn);
utxo_spending::create_table(conn);
conn.execute(
"CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT
)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE headers (
key TEXT PRIMARY KEY,
height INT,
header BLOB
)",
[],
)
.unwrap();
// Keep track of what was the initial deployment of a Cauldron
pool::create_table(conn);
// Indexes for list_by_volume
conn.execute(
"CREATE INDEX idx_utxo_funding_join ON utxo_funding(new_utxo_hash, sats, token_id);",
[],
)
.unwrap();
conn.execute("CREATE INDEX idx_utxo_funding_tvl_highest ON utxo_funding(token_id, new_utxo_hash, sats, token_amount);", []).unwrap();
}
pub fn delete_entries_for_block(tx: &Connection, blockhash: &BlockHash) -> Result<bool> {
let mut stmt = tx.prepare(
"DELETE FROM utxo_funding
WHERE txid IN (
SELECT txid FROM tx WHERE blockhash = ?
)",
)?;
let mut rows_deleted = stmt.execute(params![blockhash.to_hex()])?;
let mut stmt = tx.prepare(
"DELETE FROM utxo_spending
WHERE txid IN (
SELECT txid FROM utxo_funding WHERE txid IN (
SELECT txid FROM tx WHERE blockhash = ?
)
)",
)?;
rows_deleted += stmt.execute(params![blockhash.to_hex()])?;
// remaining tables should clear themselves due to foregn keys usage
Ok(rows_deleted != 0)
}

View file

@ -5,76 +5,19 @@
use std::sync::Arc;
use anyhow::Result;
use bitcoin_hashes::hex::ToHex;
use bitcoincash::BlockHash;
use rusqlite::{params, Connection};
pub mod config;
pub mod header;
pub mod mempool;
pub mod pool;
pub mod tx;
pub mod utxo_funding;
pub mod utxo_spending;
pub mod bcmr;
pub mod cauldron;
pub type DBPool = Arc<r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>>;
pub fn prepare_tables(conn: &Connection) {
tx::create_table(conn);
utxo_funding::create_table(conn);
utxo_spending::create_table(conn);
conn.execute(
"CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT
)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE headers (
key TEXT PRIMARY KEY,
height INT,
header BLOB
)",
[],
)
.unwrap();
// Keep track of what was the initial deployment of a Cauldron
pool::create_table(conn);
// Indexes for list_by_volume
conn.execute(
"CREATE INDEX idx_utxo_funding_join ON utxo_funding(new_utxo_hash, sats, token_id);",
[],
)
.unwrap();
conn.execute("CREATE INDEX idx_utxo_funding_tvl_highest ON utxo_funding(token_id, new_utxo_hash, sats, token_amount);", []).unwrap();
}
pub fn delete_entries_for_block(tx: &Connection, blockhash: &BlockHash) -> Result<bool> {
let mut stmt = tx.prepare(
"DELETE FROM utxo_funding
WHERE txid IN (
SELECT txid FROM tx WHERE blockhash = ?
)",
)?;
let mut rows_deleted = stmt.execute(params![blockhash.to_hex()])?;
let mut stmt = tx.prepare(
"DELETE FROM utxo_spending
WHERE txid IN (
SELECT txid FROM utxo_funding WHERE txid IN (
SELECT txid FROM tx WHERE blockhash = ?
)
)",
)?;
rows_deleted += stmt.execute(params![blockhash.to_hex()])?;
// remaining tables should clear themselves due to foregn keys usage
Ok(rows_deleted != 0)
#[derive(Clone)]
pub struct DB {
// readwrite
pub cauldron_w: DBPool,
// readonly
pub cauldron_r: DBPool,
// readonly
pub bcmr_r: DBPool,
// readwrite
pub bcmr_w: DBPool,
}

262
src/index.rs Normal file
View file

@ -0,0 +1,262 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use std::{
collections::HashSet,
sync::{mpsc::sync_channel, Arc, Mutex},
};
use bitcoin_hashes::hex::{FromHex, ToHex};
use bitcoincash::{consensus::deserialize, Block, BlockHash, Transaction, Txid};
use electrum_client::{Client, Param};
use log::{debug, info, warn};
use rayon::prelude::*;
use riftenlabs_defi::cauldron::{parse_cauldron, ParsedContract};
use crate::{
bcmr::index_bcmr,
chain::{get_new_headers, Chain, StoreBlockUndoer},
db::{
self,
cauldron::{
config::{config_get, config_set},
header::{db_get_header, store_headers},
tx::insert_block_tx,
utxo_funding::insert_utxo_funding,
utxo_spending::insert_utxo_spending,
},
DBPool, DB,
},
electrum::{electrum_fetch_mempool, electrum_get_tip, electrum_get_tx},
CASHTOKEN_ACTIVATION_HEIGHT, KEY_LAST_INDEXED,
};
use anyhow::{Context, Result};
use electrum_client::ElectrumApi;
fn parse_cauldrons(tx: &Transaction) -> Vec<ParsedContract> {
tx.input
.par_iter()
.enumerate()
.filter_map(move |(i, _)| parse_cauldron(i, tx))
.collect()
}
pub fn update_mempool(db: DBPool, electrum: Arc<Mutex<Client>>) -> Result<()> {
let our_mempool_txs: HashSet<Txid> = db::cauldron::mempool::load_mempool(&db.get().unwrap())?;
let node_mempool: HashSet<Txid> = electrum_fetch_mempool(&electrum.lock().unwrap())?;
let txs_to_delete = our_mempool_txs.difference(&node_mempool);
let txs_to_add = node_mempool.difference(&our_mempool_txs);
let txs_to_add: Vec<Transaction> = txs_to_add
.into_iter()
.filter_map(
|txid| match electrum_get_tx(&electrum.lock().unwrap(), txid) {
Ok(tx) => Some(tx),
Err(e) => {
info!("Failed to get mempool tx {}: {}", txid, e);
None
}
},
)
.collect();
let mut db_conn = db.get().unwrap();
let db_tx = db_conn.transaction()?;
let mut all_cauldrons = vec![];
for txid in txs_to_delete {
debug!("mempool remove {}", txid.to_hex());
db::cauldron::mempool::delete_mempool_tx(&db_tx, txid)?;
}
for tx in txs_to_add {
let txid = tx.txid();
debug!("mempool add {}", txid.to_hex());
db::cauldron::tx::insert_mempool_tx(&db_tx, &txid)?;
let cauldrons: Vec<ParsedContract> = tx
.input
.iter()
.enumerate()
.filter_map(|(i, _)| parse_cauldron(i, &tx))
.collect();
insert_utxo_funding(&db_tx, &cauldrons, &txid, false)?;
insert_utxo_spending(&db_tx, &cauldrons, &txid, false)?;
all_cauldrons.extend(cauldrons);
}
db::cauldron::pool::update_pool_history(&db_tx, all_cauldrons)
.context("update pool history")?;
Ok(db_tx.commit()?)
}
pub fn index_blocks(
chain: Arc<Mutex<Chain>>,
db: DB,
client: Arc<Mutex<Client>>,
bcmr_enabled: bool,
) -> Result<BlockHash> {
let (tip_header, _) = electrum_get_tip(&client.lock().unwrap())?;
let (block_send, block_recv) = sync_channel::<Option<(u64, u64, Block)>>(10);
// Update header chain (and undo any blocks that may have reorged away)
{
let chain = chain.lock().unwrap();
if tip_header.block_hash() != chain.tip_hash() {
debug!(
"Updating header chain from {} to {}",
chain.tip_hash().to_hex(),
tip_header.block_hash().to_hex()
);
let new_headers =
get_new_headers(&client.lock().unwrap(), &chain, &tip_header.block_hash())?;
debug!("Storing headers");
{
let mut db_conn = db.cauldron_w.get().unwrap();
for chunk in new_headers.chunks(100000) {
let db_tx = db_conn.transaction()?;
store_headers(&db_tx, chunk)?;
db_tx.commit()?;
}
}
let undoer = StoreBlockUndoer::new(db.clone())?;
chain.update(undoer, new_headers, None)?;
debug!("Header update done");
}
}
let db_cpy = db.clone();
std::thread::spawn(move || {
let db = db_cpy;
let last_indexed = config_get(&db.cauldron_r.get().unwrap(), KEY_LAST_INDEXED).unwrap();
let last_indexed = last_indexed.unwrap_or(CASHTOKEN_ACTIVATION_HEIGHT.to_string());
let mut last_indexed = BlockHash::from_hex(&last_indexed).unwrap();
// Check if last indexed has been orphaned
loop {
if chain.lock().unwrap().contains(&last_indexed) {
break;
}
info!(
"Last indexed block ({}) has been orphaned",
last_indexed.to_hex()
);
let header = db_get_header(&db.cauldron_r.get().unwrap(), &last_indexed)
.context("Failed to get header for last indexed")
.unwrap();
last_indexed = header.prev_blockhash;
info!(
"Last indexed block rolled back to {}",
last_indexed.to_hex()
);
}
loop {
if tip_header.block_hash() == last_indexed {
if let Err(e) = block_send.send(None) {
warn!("Failed to end EOL to block reader: {}", e);
}
break;
}
let next_height = chain
.lock()
.unwrap()
.get_block_height(&last_indexed)
.expect("last_indexed height not found in main chain")
+ 1;
let res = client
.lock()
.unwrap()
.raw_call("blockchain.block.get", vec![Param::U32(next_height as u32)])
.unwrap();
let block_hex: String = serde_json::from_str(&res.to_string()).unwrap();
let block: Block = deserialize(&hex::decode(&block_hex).unwrap()).unwrap();
let block_hash = block.block_hash();
if let Err(e) = block_send.send(Some((
next_height,
chain.lock().unwrap().get_mtp(next_height).unwrap(),
block,
))) {
warn!("Failed to send block to reader: {}", e);
break;
}
last_indexed = block_hash;
}
});
loop {
let (block_height, mtp, block) = match block_recv.recv()? {
Some(res) => res,
None => break,
};
let mut db_conn = db.cauldron_w.get().unwrap();
let db_tx = db_conn.transaction()?;
let blockhash = block.block_hash();
let mut total_cauldrons = 0;
let mut all_cauldrons = vec![];
for tx in &block.txdata {
let cauldrons = parse_cauldrons(tx);
if cauldrons.is_empty() {
continue;
}
let txid = tx.txid();
insert_block_tx(&db_tx, &txid, &blockhash, mtp as i64).context("inserting block tx")?;
insert_utxo_funding(&db_tx, &cauldrons, &txid, true)
.context("inserting funding utxos")?;
insert_utxo_spending(&db_tx, &cauldrons, &txid, true)
.context("inserting spending utxos")?;
total_cauldrons += cauldrons.len();
all_cauldrons.extend(cauldrons);
}
// Figuring out initial utxo needs to be done on all cauldrons in a block.
db::cauldron::pool::update_pool_history(&db_tx, all_cauldrons)
.context("update pool history")?;
config_set(&db_tx, KEY_LAST_INDEXED, &blockhash.to_hex());
let autheader_updates = if bcmr_enabled {
let mut conn = db.bcmr_w.get().context("failed to get bcmr db")?;
let bcmr_db_tx = conn.transaction()?;
let updates = index_bcmr(&bcmr_db_tx, &blockhash, block.txdata)?;
bcmr_db_tx.commit()?;
updates as i64
} else {
-1
};
// cauldron commit needs to come at last; as it tracks what the last successful block indexed was
db_tx.commit()?;
info!(
"Indexed {}; mtp: {}, height {}, {} trades, {} autheader updates.",
blockhash.to_hex(),
mtp,
block_height,
total_cauldrons,
autheader_updates,
);
}
Ok(tip_header.block_hash())
}

View file

@ -3,348 +3,51 @@
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::{Context, Result};
use bitcoin_hashes::hex::{FromHex, ToHex};
use bitcoincash::{consensus::deserialize, Block, BlockHash, Transaction, Txid};
use chain::{get_new_headers, Chain, StoreBlockUndoer};
use db::{config::config_get, DBPool};
use electrum::{electrum_fetch_mempool, electrum_get_tip};
use anyhow::Result;
use bitcoincash::{consensus::deserialize, Block, BlockHash};
use db::{DBPool, DB};
use electrum::electrum_get_tip;
use electrum_client::{Client, ElectrumApi, Param};
use log::{debug, error, info};
use rayon::prelude::*;
use riftenlabs_defi::cauldron::{parse_cauldron, ParsedContract};
use rocket::{
get,
http::Status,
launch,
response::status::Custom,
routes,
serde::json::{json, Json},
State,
};
use log::{error, info, warn};
use rocket::{launch, routes};
use rocket_cors::{AllowedHeaders, AllowedOrigins};
use serde_json::Value;
use rusqlite::OpenFlags;
use std::{
backtrace::Backtrace,
collections::HashSet,
panic,
path::Path,
process,
sync::{mpsc::sync_channel, Arc, Mutex},
sync::{Arc, Mutex},
thread,
time::Duration,
};
use stderrlog::LogLevelNum;
use timeutil::time_now;
use crate::{
db::{
config::config_set,
header::{db_get_header, load_all_headers, store_headers},
prepare_tables,
tx::{self, insert_block_tx},
utxo_funding::insert_utxo_funding,
utxo_spending::insert_utxo_spending,
},
electrum::electrum_get_tx,
};
use crate::bcmr::bcmrdownloader::BCMRDownloader;
use crate::db::cauldron::header::load_all_headers;
use crate::index::{index_blocks, update_mempool};
// The block where first cauldron contract was deployed. (Block 799870)
#[allow(dead_code)]
const RIFTEN_LABS_GENESIS_BLOCK: &str =
"000000000000000000ed24c811077f7268a21ecf25cb437655aaba33d8ff4997";
// Start parsing for BCMR data from this height
const CASHTOKEN_ACTIVATION_HEIGHT: &str =
"000000000000000002b678c471841c3e404ec7ae9ca9c32026fe27eb6e3a1ed1";
// Last indexed block height.
const KEY_LAST_INDEXED: &str = "last_indexed";
mod bcmr;
mod cashaddr;
mod chain;
mod db;
mod electrum;
mod index;
mod rpc;
mod timeutil;
fn parse_cauldrons(tx: &Transaction) -> Vec<ParsedContract> {
tx.input
.par_iter()
.enumerate()
.filter_map(move |(i, _)| parse_cauldron(i, tx))
.collect()
}
fn update_mempool(db: DBPool, electrum: Arc<Mutex<Client>>) -> Result<()> {
let our_mempool_txs: HashSet<Txid> = db::mempool::load_mempool(&db.get().unwrap())?;
let node_mempool: HashSet<Txid> = electrum_fetch_mempool(&electrum.lock().unwrap())?;
let txs_to_delete = our_mempool_txs.difference(&node_mempool);
let txs_to_add = node_mempool.difference(&our_mempool_txs);
let txs_to_add: Vec<Transaction> = txs_to_add
.into_iter()
.filter_map(
|txid| match electrum_get_tx(&electrum.lock().unwrap(), txid) {
Ok(tx) => Some(tx),
Err(e) => {
info!("Failed to get mempool tx {}: {}", txid, e);
None
}
},
)
.collect();
let mut db_conn = db.get().unwrap();
let db_tx = db_conn.transaction()?;
let mut all_cauldrons = vec![];
for txid in txs_to_delete {
debug!("mempool remove {}", txid.to_hex());
db::mempool::delete_mempool_tx(&db_tx, txid)?;
}
for tx in txs_to_add {
let txid = tx.txid();
debug!("mempool add {}", txid.to_hex());
tx::insert_mempool_tx(&db_tx, &txid)?;
let cauldrons: Vec<ParsedContract> = tx
.input
.iter()
.enumerate()
.filter_map(|(i, _)| parse_cauldron(i, &tx))
.collect();
insert_utxo_funding(&db_tx, &cauldrons, &txid, false)?;
insert_utxo_spending(&db_tx, &cauldrons, &txid, false)?;
all_cauldrons.extend(cauldrons);
}
db::pool::update_pool_history(&db_tx, all_cauldrons).context("update pool history")?;
Ok(db_tx.commit()?)
}
fn index_blocks(
chain: Arc<Mutex<Chain>>,
pool: DBPool,
client: Arc<Mutex<Client>>,
) -> Result<BlockHash> {
let (tip_header, _) = electrum_get_tip(&client.lock().unwrap())?;
let (block_send, block_recv) = sync_channel::<Option<(u64, u64, Block)>>(10);
// Update header chain (and undo any blocks that may have reorged away)
{
let chain = chain.lock().unwrap();
if tip_header.block_hash() != chain.tip_hash() {
debug!(
"Updating header chain from {} to {}",
chain.tip_hash().to_hex(),
tip_header.block_hash().to_hex()
);
let new_headers =
get_new_headers(&client.lock().unwrap(), &chain, &tip_header.block_hash())?;
debug!("Storing headers");
{
let mut db_conn = pool.get().unwrap();
for chunk in new_headers.chunks(100000) {
let db_tx = db_conn.transaction()?;
store_headers(&db_tx, chunk)?;
db_tx.commit()?;
}
}
let undoer = StoreBlockUndoer::new(pool.clone())?;
chain.update(undoer, new_headers, None)?;
debug!("Header update done");
}
}
let pool_cpy = pool.clone();
thread::spawn(move || {
let pool = pool_cpy;
let last_indexed = config_get(&pool.get().unwrap(), KEY_LAST_INDEXED).unwrap();
let last_indexed = last_indexed.unwrap_or(RIFTEN_LABS_GENESIS_BLOCK.to_string());
let mut last_indexed = BlockHash::from_hex(&last_indexed).unwrap();
// Check if last indexed has been orphaned
loop {
if chain.lock().unwrap().contains(&last_indexed) {
break;
}
info!(
"Last indexed block ({}) has been orphaned",
last_indexed.to_hex()
);
let header = db_get_header(&pool.get().unwrap(), &last_indexed)
.context("Failed to get header for last indexed")
.unwrap();
last_indexed = header.prev_blockhash;
info!(
"Last indexed block rolled back to {}",
last_indexed.to_hex()
);
}
loop {
if tip_header.block_hash() == last_indexed {
block_send.send(None).unwrap();
break;
}
let next_height = chain
.lock()
.unwrap()
.get_block_height(&last_indexed)
.expect("last_indexed height not found in main chain")
+ 1;
let res = client
.lock()
.unwrap()
.raw_call("blockchain.block.get", vec![Param::U32(next_height as u32)])
.unwrap();
let block_hex: String = serde_json::from_str(&res.to_string()).unwrap();
let block: Block = deserialize(&hex::decode(&block_hex).unwrap()).unwrap();
let block_hash = block.block_hash();
block_send
.send(Some((
next_height,
chain.lock().unwrap().get_mtp(next_height).unwrap(),
block,
)))
.unwrap();
last_indexed = block_hash;
}
});
loop {
let (block_height, mtp, block) = match block_recv.recv()? {
Some(res) => res,
None => break,
};
let mut db_conn = pool.get().unwrap();
let db_tx = db_conn.transaction()?;
let blockhash = block.block_hash();
let mut total_cauldrons = 0;
let mut all_cauldrons = vec![];
for tx in block.txdata {
let cauldrons = parse_cauldrons(&tx);
if cauldrons.is_empty() {
continue;
}
let txid = tx.txid();
insert_block_tx(&db_tx, &txid, &blockhash, mtp as i64).context("inserting block tx")?;
insert_utxo_funding(&db_tx, &cauldrons, &txid, true)
.context("inserting funding utxos")?;
insert_utxo_spending(&db_tx, &cauldrons, &txid, true)
.context("inserting spending utxos")?;
total_cauldrons += cauldrons.len();
all_cauldrons.extend(cauldrons);
}
// Figuring out initial utxo needs to be done on all cauldrons in a block.
db::pool::update_pool_history(&db_tx, all_cauldrons).context("update pool history")?;
config_set(&db_tx, KEY_LAST_INDEXED, &blockhash.to_hex());
db_tx.commit()?;
println!(
"Indexed {}; mtp: {}, height {}, {} trades.",
block.header.block_hash().to_hex(),
mtp,
block_height,
total_cauldrons
);
}
Ok(tip_header.block_hash())
}
#[get("/tokens/list_by_volume?<duration>&<limit>")]
fn list_by_volume(
duration: Option<usize>,
limit: Option<usize>,
conn: &State<DBPool>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
let thirty_days = 24 * 60 * 60 * 30;
let duration = duration.unwrap_or(thirty_days);
let limit = limit.unwrap_or(50);
let limit = std::cmp::max(limit, 1000);
let list: Vec<(String, u64, u64, u64, u64, u64, u64)> =
rpc::list_tokens_by_volume(&conn.get().unwrap(), duration, limit)
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let result: Vec<Value> = list
.into_par_iter()
.map(
|(
token_id,
trade_volume,
trade_count,
tvl_sats,
tvl_token,
best_contract_sats,
best_contract_tokens,
)| {
json!({
"token_id": token_id,
"trade_volume": trade_volume,
"trade_count": trade_count,
"tvl_sats": tvl_sats,
"tvl_tokens": tvl_token,
"best_contract_sats": best_contract_sats,
"best_contract_tokens": best_contract_tokens
})
},
)
.collect();
Ok(Json(result))
}
#[get("/contract/volume?<end>")]
fn contract_volume(
end: Option<i64>,
conn: &State<DBPool>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
let end_timestamp = match end {
Some(e) => e,
None => time_now(),
};
let db = conn
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let volume = rpc::contract_volume(&db, end_timestamp as u64)
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
let result: Vec<Value> = volume
.into_par_iter()
.map(|(token, (total, thirty_days, one_day))| {
json!({
"token_id": token,
"total_sats": total,
"thirty_days_sats": thirty_days,
"one_day_sats": one_day
})
})
.collect();
Ok(Json(result))
}
fn set_panic_hook() {
panic::set_hook(Box::new(|panic_info| {
error!("A thread panicked, terminating the program.");
@ -368,16 +71,46 @@ fn set_panic_hook() {
}));
}
fn start_program() -> Result<DBPool> {
let db_path = "cauldron.db";
let db_exists = Path::new(db_path).exists();
fn start_program() -> Result<(DB, BCMRDownloader)> {
let create_db_pool = |db_path| -> (bool, DBPool, DBPool) {
let db_exists = Path::new(db_path).exists();
let manager = r2d2_sqlite::SqliteConnectionManager::file(db_path)
.with_init(|c| c.execute_batch("PRAGMA foreign_keys=1;"));
let write_manager = r2d2_sqlite::SqliteConnectionManager::file(db_path)
.with_flags(OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE)
.with_init(|c| c.execute_batch("PRAGMA foreign_keys=1;"))
.with_init(|c| c.busy_timeout(Duration::from_secs(30)))
.with_init(|c| c.execute_batch("PRAGMA journal_mode=WAL;"));
let pool = Arc::new(r2d2::Pool::new(manager).expect("Failed to initialize database"));
let write_pool =
Arc::new(r2d2::Pool::new(write_manager).expect("Failed to initialize database"));
let read_manager = r2d2_sqlite::SqliteConnectionManager::file(db_path)
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_init(|c| c.execute_batch("PRAGMA foreign_keys=1;"))
.with_init(|c| c.busy_timeout(Duration::from_secs(30)))
.with_init(|c| c.execute_batch("PRAGMA journal_mode=WAL;"));
let read_pool =
Arc::new(r2d2::Pool::new(read_manager).expect("Failed to initialize database"));
(db_exists, write_pool, read_pool)
};
let (db_exists, cauldron_db_write, cauldron_db_read) = create_db_pool("cauldron.db");
if !db_exists {
prepare_tables(&pool.get().expect("failed to get sqlite connection"));
db::cauldron::prepare_tables(
&cauldron_db_write
.get()
.expect("failed to get sqlite connection"),
);
}
let (db_exists, bcmr_db_write, bcmr_db_read) = create_db_pool("bcmr.db");
if !db_exists {
db::bcmr::prepare_tables(
&bcmr_db_write
.get()
.expect("failed to create sql connection"),
);
}
let client = Arc::new(Mutex::new(
@ -393,53 +126,79 @@ fn start_program() -> Result<DBPool> {
let chain = Arc::new(Mutex::new(chain::Chain::new(genesis.header)));
info!("Loading block headers...");
let all_headers = load_all_headers(&pool.get().unwrap()).unwrap();
let all_headers = load_all_headers(&cauldron_db_read.get().unwrap()).unwrap();
info!("Initializing {} headers...", all_headers.len());
chain.lock().unwrap().load(all_headers).unwrap();
info!("Headers loaded.");
let pool_cpy = Arc::clone(&pool);
let client_cpy = client.clone();
let db = DB {
cauldron_w: cauldron_db_write,
cauldron_r: cauldron_db_read,
bcmr_w: bcmr_db_write,
bcmr_r: bcmr_db_read,
};
let db_cpy = db.clone();
// let client_cpy = client.clone();
thread::spawn(move || {
let pool = pool_cpy;
let client = client_cpy;
let db = db_cpy;
//let client = client_cpy;
let mut tip: BlockHash = match index_blocks(chain.clone(), pool.clone(), client.clone()) {
Ok(tip) => tip,
Err(e) => {
panic!("Initial index failed: {}", e);
}
let mut tip: BlockHash = loop {
break match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
Ok(tip) => tip,
Err(e) => {
if e.to_string().contains("database is locked") {
warn!("initial index error, trying again: {}", e);
continue;
}
panic!("Initial index failed: {}\n {}", e, e.backtrace());
}
};
};
loop {
let new_tip = electrum_get_tip(&client.lock().unwrap())
.unwrap()
.0
.block_hash();
let new_tip = match electrum_get_tip(&client.lock().unwrap()) {
Ok(t) => t.0.block_hash(),
Err(e) => {
warn!("Failed to get block chain tip from electrum: {}", e);
thread::sleep(Duration::from_secs(5));
continue;
}
};
if new_tip != tip {
tip = index_blocks(chain.clone(), pool.clone(), client.clone())
.expect("Indexing failed");
tip = match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
Ok(t) => t,
Err(e) => {
warn!("Indexing block failed: {} {}", e, e.backtrace());
tip
}
}
}
if let Err(e) = update_mempool(pool.clone(), client.clone()) {
if let Err(e) = update_mempool(db.cauldron_w.clone(), client.clone()) {
error!("Failed to update mempool: {}", e);
}
thread::sleep(Duration::from_secs(5));
}
});
Ok(pool)
let mut bcmrdownloader = BCMRDownloader::new(db.bcmr_w.clone());
bcmrdownloader.start()?;
Ok((db, bcmrdownloader))
}
#[launch]
fn launch() -> _ {
stderrlog::new()
.verbosity(LogLevelNum::Debug)
.verbosity(LogLevelNum::Info)
.init()
.unwrap();
set_panic_hook();
let dbpool = match start_program() {
let (dbpool, bcmrdownloader) = match start_program() {
Ok(db) => db,
Err(e) => {
let backtrace = Backtrace::capture();
@ -468,21 +227,24 @@ fn launch() -> _ {
rocket::build()
.manage(dbpool)
// give rocket ownership of downloader to ensure thread isn't dropped
.manage(bcmrdownloader)
.mount(
"/cauldron/",
routes![
rpc::tvl::deprecated_tvl,
rpc::tvl::valuelocked_token,
rpc::tvl::valuelocked_all,
list_by_volume,
rpc::tokens::list_by_volume,
rpc::price::price_history,
rpc::price::price_current,
rpc::pool::list_pools_by_apy,
rpc::pool::list_active_pools,
rpc::contract::contract_count_token,
rpc::contract::contract_count_all,
contract_volume
rpc::contract::contract_volume
],
)
.mount("/bcmr", routes![rpc::bcmr::token_bcmr,])
.attach(cors)
}

32
src/rpc/bcmr.rs Normal file
View file

@ -0,0 +1,32 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::Context;
use bitcoin_hashes::hex::{FromHex, ToHex};
use bitcoincash::TokenID;
use rocket::{get, http::Status, response::status::Custom, serde::json::Json, State};
use serde_json::json;
use serde_json::Value;
use crate::db::{bcmr::get_token_bcmr, DB};
#[get("/token/<category>")]
pub fn token_bcmr(category: Option<&str>, db: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let token_id_hex = category
.context("category missing")
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
// validate input by parsing it into TokenID
let token_id = TokenID::from_hex(token_id_hex)
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
let conn = db
.bcmr_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let bcmr = get_token_bcmr(&conn, &token_id.to_hex())
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
Ok(Json(json!(bcmr)))
}

View file

@ -4,12 +4,13 @@
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::Result;
use rayon::prelude::*;
use rocket::{get, http::Status, response::status::Custom, serde::json::Json, State};
use rusqlite::Connection;
use serde::Serialize;
use serde_json::{json, Value};
use crate::db::DBPool;
use crate::{db::DB, timeutil::time_now};
#[derive(Serialize)]
pub struct ContractCount {
@ -50,8 +51,9 @@ fn db_contract_count_by_token(db: &Connection, token_id: &str) -> Result<Contrac
}
#[get("/contract/count")]
pub fn contract_count_all(conn: &State<DBPool>) -> Result<Json<Value>, Custom<String>> {
pub fn contract_count_all(conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let count = db_contract_count_all(&db)
@ -60,14 +62,44 @@ pub fn contract_count_all(conn: &State<DBPool>) -> Result<Json<Value>, Custom<St
}
#[get("/contract/count/<token>")]
pub fn contract_count_token(
token: &str,
conn: &State<DBPool>,
) -> Result<Json<Value>, Custom<String>> {
pub fn contract_count_token(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let count = db_contract_count_by_token(&db, token)
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
Ok(Json(json!(count)))
}
#[get("/contract/volume?<end>")]
pub fn contract_volume(
end: Option<i64>,
conn: &State<DB>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
let end_timestamp = match end {
Some(e) => e,
None => time_now(),
};
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let volume = super::contract_volume(&db, end_timestamp as u64)
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
let result: Vec<Value> = volume
.into_par_iter()
.map(|(token, (total, thirty_days, one_day))| {
json!({
"token_id": token,
"total_sats": total,
"thirty_days_sats": thirty_days,
"one_day_sats": one_day
})
})
.collect();
Ok(Json(result))
}

View file

@ -8,19 +8,24 @@ use std::collections::HashMap;
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use crate::{bcmr::parsedbcmr::ParsedBCMR, db::bcmr::get_token_bcmr};
pub mod bcmr;
pub mod contract;
pub mod pool;
pub mod price;
pub mod tokens;
pub mod tvl;
#[allow(clippy::type_complexity)]
pub fn list_tokens_by_volume(
connection: &Connection,
cauldron_conn: &Connection,
bcmr_conn: &Connection,
seconds: usize,
limit: usize,
) -> Result<Vec<(String, u64, u64, u64, u64, u64, u64)>> {
) -> Result<Vec<(String, u64, u64, u64, u64, u64, u64, Option<ParsedBCMR>)>> {
// List token ID's by volume last n seconds
let mut statement = connection
let mut statement = cauldron_conn
.prepare(
"
WITH TradeData AS (
@ -78,7 +83,7 @@ pub fn list_tokens_by_volume(
",
)?;
let tokens: Vec<(String, u64, u64, u64, u64, u64, u64)> = statement
let tokens: Vec<(String, u64, u64, u64, u64, u64, u64, Option<ParsedBCMR>)> = statement
.query_and_then([seconds, limit], |row| {
let token_id: String = row.get(0)?;
let trade_volume: u64 = row.get(1)?;
@ -87,6 +92,7 @@ pub fn list_tokens_by_volume(
let tvl_token: u64 = row.get(4)?;
let best_contract_sats: u64 = row.get(5)?;
let best_contracts_token: u64 = row.get(6)?;
let bcmr = get_token_bcmr(bcmr_conn, &token_id)?;
Ok((
token_id,
trade_volume,
@ -95,10 +101,11 @@ pub fn list_tokens_by_volume(
tvl_token,
best_contract_sats,
best_contracts_token,
bcmr,
))
})
.unwrap()
.map(|row: Result<(String, u64, u64, u64, u64, u64, u64)>| row.unwrap())
.map(|row: Result<(String, u64, u64, u64, u64, u64, u64, Option<ParsedBCMR>)>| row.unwrap())
.collect();
Ok(tokens)

View file

@ -5,7 +5,7 @@
use crate::{
cashaddr::{self, version_byte_flags},
db::DBPool,
db::DB,
timeutil::time_now,
};
use anyhow::{Context, Result};
@ -139,8 +139,9 @@ fn pools_by_apy(connection: &Connection) -> Result<Vec<PoolYield>> {
}
#[get("/pool/list_by_apy")]
pub fn list_pools_by_apy(conn: &State<DBPool>) -> Result<Json<Value>, Custom<String>> {
pub fn list_pools_by_apy(conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
@ -237,9 +238,10 @@ fn db_list_active_pools(
pub fn list_active_pools(
token: Option<&str>,
pkh: Option<&str>,
conn: &State<DBPool>,
conn: &State<DB>,
) -> Result<Json<Value>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;

View file

@ -9,7 +9,7 @@ use rusqlite::{params, Connection};
use rust_decimal::prelude::*;
use serde_json::{json, Value};
use crate::{db::DBPool, timeutil::time_now};
use crate::{db::DB, timeutil::time_now};
struct PriceInterval {
start: i64,
@ -181,8 +181,9 @@ fn historic_price(
}
#[get("/price/<token>/current")]
pub fn price_current(token: &str, conn: &State<DBPool>) -> Result<Json<Value>, Custom<String>> {
pub fn price_current(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
@ -200,11 +201,12 @@ pub fn price_history(
start: Option<i64>,
end: Option<i64>,
stepsize: Option<i64>,
conn: &State<DBPool>,
conn: &State<DB>,
) -> Result<Json<Value>, Custom<String>> {
let current_timestamp = time_now();
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;

65
src/rpc/tokens.rs Normal file
View file

@ -0,0 +1,65 @@
// Copyright (C) 2024 Riften Labs AS
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use rocket::http::Status;
use rocket::{get, response::status::Custom, serde::json::Json, State};
use serde_json::json;
use serde_json::Value;
use crate::bcmr::parsedbcmr::ParsedBCMR;
use crate::db::DB;
use crate::rpc;
use rayon::prelude::*;
#[get("/tokens/list_by_volume?<duration>&<limit>")]
pub fn list_by_volume(
duration: Option<usize>,
limit: Option<usize>,
db: &State<DB>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
let thirty_days = 24 * 60 * 60 * 30;
let duration = duration.unwrap_or(thirty_days);
let limit = limit.unwrap_or(50);
let limit = std::cmp::max(limit, 1000);
#[allow(clippy::type_complexity)]
let list: Vec<(String, u64, u64, u64, u64, u64, u64, Option<ParsedBCMR>)> =
rpc::list_tokens_by_volume(
&db.cauldron_r.get().unwrap(),
&db.bcmr_r.get().unwrap(),
duration,
limit,
)
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
let result: Vec<Value> = list
.into_par_iter()
.map(
|(
token_id,
trade_volume,
trade_count,
tvl_sats,
tvl_token,
best_contract_sats,
best_contract_tokens,
bcmr,
)| {
json!({
"token_id": token_id,
"trade_volume": trade_volume,
"trade_count": trade_count,
"tvl_sats": tvl_sats,
"tvl_tokens": tvl_token,
"best_contract_sats": best_contract_sats,
"best_contract_tokens": best_contract_tokens,
"bcmr": bcmr
})
},
)
.collect();
Ok(Json(result))
}

View file

@ -9,7 +9,7 @@ use rocket::{get, http::Status, response::status::Custom, serde::json::Json, Sta
use rusqlite::{params, Connection};
use serde_json::{json, Value};
use crate::{db::DBPool, timeutil::time_now};
use crate::{db::DB, timeutil::time_now};
/// Fetch TVL for all tokens
pub fn get_all_token_tvl(
@ -92,11 +92,9 @@ pub fn get_token_tvl(
// Deprecated; use valuelocked with optional parameters
#[get("/tvl/<time>")]
pub fn deprecated_tvl(
time: usize,
conn: &State<DBPool>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
pub fn deprecated_tvl(time: usize, conn: &State<DB>) -> Result<Json<Vec<Value>>, Custom<String>> {
let db = conn
.cauldron_r
.get()
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
@ -120,9 +118,9 @@ pub fn deprecated_tvl(
#[get("/valuelocked?<time>")]
pub fn valuelocked_all(
time: Option<usize>,
conn: &State<DBPool>,
conn: &State<DB>,
) -> Result<Json<Vec<Value>>, Custom<String>> {
let db = conn.get().map_err(|_| {
let db = conn.cauldron_r.get().map_err(|_| {
Custom(
Status::InternalServerError,
"Failed to get DB connection".into(),
@ -152,9 +150,9 @@ pub fn valuelocked_all(
pub fn valuelocked_token(
token: &str,
time: Option<usize>,
conn: &State<DBPool>,
conn: &State<DB>,
) -> Result<Json<Value>, Custom<String>> {
let db = conn.get().map_err(|_| {
let db = conn.cauldron_r.get().map_err(|_| {
Custom(
Status::InternalServerError,
"Failed to get DB connection".into(),