// 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>, } 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 { 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 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 = 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, 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> { // 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> { 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) } #[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" ); } }