2026-01-21 12:34:59 +01:00
|
|
|
// Copyright (C) 2024-2026 Whiterun LLC
|
2024-05-09 11:25:27 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
|
2024-09-10 22:37:44 +02:00
|
|
|
use crate::bcmr::parsedbcmr::{FileMeta, ParsedBCMR, Token, Uris, SOURCE_ON_CHAIN};
|
2026-02-01 13:23:57 +01:00
|
|
|
use crate::db::blob::{display_hex_to_blob, FromBlob, ToBlob};
|
2024-05-09 11:25:27 +02:00
|
|
|
use anyhow::*;
|
2026-06-10 16:06:02 +02:00
|
|
|
|
2024-05-09 11:25:27 +02:00
|
|
|
use bitcoincash::{BlockHash, TokenID, Txid};
|
|
|
|
|
use log::info;
|
|
|
|
|
use riftenlabs_defi::chainutil::OutPointHash;
|
2026-02-17 17:47:43 +01:00
|
|
|
use sqlx::{Row, SqlitePool};
|
2024-05-09 11:25:27 +02:00
|
|
|
|
|
|
|
|
const MAX_DOWNLOAD_ATTEMPTS: usize = 100;
|
|
|
|
|
|
|
|
|
|
pub struct AuthChainEntry {
|
|
|
|
|
pub utxo: OutPointHash,
|
|
|
|
|
pub txid: Txid,
|
|
|
|
|
pub token_id: TokenID,
|
|
|
|
|
pub height: usize,
|
|
|
|
|
pub bcmr_data: Option<Vec<u8>>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn prepare_tables(pool: &SqlitePool) {
|
|
|
|
|
sqlx::query(
|
2024-05-09 11:25:27 +02:00
|
|
|
"CREATE TABLE auth_chain_entry (
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id BLOB NOT NULL,
|
|
|
|
|
utxo BLOB NOT NULL,
|
|
|
|
|
blockhash BLOB NOT NULL,
|
|
|
|
|
txid BLOB NOT NULL,
|
2025-11-17 09:21:51 +00:00
|
|
|
height INT NOT NULL,
|
|
|
|
|
bcmr_data TEXT,
|
|
|
|
|
PRIMARY KEY (token_id, utxo)
|
2024-05-09 11:25:27 +02:00
|
|
|
)",
|
|
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
2024-05-09 11:25:27 +02:00
|
|
|
.expect("failed to create auth_chain_entry table");
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(
|
2024-05-09 11:25:27 +02:00
|
|
|
"CREATE TABLE bcmr_data (
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id BLOB NOT NULL,
|
|
|
|
|
utxo BLOB NOT NULL,
|
2025-11-17 09:21:51 +00:00
|
|
|
symbol TEXT NOT NULL,
|
|
|
|
|
decimals INT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
description TEXT NOT NULL,
|
|
|
|
|
icon TEXT NOT NULL,
|
|
|
|
|
web TEXT NOT NULL,
|
2024-05-09 11:25:27 +02:00
|
|
|
expected_hash TEXT NOT NULL,
|
2025-11-17 09:21:51 +00:00
|
|
|
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
|
2026-02-17 17:47:43 +01:00
|
|
|
)",
|
2024-05-09 11:25:27 +02:00
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
2024-05-09 11:25:27 +02:00
|
|
|
.expect("failed to create bcmr_data table");
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(
|
2024-05-09 11:25:27 +02:00
|
|
|
"CREATE TABLE bcmr_failure (
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id BLOB NOT NULL,
|
|
|
|
|
utxo BLOB NOT NULL,
|
|
|
|
|
txid BLOB NOT NULL,
|
2024-05-09 11:25:27 +02:00
|
|
|
last_attempt INT NOT NULL,
|
2025-11-17 09:21:51 +00:00
|
|
|
attempts INT NOT NULL,
|
2024-05-09 11:25:27 +02:00
|
|
|
error_message TEXT,
|
2025-11-17 09:21:51 +00:00
|
|
|
give_up BOOLEAN,
|
|
|
|
|
PRIMARY KEY (token_id, utxo, txid)
|
2024-05-09 11:25:27 +02:00
|
|
|
)",
|
|
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
2024-05-09 11:25:27 +02:00
|
|
|
.expect("failed to create bcmr_failure table");
|
2024-09-10 22:37:44 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_bcmr_failure_txid ON bcmr_failure(txid)")
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
2025-11-07 13:53:47 +00:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(
|
|
|
|
|
"CREATE TABLE bcmr_well_known (
|
2024-09-10 22:37:44 +02:00
|
|
|
source TEXT NOT NULL,
|
|
|
|
|
symbol TEXT NOT NULL,
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id BLOB NOT NULL,
|
2024-09-10 22:37:44 +02:00
|
|
|
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)
|
2026-02-17 17:47:43 +01:00
|
|
|
)",
|
2024-09-10 22:37:44 +02:00
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
2024-09-10 22:37:44 +02:00
|
|
|
.unwrap();
|
2025-09-29 14:08:22 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query("CREATE INDEX idx_auth_utxo ON auth_chain_entry(utxo);")
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("failed to create index");
|
|
|
|
|
sqlx::query("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")
|
|
|
|
|
.execute(pool).await.expect("failed to create index");
|
|
|
|
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_auth_token_height ON auth_chain_entry(token_id, height DESC)")
|
|
|
|
|
.execute(pool).await.expect("failed to create index");
|
|
|
|
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_bcmr_data_utxo ON bcmr_data(utxo)")
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("failed to create index");
|
|
|
|
|
sqlx::query(
|
2025-11-07 13:53:47 +00:00
|
|
|
"CREATE INDEX IF NOT EXISTS idx_bcmr_failure_last_attempt ON bcmr_failure(last_attempt)",
|
|
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("failed to create index");
|
2024-09-10 22:37:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
/// Delete all well-known BCMR entries for the given source.
|
|
|
|
|
/// Accepts both `&SqlitePool` and `&mut SqliteConnection` (including transactions).
|
|
|
|
|
pub async fn delete_entries_for_well_known<'e>(
|
|
|
|
|
executor: impl sqlx::Executor<'e, Database = sqlx::Sqlite>,
|
|
|
|
|
source: &str,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
sqlx::query("DELETE FROM bcmr_well_known WHERE source = ?")
|
|
|
|
|
.bind(source)
|
|
|
|
|
.execute(executor)
|
|
|
|
|
.await?;
|
2024-09-10 22:37:44 +02:00
|
|
|
Ok(())
|
2024-05-09 11:25:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result<bool> {
|
|
|
|
|
let r = sqlx::query("DELETE FROM auth_chain_entry WHERE blockhash = ?")
|
|
|
|
|
.bind(blockhash.to_blob())
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(r.rows_affected() != 0)
|
2024-05-09 11:25:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add or update config entry
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn insert_authheader(
|
|
|
|
|
pool: &SqlitePool,
|
2024-05-09 11:25:27 +02:00
|
|
|
utxo: &OutPointHash,
|
|
|
|
|
blockhash: &BlockHash,
|
|
|
|
|
txid: &Txid,
|
|
|
|
|
token_id: &TokenID,
|
|
|
|
|
height: usize,
|
|
|
|
|
bcmr_data: Option<Vec<u8>>,
|
|
|
|
|
) -> Result<()> {
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(
|
2025-11-07 13:53:47 +00:00
|
|
|
"INSERT OR REPLACE INTO auth_chain_entry
|
|
|
|
|
(utxo, blockhash, txid, token_id, height, bcmr_data)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
2026-02-17 17:47:43 +01:00
|
|
|
)
|
|
|
|
|
.bind(utxo.to_blob())
|
|
|
|
|
.bind(blockhash.to_blob())
|
|
|
|
|
.bind(txid.to_blob())
|
|
|
|
|
.bind(token_id.to_blob())
|
|
|
|
|
.bind(height as i64)
|
2026-06-10 16:06:02 +02:00
|
|
|
.bind(bcmr_data.map(|bcmr| hex::encode(&bcmr)))
|
2026-02-17 17:47:43 +01:00
|
|
|
.execute(pool)
|
|
|
|
|
.await?;
|
2024-05-09 11:25:27 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get config entry
|
|
|
|
|
#[allow(dead_code)]
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn get_authheader(
|
|
|
|
|
pool: &SqlitePool,
|
|
|
|
|
utxo: &OutPointHash,
|
|
|
|
|
) -> Result<Option<AuthChainEntry>> {
|
|
|
|
|
let row = sqlx::query(
|
|
|
|
|
"SELECT token_id, txid, height, bcmr_data FROM auth_chain_entry WHERE utxo = ?",
|
|
|
|
|
)
|
|
|
|
|
.bind(utxo.to_blob())
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if let Some(r) = row {
|
|
|
|
|
let token_blob: Vec<u8> = r.get(0);
|
|
|
|
|
let txid_blob: Vec<u8> = r.get(1);
|
|
|
|
|
let height: i64 = r.get(2);
|
|
|
|
|
let bcmr_data_hex: Option<String> = r.get(3);
|
2024-05-09 11:25:27 +02:00
|
|
|
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,
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id: TokenID::from_blob(&token_blob).context("failed to decode token blob")?,
|
|
|
|
|
txid: Txid::from_blob(&txid_blob).context("failed to decode txid blob")?,
|
2026-02-17 17:47:43 +01:00
|
|
|
height: height as usize,
|
2024-05-09 11:25:27 +02:00
|
|
|
bcmr_data,
|
|
|
|
|
}))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn get_entries_missing_bcmr_download(pool: &SqlitePool) -> Result<Vec<AuthChainEntry>> {
|
2025-11-07 13:53:47 +00:00
|
|
|
let sql = r#"
|
|
|
|
|
WITH ranked AS (
|
2024-05-09 11:25:27 +02:00
|
|
|
SELECT
|
2026-02-17 17:47:43 +01:00
|
|
|
ace.utxo, ace.token_id, ace.txid, ace.height, ace.bcmr_data,
|
2025-11-07 13:53:47 +00:00
|
|
|
ROW_NUMBER() OVER (PARTITION BY ace.token_id ORDER BY ace.height DESC) AS rn
|
|
|
|
|
FROM auth_chain_entry ace
|
2026-02-17 17:47:43 +01:00
|
|
|
WHERE ace.bcmr_data IS NOT NULL
|
2024-05-09 11:25:27 +02:00
|
|
|
)
|
2026-02-17 17:47:43 +01:00
|
|
|
SELECT r.token_id, r.txid, r.height, r.bcmr_data, r.utxo
|
2025-11-07 13:53:47 +00:00
|
|
|
FROM ranked r
|
2026-02-17 17:47:43 +01:00
|
|
|
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
|
2025-11-07 13:53:47 +00:00
|
|
|
AND (bf.give_up = 1 OR (strftime('%s','now') - bf.last_attempt) < 1800)
|
2026-02-17 17:47:43 +01:00
|
|
|
WHERE r.rn = 1 AND bd.utxo IS NULL AND bf.utxo IS NULL
|
2025-11-07 13:53:47 +00:00
|
|
|
ORDER BY r.height DESC
|
|
|
|
|
"#;
|
2024-05-09 11:25:27 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let rows = sqlx::query(sql).fetch_all(pool).await?;
|
2024-05-09 11:25:27 +02:00
|
|
|
let mut matches: Vec<AuthChainEntry> = Vec::new();
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
for r in rows {
|
|
|
|
|
let token_blob: Vec<u8> = r.get(0);
|
|
|
|
|
let txid_blob: Vec<u8> = r.get(1);
|
|
|
|
|
let height: i64 = r.get(2);
|
|
|
|
|
let bcmr_data_hex: Option<String> = r.get(3);
|
2024-05-09 11:25:27 +02:00
|
|
|
let bcmr_data = if let Some(data) = bcmr_data_hex {
|
|
|
|
|
Some(hex::decode(&data).context("failed to decode bcmr data")?)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2026-02-17 17:47:43 +01:00
|
|
|
let utxo_blob: Vec<u8> = r.get(4);
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
let utxo = OutPointHash::from_blob(&utxo_blob)?;
|
2024-05-09 11:25:27 +02:00
|
|
|
|
|
|
|
|
matches.push(AuthChainEntry {
|
|
|
|
|
utxo,
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
token_id: TokenID::from_blob(&token_blob).context("failed to decode token blob")?,
|
|
|
|
|
txid: Txid::from_blob(&txid_blob).context("failed to decode txid blob")?,
|
2026-02-17 17:47:43 +01:00
|
|
|
height: height as usize,
|
2024-05-09 11:25:27 +02:00
|
|
|
bcmr_data,
|
2025-11-07 13:53:47 +00:00
|
|
|
});
|
2024-05-09 11:25:27 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-07 13:53:47 +00:00
|
|
|
if !matches.is_empty() {
|
|
|
|
|
log::info!(
|
|
|
|
|
"bcmr: {} head(s) need download; first few: {:?}",
|
|
|
|
|
matches.len(),
|
|
|
|
|
matches
|
|
|
|
|
.iter()
|
|
|
|
|
.take(3)
|
2026-06-10 16:06:02 +02:00
|
|
|
.map(|e| (e.token_id.to_string(), e.height, e.utxo.to_string()))
|
2025-11-07 13:53:47 +00:00
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-05-09 11:25:27 +02:00
|
|
|
Ok(matches)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn insert_bcmr_data(
|
|
|
|
|
pool: &SqlitePool,
|
2025-11-17 09:21:51 +00:00
|
|
|
token_id: &TokenID,
|
2024-05-09 11:25:27 +02:00
|
|
|
utxo: &OutPointHash,
|
|
|
|
|
bcmr: &ParsedBCMR,
|
|
|
|
|
) -> Result<()> {
|
2025-11-17 09:21:51 +00:00
|
|
|
let sql = "INSERT OR REPLACE INTO bcmr_data (
|
2026-02-17 17:47:43 +01:00
|
|
|
token_id, utxo, symbol, decimals, name, description, icon, web,
|
|
|
|
|
expected_hash, actual_hash
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
2024-05-09 11:25:27 +02:00
|
|
|
|
2026-06-10 16:06:02 +02:00
|
|
|
info!("Trying to insert {}", utxo);
|
2026-02-17 17:47:43 +01:00
|
|
|
let empty_string = "".to_owned();
|
|
|
|
|
|
|
|
|
|
sqlx::query(sql)
|
|
|
|
|
.bind(token_id.to_blob())
|
|
|
|
|
.bind(utxo.to_blob())
|
|
|
|
|
.bind(&bcmr.token.symbol)
|
|
|
|
|
.bind(bcmr.token.decimals as i64)
|
|
|
|
|
.bind(&bcmr.name)
|
|
|
|
|
.bind(&bcmr.description)
|
|
|
|
|
.bind(bcmr.uris.icon.as_ref().unwrap_or(&empty_string))
|
|
|
|
|
.bind(bcmr.uris.web.as_ref().unwrap_or(&empty_string))
|
|
|
|
|
.bind(bcmr.filemeta.expected_hash.as_deref().unwrap_or(""))
|
|
|
|
|
.bind(bcmr.filemeta.actual_hash.as_deref().unwrap_or(""))
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await?;
|
2025-11-17 09:21:51 +00:00
|
|
|
|
2024-05-09 11:25:27 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
/// Insert or replace a well-known BCMR entry.
|
|
|
|
|
/// Accepts both `&SqlitePool` and `&mut SqliteConnection` (including transactions).
|
|
|
|
|
pub async fn insert_well_known_bcmr<'e>(
|
|
|
|
|
executor: impl sqlx::Executor<'e, Database = sqlx::Sqlite>,
|
2024-09-10 22:37:44 +02:00
|
|
|
source: &str,
|
|
|
|
|
bcmr: &ParsedBCMR,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let sql = "INSERT OR REPLACE INTO bcmr_well_known (source, symbol, token_id, decimals, name, description, icon, web)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let empty_string = "".to_owned();
|
2026-02-01 13:23:57 +01:00
|
|
|
let token_blob = display_hex_to_blob::<TokenID>(&bcmr.token.category)?;
|
2024-09-10 22:37:44 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(sql)
|
|
|
|
|
.bind(source)
|
|
|
|
|
.bind(&bcmr.token.symbol)
|
|
|
|
|
.bind(token_blob)
|
|
|
|
|
.bind(bcmr.token.decimals as i64)
|
|
|
|
|
.bind(&bcmr.name)
|
|
|
|
|
.bind(&bcmr.description)
|
|
|
|
|
.bind(bcmr.uris.icon.as_ref().unwrap_or(&empty_string))
|
|
|
|
|
.bind(bcmr.uris.web.as_ref().unwrap_or(&empty_string))
|
|
|
|
|
.execute(executor)
|
|
|
|
|
.await?;
|
2024-09-10 22:37:44 +02:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn update_bcmr_failure(
|
|
|
|
|
pool: &SqlitePool,
|
2024-05-09 11:25:27 +02:00
|
|
|
utxo: &OutPointHash,
|
2025-11-07 13:53:47 +00:00
|
|
|
txid: &Txid,
|
2025-11-17 09:21:51 +00:00
|
|
|
token_id: &TokenID,
|
2024-05-09 11:25:27 +02:00
|
|
|
error_message: &str,
|
|
|
|
|
give_up: bool,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let sql = format!(
|
2026-02-17 17:47:43 +01:00
|
|
|
"INSERT INTO bcmr_failure (token_id, utxo, txid, last_attempt, attempts, error_message, give_up)
|
2025-11-17 09:21:51 +00:00
|
|
|
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
|
2026-02-17 17:47:43 +01:00
|
|
|
END",
|
2025-11-17 09:21:51 +00:00
|
|
|
max = MAX_DOWNLOAD_ATTEMPTS,
|
2024-05-09 11:25:27 +02:00
|
|
|
);
|
2026-02-17 17:47:43 +01:00
|
|
|
sqlx::query(&sql)
|
|
|
|
|
.bind(token_id.to_blob())
|
|
|
|
|
.bind(utxo.to_blob())
|
|
|
|
|
.bind(txid.to_blob())
|
|
|
|
|
.bind(error_message)
|
|
|
|
|
.bind(give_up)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await?;
|
2024-05-09 11:25:27 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 11:07:31 +00:00
|
|
|
/// Return the current authhead of a token's auth chain: the txid of the latest (highest
|
|
|
|
|
/// block height) auth_chain_entry. The authhead UTXO is always output 0 of that tx
|
|
|
|
|
/// (see `compute_outpoint_hash(&txid, 0)` in the indexer), so the outpoint is `<txid>:0`.
|
|
|
|
|
/// Returns None if the token has no auth chain entry.
|
|
|
|
|
pub async fn get_current_authhead(
|
|
|
|
|
pool: &SqlitePool,
|
|
|
|
|
token_hex: &str,
|
|
|
|
|
) -> Result<Option<(Txid, usize)>> {
|
|
|
|
|
// txid is a deterministic tiebreaker: a forked/branched chain can have two entries at the
|
|
|
|
|
// same max height for one token, and without it SQLite's LIMIT 1 could flip between them.
|
|
|
|
|
let sql = "SELECT txid, height FROM auth_chain_entry
|
|
|
|
|
WHERE token_id = ? ORDER BY height DESC, txid DESC LIMIT 1";
|
|
|
|
|
let token_blob = display_hex_to_blob::<TokenID>(token_hex)?;
|
|
|
|
|
|
|
|
|
|
let row = sqlx::query(sql)
|
|
|
|
|
.bind(token_blob)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if let Some(r) = row {
|
|
|
|
|
let txid_blob: Vec<u8> = r.get(0);
|
|
|
|
|
let height: i64 = r.get(1);
|
|
|
|
|
let txid = Txid::from_blob(&txid_blob).context("failed to decode txid blob")?;
|
|
|
|
|
Ok(Some((txid, height as usize)))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn get_token_bcmr(pool: &SqlitePool, token_hex: &str) -> Result<Option<ParsedBCMR>> {
|
2025-11-07 13:53:47 +00:00
|
|
|
let sql = r#"
|
2025-11-17 09:21:51 +00:00
|
|
|
SELECT symbol, decimals, name, description, icon, web, actual_hash, expected_hash
|
|
|
|
|
FROM bcmr_data
|
|
|
|
|
WHERE token_id = ?
|
|
|
|
|
ORDER BY ROWID DESC
|
|
|
|
|
LIMIT 1;
|
2025-11-07 13:53:47 +00:00
|
|
|
"#;
|
2026-02-01 13:23:57 +01:00
|
|
|
let token_blob = display_hex_to_blob::<TokenID>(token_hex)?;
|
2024-05-09 11:25:27 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let row = sqlx::query(sql)
|
|
|
|
|
.bind(token_blob)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if let Some(r) = row {
|
|
|
|
|
let symbol: String = r.get(0);
|
|
|
|
|
let decimals: i64 = 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);
|
2024-05-09 11:25:27 +02:00
|
|
|
|
|
|
|
|
Ok(Some(ParsedBCMR {
|
|
|
|
|
name,
|
|
|
|
|
description,
|
|
|
|
|
token: Token {
|
|
|
|
|
category: token_hex.to_owned(),
|
|
|
|
|
symbol,
|
2026-02-17 17:47:43 +01:00
|
|
|
decimals: decimals as usize,
|
2024-05-09 11:25:27 +02:00
|
|
|
},
|
|
|
|
|
uris: Uris {
|
|
|
|
|
web: if web.is_empty() { None } else { Some(web) },
|
|
|
|
|
icon: if icon.is_empty() { None } else { Some(icon) },
|
|
|
|
|
},
|
|
|
|
|
filemeta: FileMeta {
|
2024-09-10 22:37:44 +02:00
|
|
|
expected_hash: Some(expected_hash),
|
|
|
|
|
actual_hash: Some(actual_hash),
|
|
|
|
|
source: SOURCE_ON_CHAIN.into(),
|
2024-05-09 11:25:27 +02:00
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-09-10 22:37:44 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn get_well_known_bcmr(pool: &SqlitePool, token_hex: &str) -> Result<Vec<ParsedBCMR>> {
|
|
|
|
|
let sql = "SELECT source, symbol, decimals, name, description, icon, web
|
|
|
|
|
FROM bcmr_well_known WHERE token_id = ?";
|
2026-02-01 13:23:57 +01:00
|
|
|
let token_blob = display_hex_to_blob::<TokenID>(token_hex)?;
|
2024-09-10 22:37:44 +02:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let rows = sqlx::query(sql).bind(token_blob).fetch_all(pool).await?;
|
2024-09-10 22:37:44 +02:00
|
|
|
|
|
|
|
|
let mut entries: Vec<ParsedBCMR> = Vec::default();
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
for r in rows {
|
|
|
|
|
let source: String = r.get(0);
|
|
|
|
|
let symbol: String = r.get(1);
|
|
|
|
|
let decimals: i64 = r.get(2);
|
|
|
|
|
let name: String = r.get(3);
|
|
|
|
|
let description: String = r.get(4);
|
|
|
|
|
let icon: String = r.get(5);
|
|
|
|
|
let web: String = r.get(6);
|
2024-09-10 22:37:44 +02:00
|
|
|
|
|
|
|
|
entries.push(ParsedBCMR {
|
|
|
|
|
name,
|
|
|
|
|
description,
|
|
|
|
|
token: Token {
|
|
|
|
|
category: token_hex.to_owned(),
|
|
|
|
|
symbol,
|
2026-02-17 17:47:43 +01:00
|
|
|
decimals: decimals as usize,
|
2024-09-10 22:37:44 +02:00
|
|
|
},
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-01-21 10:17:49 +01:00
|
|
|
|
2026-02-02 21:03:11 +01:00
|
|
|
/// Given a list of token blobs, returns which ones have BCMR data in bcmr_data table.
|
|
|
|
|
/// Uses batched IN queries to handle large lists efficiently.
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn filter_tokens_with_bcmr(
|
|
|
|
|
pool: &SqlitePool,
|
|
|
|
|
token_blobs: &[Vec<u8>],
|
|
|
|
|
) -> Result<Vec<Vec<u8>>> {
|
2026-02-02 21:03:11 +01:00
|
|
|
if token_blobs.is_empty() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut result = Vec::new();
|
|
|
|
|
const BATCH_SIZE: usize = 500;
|
|
|
|
|
|
|
|
|
|
for chunk in token_blobs.chunks(BATCH_SIZE) {
|
|
|
|
|
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
|
|
|
|
|
let sql = format!(
|
|
|
|
|
"SELECT DISTINCT token_id FROM bcmr_data WHERE token_id IN ({})",
|
|
|
|
|
placeholders.join(", ")
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let mut query = sqlx::query(&sql);
|
|
|
|
|
for blob in chunk {
|
|
|
|
|
query = query.bind(blob);
|
|
|
|
|
}
|
2026-02-02 21:03:11 +01:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let rows = query.fetch_all(pool).await?;
|
|
|
|
|
for row in rows {
|
|
|
|
|
let blob: Vec<u8> = row.get(0);
|
2026-02-02 21:03:11 +01:00
|
|
|
result.push(blob);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(result)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 10:17:49 +01:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_update_bcmr_failure_multiple_tokens_same_utxo_txid() {
|
|
|
|
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
|
|
|
|
prepare_tables(&pool).await;
|
2026-01-21 10:17:49 +01:00
|
|
|
|
2026-06-10 16:06:02 +02:00
|
|
|
let token_a = "0101010101010101010101010101010101010101010101010101010101010101"
|
|
|
|
|
.parse::<TokenID>()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let token_b = "0202020202020202020202020202020202020202020202020202020202020202"
|
|
|
|
|
.parse::<TokenID>()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let utxo = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
|
|
|
.parse::<OutPointHash>()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let txid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
|
|
|
.parse::<Txid>()
|
|
|
|
|
.unwrap();
|
2026-01-21 10:17:49 +01:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
update_bcmr_failure(&pool, &utxo, &txid, &token_a, "error A", false)
|
|
|
|
|
.await
|
2026-01-21 10:17:49 +01:00
|
|
|
.expect("first update_bcmr_failure should succeed");
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
update_bcmr_failure(&pool, &utxo, &txid, &token_b, "error B", false)
|
|
|
|
|
.await
|
|
|
|
|
.expect("second update_bcmr_failure should succeed");
|
2026-01-21 10:17:49 +01:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bcmr_failure")
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
|
.await
|
2026-01-21 10:17:49 +01:00
|
|
|
.unwrap();
|
2026-02-17 17:47:43 +01:00
|
|
|
assert_eq!(count.0, 2, "should have 2 failure entries");
|
2026-01-21 10:17:49 +01:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
update_bcmr_failure(&pool, &utxo, &txid, &token_a, "error A updated", false)
|
|
|
|
|
.await
|
2026-01-21 10:17:49 +01:00
|
|
|
.expect("updating token A failure should succeed");
|
2026-02-17 17:47:43 +01:00
|
|
|
update_bcmr_failure(&pool, &utxo, &txid, &token_b, "error B updated", false)
|
|
|
|
|
.await
|
2026-01-21 10:17:49 +01:00
|
|
|
.expect("updating token B failure should succeed");
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bcmr_failure")
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
|
.await
|
2026-01-21 10:17:49 +01:00
|
|
|
.unwrap();
|
2026-02-17 17:47:43 +01:00
|
|
|
assert_eq!(count.0, 2, "should still have 2 failure entries");
|
2026-01-21 10:17:49 +01:00
|
|
|
}
|
|
|
|
|
}
|