// 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>, } 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"); 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) ON DELETE CASCADE )", [], ) .expect("failed to create bcmr_failure table"); 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 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"); } 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 { 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>, ) -> 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> { 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 = 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, ) -> Result> { // To not exceed SQL query length; we chunk the queries into entries of this size. let chunk_size = 10000; let keys: Vec = keys.map(|utxo| utxo.to_hex()).collect(); let mut matches: Vec = 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 = 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> { 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 = 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 = 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 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, 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 > {MAX_DOWNLOAD_ATTEMPTS} THEN true ELSE excluded.give_up END" ); conn.execute(&sql, params![&utxo.to_hex(), error_message, give_up])?; Ok(()) } pub fn get_token_bcmr(conn: &Connection, token_hex: &str) -> Result> { 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: 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> { 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 = 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) }