riftenlabs-indexer/src/db/bcmr/mod.rs
Dagur Valberg Johannsson d85bc2c9db
Update copyright headers
2026-01-21 12:37:13 +01:00

540 lines
17 KiB
Rust

// Copyright (C) 2024-2026 Whiterun LLC
//
// 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 (
token_id TEXT NOT NULL,
utxo TEXT NOT NULL,
blockhash TEXT NOT NULL,
txid TEXT NOT NULL,
height INT NOT NULL,
bcmr_data TEXT,
PRIMARY KEY (token_id, utxo)
)",
[],
)
.expect("failed to create auth_chain_entry table");
conn.execute(
"CREATE TABLE bcmr_data (
token_id TEXT NOT NULL,
utxo TEXT NOT NULL,
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,
PRIMARY KEY (token_id, utxo),
FOREIGN KEY (token_id, utxo)
REFERENCES auth_chain_entry(token_id, utxo)
ON DELETE CASCADE
);
",
[],
)
.expect("failed to create bcmr_data table");
conn.execute(
"CREATE TABLE bcmr_failure (
token_id TEXT NOT NULL,
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 (token_id, 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();
// Create index for efficient token BCMR lookups
conn.execute("CREATE INDEX idx_auth_utxo ON auth_chain_entry(utxo);", [])
.expect("failed to create index for token BCMR lookups");
// 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");
// 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
AND bd.token_id = r.token_id
LEFT JOIN bcmr_failure bf
ON bf.utxo = r.utxo
AND bf.txid = r.txid
AND bf.token_id = r.token_id
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,
token_id: &TokenID,
utxo: &OutPointHash,
bcmr: &ParsedBCMR,
) -> Result<()> {
let sql = "INSERT OR REPLACE INTO bcmr_data (
token_id,
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![
&token_id.to_hex(),
&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.as_deref().unwrap_or(""),
&bcmr.filemeta.actual_hash.as_deref().unwrap_or(""),
],
)?;
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,
token_id: &TokenID,
error_message: &str,
give_up: bool,
) -> Result<()> {
let sql = format!(
"
INSERT INTO bcmr_failure (token_id, utxo, txid, last_attempt, attempts, error_message, give_up)
VALUES (?1, ?2, ?3, strftime('%s','now'), 1, ?4, ?5)
ON CONFLICT(token_id, 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![
&token_id.to_hex(),
&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 symbol, decimals, name, description, icon, web, actual_hash, expected_hash
FROM bcmr_data
WHERE token_id = ?
ORDER BY ROWID 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: 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)
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin_hashes::hex::FromHex;
#[test]
fn test_update_bcmr_failure_multiple_tokens_same_utxo_txid() {
// Test that bcmr_failure can record failures for multiple tokens
// that share the same (utxo, txid) - this happens when a single
// transaction updates the auth chain for multiple tokens.
let conn = Connection::open_in_memory().unwrap();
prepare_tables(&conn);
// Two different tokens
let token_a =
TokenID::from_hex("0101010101010101010101010101010101010101010101010101010101010101")
.unwrap();
let token_b =
TokenID::from_hex("0202020202020202020202020202020202020202020202020202020202020202")
.unwrap();
// Same utxo and txid (simulates a tx that updates auth chain for both tokens)
let utxo = OutPointHash::from_hex(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
let txid =
Txid::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
.unwrap();
// Record failure for token A - should succeed
update_bcmr_failure(&conn, &utxo, &txid, &token_a, "error A", false)
.expect("first update_bcmr_failure should succeed");
// Record failure for token B with same (utxo, txid) - should also succeed
update_bcmr_failure(&conn, &utxo, &txid, &token_b, "error B", false).expect(
"second update_bcmr_failure with same utxo/txid but different token should succeed",
);
// Verify both entries exist
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM bcmr_failure", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 2, "should have 2 failure entries");
// Verify we can update both independently
update_bcmr_failure(&conn, &utxo, &txid, &token_a, "error A updated", false)
.expect("updating token A failure should succeed");
update_bcmr_failure(&conn, &utxo, &txid, &token_b, "error B updated", false)
.expect("updating token B failure should succeed");
// Still only 2 entries (updates, not new inserts)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM bcmr_failure", [], |row| row.get(0))
.unwrap();
assert_eq!(
count, 2,
"should still have 2 failure entries after updates"
);
}
}