// 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"); // 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(); // 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 { 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_entries_missing_bcmr_download(conn: &Connection) -> Result> { 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 = 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 = 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::>() ); } 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> { // 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> { 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) }