riftenlabs-indexer/src/db/bcmr/mod.rs

457 lines
14 KiB
Rust
Raw Normal View History

// 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, SOURCE_ON_CHAIN};
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
#[allow(dead_code)]
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) ON DELETE CASCADE
)",
[],
)
.expect("failed to create bcmr_data table");
// NOTE: PRIMARY KEY (utxo, txid) is required for ON CONFLICT(utxo, txid)
conn.execute(
"CREATE TABLE bcmr_failure (
utxo TEXT NOT NULL,
txid TEXT NOT NULL,
last_attempt INT NOT NULL,
attempts INT NOT NULL,
error_message TEXT,
give_up BOOLEAN,
PRIMARY KEY (utxo, txid)
)",
[],
)
.expect("failed to create bcmr_failure table");
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bcmr_failure_txid ON bcmr_failure(txid)",
[],
)
.unwrap();
conn.execute(
"
CREATE TABLE bcmr_well_known (
source TEXT NOT NULL,
symbol TEXT NOT NULL,
token_id TEXT NOT NULL,
decimals INT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
web TEXT NOT NULL,
PRIMARY KEY (source, token_id)
)
",
[],
)
.unwrap();
2025-09-29 14:08:22 +02:00
// Create index for efficient token BCMR lookups
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_chain_token_bcmr_height
ON auth_chain_entry(token_id, height DESC)
WHERE bcmr_data IS NOT NULL",
[],
)
.expect("failed to create index for token BCMR lookups");
// UTXO lookups by primary key
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_utxo ON auth_chain_entry(utxo)",
[],
)
.expect("failed to create idx_auth_utxo");
// Speeds “head” selection for a token (ORDER BY height DESC per token)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_token_height ON auth_chain_entry(token_id, height DESC)",
[],
).expect("failed to create idx_auth_token_height");
// Quick existence test for already-downloaded BCMR
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bcmr_data_utxo ON bcmr_data(utxo)",
[],
)
.expect("failed to create idx_bcmr_data_utxo");
// Enforce per-(utxo, txid) backoff and dedupe updates
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_bcmr_failure_utxo_txid ON bcmr_failure(utxo, txid)",
[],
)
.expect("failed to create idx_bcmr_failure_utxo_txid");
// Optional: if you often filter by recent failures/backoff window
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bcmr_failure_last_attempt ON bcmr_failure(last_attempt)",
[],
)
.expect("failed to create idx_bcmr_failure_last_attempt");
}
pub fn delete_entries_for_well_known(tx: &Connection, source: &str) -> Result<()> {
let mut stmt = tx.prepare("DELETE FROM bcmr_well_known WHERE source = ?")?;
stmt.execute(params![source])?;
Ok(())
}
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_entries_missing_bcmr_download(conn: &Connection) -> Result<Vec<AuthChainEntry>> {
let sql = r#"
WITH ranked AS (
SELECT
ace.utxo,
ace.token_id,
ace.txid,
ace.height,
ace.bcmr_data,
ROW_NUMBER() OVER (PARTITION BY ace.token_id ORDER BY ace.height DESC) AS rn
FROM auth_chain_entry ace
WHERE ace.bcmr_data IS NOT NULL -- <— IMPORTANT: filter BEFORE ranking
)
SELECT
r.token_id, r.txid, r.height, r.bcmr_data, r.utxo
FROM ranked r
LEFT JOIN bcmr_data bd
ON bd.utxo = r.utxo
LEFT JOIN bcmr_failure bf
ON bf.utxo = r.utxo
AND bf.txid = r.txid
AND (bf.give_up = 1 OR (strftime('%s','now') - bf.last_attempt) < 1800)
WHERE r.rn = 1
AND bd.utxo IS NULL
AND bf.utxo IS NULL
ORDER BY r.height DESC
"#;
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: usize = 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,
});
}
if !matches.is_empty() {
log::info!(
"bcmr: {} head(s) need download; first few: {:?}",
matches.len(),
matches
.iter()
.take(3)
.map(|e| (e.token_id.to_hex(), e.height, e.utxo.to_hex()))
.collect::<Vec<_>>()
);
}
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 insert_well_known_bcmr(
conn: &rusqlite::Connection,
source: &str,
bcmr: &ParsedBCMR,
) -> Result<()> {
let sql = "INSERT OR REPLACE INTO bcmr_well_known (source, symbol, token_id, decimals, name, description, icon, web)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
let empty_string: String = "".to_owned();
conn.execute(
sql,
rusqlite::params![
source,
bcmr.token.symbol,
bcmr.token.category,
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)
],
)?;
Ok(())
}
pub fn update_bcmr_failure(
conn: &Connection,
utxo: &OutPointHash,
txid: &Txid,
error_message: &str,
give_up: bool,
) -> Result<()> {
let sql = format!(
"
INSERT INTO bcmr_failure (utxo, txid, last_attempt, attempts, error_message, give_up)
VALUES (?1, ?2, strftime('%s','now'), 1, ?3, ?4)
ON CONFLICT(utxo, txid) 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 > {max} THEN 1
ELSE excluded.give_up
END",
max = MAX_DOWNLOAD_ATTEMPTS
);
conn.execute(
&sql,
params![&utxo.to_hex(), &txid.to_hex(), error_message, give_up],
)?;
Ok(())
}
pub fn get_token_bcmr(conn: &Connection, token_hex: &str) -> Result<Option<ParsedBCMR>> {
// Pick *exactly* the head utxo for this token.
let sql = r#"
SELECT b.symbol, b.decimals, b.name, b.description, b.icon, b.web,
b.actual_hash, b.expected_hash
FROM bcmr_data b
WHERE b.utxo = (
SELECT ace.utxo
FROM auth_chain_entry ace
WHERE ace.token_id = ?
AND ace.bcmr_data IS NOT NULL
ORDER BY ace.height DESC
LIMIT 1
)
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: String = r.get(2)?;
let description: String = 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: Some(expected_hash),
actual_hash: Some(actual_hash),
source: SOURCE_ON_CHAIN.into(),
},
}))
} else {
Ok(None)
}
}
pub fn get_well_known_bcmr(conn: &Connection, token_hex: &str) -> Result<Vec<ParsedBCMR>> {
let sql = "SELECT
source, symbol, decimals, name, description, icon, web
FROM bcmr_well_known
WHERE token_id = ?";
let mut stmt = conn.prepare(sql)?;
let mut row = stmt.query([token_hex])?;
let mut entries: Vec<ParsedBCMR> = Vec::default();
while let Some(r) = row.next()? {
let source: String = r.get(0)?;
let symbol: String = r.get(1)?;
let decimals: usize = r.get(2)?;
let name = r.get(3)?;
let description = r.get(4)?;
let icon: String = r.get(5)?;
let web: String = r.get(6)?;
entries.push(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: None,
actual_hash: None,
source,
},
})
}
Ok(entries)
}