Migrate off the deprecated bitcoincash 0.29 API (Amount/Version types, FromHex -> FromStr/parse, non-exhaustive Network) across the indexer and tests. Add indexing of native token-A <-> token-B (TokenToken) AMM pools: - tokentoken_pool / tokentoken_pool_history_entry tables in cauldron.db, created via an always-run idempotent migration (no DB_VERSION bump, so existing databases upgrade in place) - block-path indexing sharing the cauldron write transaction and reorg undo, with creation/swap/withdrawal state tracking and reserve deltas - mempool indexing: electrum mempool.get filters on the tokentoken contract code (spends) and the CONJURE op_return hint (creations); first_seen_timestamp reconciles with mtp on confirmation - RPC endpoints /tokentoken/pool/active (pair lookup, order-insensitive) and /tokentoken/tokens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
13 KiB
Rust
369 lines
13 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
|
|
|
|
//! BLOB storage helpers for efficient database storage of hash types.
|
|
//!
|
|
//! Converts 32-byte hashes from TEXT (64-char hex) to BLOB (32 bytes) for ~50% storage savings.
|
|
//!
|
|
//! ## IMPORTANT: Byte Order (Endianness)
|
|
//!
|
|
//! Bitcoin hash types (Txid, BlockHash, TokenID, OutPointHash, PoolID) use **reversed byte display**.
|
|
//! When a hash is displayed as hex (e.g., in block explorers or APIs), it shows the bytes in
|
|
//! reverse order compared to how they're stored internally.
|
|
//!
|
|
//! This means:
|
|
//! - `hex.parse::<Txid>()` reverses the input bytes
|
|
//! - `Txid::to_hex()` reverses the output bytes
|
|
//! - `Txid::into_inner()` / `Txid::from_byte_array()` work with internal (non-reversed) bytes
|
|
//!
|
|
//! **NEVER use `hex::decode()` directly on user-provided hash hex strings!**
|
|
//! Instead, use `display_hex_to_blob::<TokenID>(hex)` or `hex.parse::<TokenID>()?.to_blob()`.
|
|
//!
|
|
//! **PubkeyHash is the exception** - it does NOT reverse bytes.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::str::FromStr;
|
|
|
|
use bitcoin_hashes::Hash;
|
|
use bitcoincash::{BlockHash, PubkeyHash, TokenID, Txid};
|
|
use riftenlabs_defi::chainutil::OutPointHash;
|
|
|
|
use crate::def::PoolID;
|
|
|
|
/// Trait for converting hash types to BLOB bytes for database storage.
|
|
pub trait ToBlob {
|
|
fn to_blob(&self) -> Vec<u8>;
|
|
}
|
|
|
|
/// Trait for converting BLOB bytes back to hash types.
|
|
pub trait FromBlob: Sized {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self>;
|
|
}
|
|
|
|
// Implement ToBlob for 32-byte hash types
|
|
impl ToBlob for Txid {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ToBlob for BlockHash {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ToBlob for TokenID {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ToBlob for OutPointHash {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ToBlob for PoolID {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
// Implement ToBlob for 20-byte PubkeyHash
|
|
impl ToBlob for PubkeyHash {
|
|
fn to_blob(&self) -> Vec<u8> {
|
|
self.as_byte_array().to_vec()
|
|
}
|
|
}
|
|
|
|
// Implement FromBlob for 32-byte hash types
|
|
impl FromBlob for Txid {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 32] = bytes.try_into().context("Txid blob must be 32 bytes")?;
|
|
Ok(Txid::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
impl FromBlob for BlockHash {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 32] = bytes
|
|
.try_into()
|
|
.context("BlockHash blob must be 32 bytes")?;
|
|
Ok(BlockHash::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
impl FromBlob for TokenID {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 32] = bytes.try_into().context("TokenID blob must be 32 bytes")?;
|
|
Ok(TokenID::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
impl FromBlob for OutPointHash {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 32] = bytes
|
|
.try_into()
|
|
.context("OutPointHash blob must be 32 bytes")?;
|
|
Ok(OutPointHash::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
impl FromBlob for PoolID {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 32] = bytes.try_into().context("PoolID blob must be 32 bytes")?;
|
|
Ok(PoolID::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
// Implement FromBlob for 20-byte PubkeyHash
|
|
impl FromBlob for PubkeyHash {
|
|
fn from_blob(bytes: &[u8]) -> Result<Self> {
|
|
let arr: [u8; 20] = bytes
|
|
.try_into()
|
|
.context("PubkeyHash blob must be 20 bytes")?;
|
|
Ok(PubkeyHash::from_byte_array(arr))
|
|
}
|
|
}
|
|
|
|
/// Convert a display-format hex string to blob bytes for database storage.
|
|
///
|
|
/// This correctly handles the byte reversal that Bitcoin hash types use.
|
|
/// Use this instead of `hex::decode()` for hash types!
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// // User provides token ID in display format (from API, block explorer, etc.)
|
|
/// let user_input = "abcd1234...";
|
|
/// let blob = display_hex_to_blob::<TokenID>(user_input)?;
|
|
/// // Now `blob` can be used in SQL queries against BLOB columns
|
|
/// ```
|
|
pub fn display_hex_to_blob<T>(hex: &str) -> Result<Vec<u8>>
|
|
where
|
|
T: FromStr + ToBlob,
|
|
{
|
|
let hash = T::from_str(hex).map_err(|_| anyhow::anyhow!("invalid hash hex"))?;
|
|
Ok(hash.to_blob())
|
|
}
|
|
|
|
/// Convert blob bytes from database to display-format hex string.
|
|
///
|
|
/// This correctly handles the byte reversal that Bitcoin hash types use.
|
|
/// Use this instead of relying on SQL `hex()` function for hash types!
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// let blob: Vec<u8> = row.get(0)?;
|
|
/// let display_hex = blob_to_display_hex::<TokenID>(&blob)?;
|
|
/// // Now `display_hex` is in the format users expect
|
|
/// ```
|
|
pub fn blob_to_display_hex<T>(blob: &[u8]) -> Result<String>
|
|
where
|
|
T: FromBlob + std::fmt::Display,
|
|
{
|
|
let hash = T::from_blob(blob)?;
|
|
Ok(hash.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_txid_roundtrip() {
|
|
let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
let txid = hex.parse::<Txid>().unwrap();
|
|
let blob = txid.to_blob();
|
|
assert_eq!(blob.len(), 32);
|
|
let recovered = Txid::from_blob(&blob).unwrap();
|
|
assert_eq!(txid, recovered);
|
|
assert_eq!(recovered.to_string(), hex);
|
|
}
|
|
|
|
#[test]
|
|
fn test_blockhash_roundtrip() {
|
|
let hex = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
|
let hash = hex.parse::<BlockHash>().unwrap();
|
|
let blob = hash.to_blob();
|
|
assert_eq!(blob.len(), 32);
|
|
let recovered = BlockHash::from_blob(&blob).unwrap();
|
|
assert_eq!(hash, recovered);
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_id_roundtrip() {
|
|
let hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
|
let token = hex.parse::<TokenID>().unwrap();
|
|
let blob = token.to_blob();
|
|
assert_eq!(blob.len(), 32);
|
|
let recovered = TokenID::from_blob(&blob).unwrap();
|
|
assert_eq!(token, recovered);
|
|
}
|
|
|
|
#[test]
|
|
fn test_outpointhash_roundtrip() {
|
|
let hex = "1111111111111111111111111111111111111111111111111111111111111111";
|
|
let hash = hex.parse::<OutPointHash>().unwrap();
|
|
let blob = hash.to_blob();
|
|
assert_eq!(blob.len(), 32);
|
|
let recovered = OutPointHash::from_blob(&blob).unwrap();
|
|
assert_eq!(hash, recovered);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pubkeyhash_roundtrip() {
|
|
let hex = "0123456789abcdef01230123456789abcdef0123";
|
|
let pkh = hex.parse::<PubkeyHash>().unwrap();
|
|
let blob = pkh.to_blob();
|
|
assert_eq!(blob.len(), 20);
|
|
let recovered = PubkeyHash::from_blob(&blob).unwrap();
|
|
assert_eq!(pkh, recovered);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_blob_size() {
|
|
let short_blob = vec![0u8; 16];
|
|
assert!(Txid::from_blob(&short_blob).is_err());
|
|
assert!(BlockHash::from_blob(&short_blob).is_err());
|
|
assert!(TokenID::from_blob(&short_blob).is_err());
|
|
assert!(PubkeyHash::from_blob(&short_blob).is_err());
|
|
}
|
|
|
|
// ==================== ENDIANNESS TESTS ====================
|
|
// These tests use asymmetric (non-palindromic) hex values to catch byte-order bugs.
|
|
// The original tests used values like [0x11; 32] which are the same reversed.
|
|
|
|
/// Proves that hex::decode() differs from from_hex().to_blob() for Bitcoin hash types.
|
|
/// This is the core test that would have caught the LE/BE bug.
|
|
#[test]
|
|
fn test_hex_decode_differs_from_display_hex_to_blob() {
|
|
// Asymmetric hex - NOT a palindrome, so reversal is detectable
|
|
let display_hex = "0102030405060708091011121314151617181920212223242526272829303132";
|
|
|
|
// WRONG: raw hex::decode (no byte reversal)
|
|
let raw_bytes = hex::decode(display_hex).unwrap();
|
|
|
|
// CORRECT: parse as TokenID then get blob (handles reversal)
|
|
let token = display_hex.parse::<TokenID>().unwrap();
|
|
let blob_bytes = token.to_blob();
|
|
|
|
// These MUST be different because Bitcoin hashes reverse bytes for display
|
|
assert_ne!(
|
|
raw_bytes, blob_bytes,
|
|
"hex::decode and to_blob MUST differ for Bitcoin hash types!"
|
|
);
|
|
|
|
// Verify the reversal: first byte of display becomes last byte of blob
|
|
assert_eq!(raw_bytes[0], 0x01, "raw_bytes should start with 0x01");
|
|
assert_eq!(
|
|
blob_bytes[0], 0x32,
|
|
"blob_bytes should start with 0x32 (reversed)"
|
|
);
|
|
assert_eq!(raw_bytes[31], 0x32, "raw_bytes should end with 0x32");
|
|
assert_eq!(
|
|
blob_bytes[31], 0x01,
|
|
"blob_bytes should end with 0x01 (reversed)"
|
|
);
|
|
}
|
|
|
|
/// Proves that SQL hex(blob) would produce wrong format compared to to_hex().
|
|
#[test]
|
|
fn test_sql_hex_vs_to_hex() {
|
|
let display_hex = "0102030405060708091011121314151617181920212223242526272829303132";
|
|
let token = display_hex.parse::<TokenID>().unwrap();
|
|
let blob = token.to_blob();
|
|
|
|
// Simulate what SQL hex() does: just uppercase hex of raw bytes (no reversal)
|
|
let sql_hex_output = hex::encode(&blob).to_lowercase();
|
|
|
|
// SQL hex() output is NOT what users expect
|
|
assert_ne!(
|
|
sql_hex_output, display_hex,
|
|
"SQL hex() should NOT match display format"
|
|
);
|
|
|
|
// Correct way: parse blob back to TokenID and use to_hex()
|
|
let recovered = TokenID::from_blob(&blob).unwrap();
|
|
assert_eq!(
|
|
recovered.to_string(),
|
|
display_hex,
|
|
"to_hex() should match original display format"
|
|
);
|
|
}
|
|
|
|
/// Test the helper function display_hex_to_blob
|
|
#[test]
|
|
fn test_display_hex_to_blob_helper() {
|
|
let display_hex = "0102030405060708091011121314151617181920212223242526272829303132";
|
|
|
|
// Helper should produce same result as manual from_hex + to_blob
|
|
let helper_result = display_hex_to_blob::<TokenID>(display_hex).unwrap();
|
|
let manual_result = display_hex.parse::<TokenID>().unwrap().to_blob();
|
|
|
|
assert_eq!(helper_result, manual_result);
|
|
|
|
// And it should NOT equal raw hex::decode
|
|
let raw_decode = hex::decode(display_hex).unwrap();
|
|
assert_ne!(helper_result, raw_decode);
|
|
}
|
|
|
|
/// Test the helper function blob_to_display_hex
|
|
#[test]
|
|
fn test_blob_to_display_hex_helper() {
|
|
let display_hex = "0102030405060708091011121314151617181920212223242526272829303132";
|
|
let token = display_hex.parse::<TokenID>().unwrap();
|
|
let blob = token.to_blob();
|
|
|
|
// Helper should produce correct display format
|
|
let helper_result = blob_to_display_hex::<TokenID>(&blob).unwrap();
|
|
assert_eq!(helper_result, display_hex);
|
|
|
|
// Raw hex::encode should NOT match
|
|
let raw_encode = hex::encode(&blob);
|
|
assert_ne!(raw_encode, display_hex);
|
|
}
|
|
|
|
/// Confirm PubkeyHash does NOT reverse bytes (it's the exception).
|
|
#[test]
|
|
fn test_pubkeyhash_does_not_reverse() {
|
|
let hex_str = "0102030405060708091011121314151617181920";
|
|
|
|
// For PubkeyHash, raw hex::decode SHOULD equal to_blob
|
|
let raw_bytes = hex::decode(hex_str).unwrap();
|
|
let pkh = hex_str.parse::<PubkeyHash>().unwrap();
|
|
let blob_bytes = pkh.to_blob();
|
|
|
|
assert_eq!(raw_bytes, blob_bytes, "PubkeyHash should NOT reverse bytes");
|
|
}
|
|
|
|
/// Test all Bitcoin hash types that DO reverse bytes.
|
|
#[test]
|
|
fn test_all_reversed_types() {
|
|
let display_hex = "0102030405060708091011121314151617181920212223242526272829303132";
|
|
let raw_bytes = hex::decode(display_hex).unwrap();
|
|
|
|
// Txid reverses
|
|
let txid_blob = display_hex.parse::<Txid>().unwrap().to_blob();
|
|
assert_ne!(raw_bytes, txid_blob, "Txid should reverse");
|
|
|
|
// BlockHash reverses
|
|
let blockhash_blob = display_hex.parse::<BlockHash>().unwrap().to_blob();
|
|
assert_ne!(raw_bytes, blockhash_blob, "BlockHash should reverse");
|
|
|
|
// TokenID reverses
|
|
let tokenid_blob = display_hex.parse::<TokenID>().unwrap().to_blob();
|
|
assert_ne!(raw_bytes, tokenid_blob, "TokenID should reverse");
|
|
|
|
// OutPointHash reverses
|
|
let outpoint_blob = display_hex.parse::<OutPointHash>().unwrap().to_blob();
|
|
assert_ne!(raw_bytes, outpoint_blob, "OutPointHash should reverse");
|
|
|
|
// PoolID reverses
|
|
let poolid_blob = display_hex.parse::<PoolID>().unwrap().to_blob();
|
|
assert_ne!(raw_bytes, poolid_blob, "PoolID should reverse");
|
|
}
|
|
}
|