fix(brc20): verify ordinal transfers in chunks instead of individually (#394)

* chore: group transfers

* fix: finish integration

* fix: chunk query

* chunk size

* test: indexing

* fix: comments
This commit is contained in:
Rafael Cárdenas
2025-02-05 13:14:41 -06:00
committed by GitHub
parent 51d3a76272
commit fe842e2e77
7 changed files with 737 additions and 375 deletions

View File

@@ -7,6 +7,11 @@ pub use tokio_postgres;
use tokio_postgres::{Client, Config, NoTls, Row};
/// Standard chunk size to use when we're batching multiple query inserts into a single SQL statement to save on DB round trips.
/// This number is designed to not hit the postgres limit of 65536 query parameters in a single SQL statement, but results may
/// vary depending on column counts. Queries should use other custom chunk sizes as needed.
pub const BATCH_QUERY_CHUNK_SIZE: usize = 500;
/// A Postgres configuration for a single database.
#[derive(Clone, Debug)]
pub struct PgConnectionConfig {

View File

@@ -4,7 +4,7 @@ use chainhook_postgres::{
deadpool_postgres::GenericClient,
tokio_postgres::{types::ToSql, Client},
types::{PgNumericU128, PgNumericU64},
utils, FromPgRow,
utils, FromPgRow, BATCH_QUERY_CHUNK_SIZE,
};
use chainhook_sdk::types::{
BitcoinBlockData, Brc20BalanceData, Brc20Operation, Brc20TokenDeployData, Brc20TransferData,
@@ -79,24 +79,43 @@ pub async fn get_token_available_balance_for_address<T: GenericClient>(
Ok(Some(supply.0))
}
pub async fn get_unsent_token_transfer<T: GenericClient>(
ordinal_number: u64,
pub async fn get_unsent_token_transfers<T: GenericClient>(
ordinal_numbers: &Vec<u64>,
client: &T,
) -> Result<Option<DbOperation>, String> {
let row = client
.query_opt(
"SELECT * FROM operations
WHERE ordinal_number = $1 AND operation = 'transfer'
AND NOT EXISTS (SELECT 1 FROM operations WHERE ordinal_number = $1 AND operation = 'transfer_send')
LIMIT 1",
&[&PgNumericU64(ordinal_number)],
)
.await
.map_err(|e| format!("get_unsent_token_transfer: {e}"))?;
let Some(row) = row else {
return Ok(None);
};
Ok(Some(DbOperation::from_pg_row(&row)))
) -> Result<Vec<DbOperation>, String> {
if ordinal_numbers.is_empty() {
return Ok(vec![]);
}
let mut results = vec![];
// We can afford a larger chunk size here because we're only using one parameter per ordinal number value.
for chunk in ordinal_numbers.chunks(5000) {
let mut wrapped = Vec::with_capacity(chunk.len());
for n in chunk {
wrapped.push(PgNumericU64(*n));
}
let mut params = vec![];
for number in wrapped.iter() {
params.push(number);
}
let rows = client
.query(
"SELECT *
FROM operations o
WHERE operation = 'transfer'
AND o.ordinal_number = ANY($1)
AND NOT EXISTS (
SELECT 1 FROM operations
WHERE ordinal_number = o.ordinal_number
AND operation = 'transfer_send'
)
LIMIT 1",
&[&params],
)
.await
.map_err(|e| format!("get_unsent_token_transfers: {e}"))?;
results.extend(rows.iter().map(|row| DbOperation::from_pg_row(row)));
}
Ok(results)
}
pub async fn insert_tokens<T: GenericClient>(
@@ -106,7 +125,7 @@ pub async fn insert_tokens<T: GenericClient>(
if tokens.len() == 0 {
return Ok(());
}
for chunk in tokens.chunks(500) {
for chunk in tokens.chunks(BATCH_QUERY_CHUNK_SIZE) {
let mut params: Vec<&(dyn ToSql + Sync)> = vec![];
for row in chunk.iter() {
params.push(&row.ticker);
@@ -148,7 +167,7 @@ pub async fn insert_operations<T: GenericClient>(
if operations.len() == 0 {
return Ok(());
}
for chunk in operations.chunks(500) {
for chunk in operations.chunks(BATCH_QUERY_CHUNK_SIZE) {
let mut params: Vec<&(dyn ToSql + Sync)> = vec![];
for row in chunk.iter() {
params.push(&row.ticker);
@@ -253,7 +272,11 @@ pub async fn update_address_operation_counts<T: GenericClient>(
if counts.len() == 0 {
return Ok(());
}
for chunk in counts.keys().collect::<Vec<&String>>().chunks(500) {
for chunk in counts
.keys()
.collect::<Vec<&String>>()
.chunks(BATCH_QUERY_CHUNK_SIZE)
{
let mut params: Vec<&(dyn ToSql + Sync)> = vec![];
let mut insert_rows = 0;
for address in chunk {
@@ -287,7 +310,11 @@ pub async fn update_token_operation_counts<T: GenericClient>(
if counts.len() == 0 {
return Ok(());
}
for chunk in counts.keys().collect::<Vec<&String>>().chunks(500) {
for chunk in counts
.keys()
.collect::<Vec<&String>>()
.chunks(BATCH_QUERY_CHUNK_SIZE)
{
let mut converted = HashMap::new();
for tick in chunk {
converted.insert(*tick, counts.get(*tick).unwrap().to_string());
@@ -324,7 +351,11 @@ pub async fn update_token_minted_supplies<T: GenericClient>(
if supplies.len() == 0 {
return Ok(());
}
for chunk in supplies.keys().collect::<Vec<&String>>().chunks(500) {
for chunk in supplies
.keys()
.collect::<Vec<&String>>()
.chunks(BATCH_QUERY_CHUNK_SIZE)
{
let mut converted = HashMap::new();
for tick in chunk {
converted.insert(*tick, supplies.get(*tick).unwrap().0.to_string());

View File

@@ -1,4 +1,7 @@
use std::{collections::HashMap, num::NonZeroUsize};
use std::{
collections::{HashMap, HashSet},
num::NonZeroUsize,
};
use chainhook_postgres::{
deadpool_postgres::GenericClient,
@@ -146,30 +149,44 @@ impl Brc20MemoryCache {
return Ok(None);
}
pub async fn get_unsent_token_transfer<T: GenericClient>(
pub async fn get_unsent_token_transfers<T: GenericClient>(
&mut self,
ordinal_number: u64,
ordinal_numbers: &Vec<&u64>,
client: &T,
) -> Result<Option<DbOperation>, String> {
// Use `get` instead of `contains` so we promote this value in the LRU.
if let Some(_) = self.ignored_inscriptions.get(&ordinal_number) {
return Ok(None);
}
if let Some(row) = self.unsent_transfers.get(&ordinal_number) {
return Ok(Some(row.clone()));
}
self.handle_cache_miss(client).await?;
match brc20_pg::get_unsent_token_transfer(ordinal_number, client).await? {
Some(row) => {
self.unsent_transfers.put(ordinal_number, row.clone());
return Ok(Some(row));
) -> Result<Vec<DbOperation>, String> {
let mut results = vec![];
let mut cache_missed_ordinal_numbers = HashSet::new();
for ordinal_number in ordinal_numbers.iter() {
// Use `get` instead of `contains` so we promote this value in the LRU.
if let Some(_) = self.ignored_inscriptions.get(*ordinal_number) {
continue;
}
None => {
// Inscription is not relevant for BRC20.
self.ignore_inscription(ordinal_number);
return Ok(None);
if let Some(row) = self.unsent_transfers.get(*ordinal_number) {
results.push(row.clone());
} else {
cache_missed_ordinal_numbers.insert(**ordinal_number);
}
}
if !cache_missed_ordinal_numbers.is_empty() {
// Some ordinal numbers were not in cache, check DB.
self.handle_cache_miss(client).await?;
let pending_transfers = brc20_pg::get_unsent_token_transfers(
&cache_missed_ordinal_numbers.iter().cloned().collect(),
client,
)
.await?;
for unsent_transfer in pending_transfers.into_iter() {
cache_missed_ordinal_numbers.remove(&unsent_transfer.ordinal_number.0);
self.unsent_transfers
.put(unsent_transfer.ordinal_number.0, unsent_transfer.clone());
results.push(unsent_transfer);
}
// Ignore all irrelevant numbers.
for irrelevant_number in cache_missed_ordinal_numbers.iter() {
self.ignore_inscription(*irrelevant_number);
}
}
return Ok(results);
}
/// Marks an ordinal number as ignored so we don't bother computing its transfers for BRC20 purposes.
@@ -456,12 +473,12 @@ impl Brc20MemoryCache {
return Ok(transfer.clone());
}
self.handle_cache_miss(client).await?;
let Some(transfer) = brc20_pg::get_unsent_token_transfer(ordinal_number, client).await?
else {
let transfers = brc20_pg::get_unsent_token_transfers(&vec![ordinal_number], client).await?;
let Some(transfer) = transfers.first() else {
unreachable!("Invalid transfer ordinal number {}", ordinal_number)
};
self.unsent_transfers.put(ordinal_number, transfer.clone());
return Ok(transfer);
return Ok(transfer.clone());
}
async fn handle_cache_miss<T: GenericClient>(&mut self, client: &T) -> Result<(), String> {

View File

@@ -3,8 +3,8 @@ use std::collections::HashMap;
use chainhook_postgres::deadpool_postgres::Transaction;
use chainhook_sdk::{
types::{
BitcoinBlockData, Brc20BalanceData, Brc20Operation, Brc20TokenDeployData,
Brc20TransferData, OrdinalOperation,
BitcoinBlockData, BlockIdentifier, Brc20BalanceData, Brc20Operation, Brc20TokenDeployData,
Brc20TransferData, OrdinalInscriptionTransferData, OrdinalOperation, TransactionIdentifier,
},
utils::Context,
};
@@ -15,10 +15,66 @@ use super::{
brc20_activation_height,
cache::Brc20MemoryCache,
parser::ParsedBrc20Operation,
verifier::{verify_brc20_operation, verify_brc20_transfer, VerifiedBrc20Operation},
verifier::{verify_brc20_operation, verify_brc20_transfers, VerifiedBrc20Operation},
};
/// Indexes BRC-20 operations in a Bitcoin block. Also writes the indexed data to DB.
/// Index ordinal transfers in a single Bitcoin block looking for BRC-20 transfers.
async fn index_unverified_brc20_transfers(
transfers: &Vec<(&TransactionIdentifier, &OrdinalInscriptionTransferData)>,
block_identifier: &BlockIdentifier,
timestamp: u32,
brc20_cache: &mut Brc20MemoryCache,
brc20_db_tx: &Transaction<'_>,
ctx: &Context,
) -> Result<Vec<(usize, Brc20Operation)>, String> {
if transfers.is_empty() {
return Ok(vec![]);
}
let mut results = vec![];
let mut verified_brc20_transfers =
verify_brc20_transfers(transfers, brc20_cache, &brc20_db_tx, &ctx).await?;
// Sort verified transfers by tx_index to make sure they are applied in the order they came through.
verified_brc20_transfers.sort_by(|a, b| a.2.tx_index.cmp(&b.2.tx_index));
for (inscription_id, data, transfer, tx_identifier) in verified_brc20_transfers.into_iter() {
let Some(token) = brc20_cache.get_token(&data.tick, brc20_db_tx).await? else {
unreachable!();
};
results.push((
transfer.tx_index,
Brc20Operation::TransferSend(Brc20TransferData {
tick: data.tick.clone(),
amt: u128_amount_to_decimals_str(data.amt, token.decimals.0),
sender_address: data.sender_address.clone(),
receiver_address: data.receiver_address.clone(),
inscription_id,
}),
));
brc20_cache
.insert_token_transfer_send(
&data,
&transfer,
block_identifier,
timestamp,
&tx_identifier,
transfer.tx_index as u64,
brc20_db_tx,
)
.await?;
try_info!(
ctx,
"BRC-20 transfer_send {} {} ({} -> {}) at block {}",
data.tick,
data.amt,
data.sender_address,
data.receiver_address,
block_identifier.index
);
}
Ok(results)
}
/// Indexes BRC-20 operations in a single Bitcoin block. Also writes indexed data to DB.
pub async fn index_block_and_insert_brc20_operations(
block: &mut BitcoinBlockData,
brc20_operation_map: &mut HashMap<String, ParsedBrc20Operation>,
@@ -29,160 +85,95 @@ pub async fn index_block_and_insert_brc20_operations(
if block.block_identifier.index < brc20_activation_height(&block.metadata.network) {
return Ok(());
}
// Ordinal transfers may be BRC-20 transfers. We group them into a vector to minimize round trips to the db when analyzing
// them. We will always insert them correctly in between new BRC-20 operations.
let mut unverified_ordinal_transfers = vec![];
let mut verified_brc20_transfers = vec![];
// Check every transaction in the block. Look for BRC-20 operations.
for (tx_index, tx) in block.transactions.iter_mut().enumerate() {
for op in tx.metadata.ordinal_operations.iter() {
match op {
OrdinalOperation::InscriptionRevealed(reveal) => {
if let Some(parsed_brc20_operation) =
let Some(parsed_brc20_operation) =
brc20_operation_map.get(&reveal.inscription_id)
{
match verify_brc20_operation(
parsed_brc20_operation,
reveal,
&block.block_identifier,
&block.metadata.network,
brc20_cache,
&brc20_db_tx,
&ctx,
)
.await?
{
Some(VerifiedBrc20Operation::TokenDeploy(token)) => {
tx.metadata.brc20_operation =
Some(Brc20Operation::Deploy(Brc20TokenDeployData {
tick: token.tick.clone(),
max: u128_amount_to_decimals_str(token.max, token.dec),
lim: u128_amount_to_decimals_str(token.lim, token.dec),
dec: token.dec.to_string(),
address: token.address.clone(),
inscription_id: reveal.inscription_id.clone(),
self_mint: token.self_mint,
}));
brc20_cache.insert_token_deploy(
&token,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
tx_index as u64,
)?;
try_info!(
ctx,
"BRC-20 deploy {} ({}) at block {}",
token.tick,
token.address,
block.block_identifier.index
);
}
Some(VerifiedBrc20Operation::TokenMint(balance)) => {
let Some(token) =
brc20_cache.get_token(&balance.tick, brc20_db_tx).await?
else {
unreachable!();
};
tx.metadata.brc20_operation =
Some(Brc20Operation::Mint(Brc20BalanceData {
tick: balance.tick.clone(),
amt: u128_amount_to_decimals_str(
balance.amt,
token.decimals.0,
),
address: balance.address.clone(),
inscription_id: reveal.inscription_id.clone(),
}));
brc20_cache
.insert_token_mint(
&balance,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
tx_index as u64,
brc20_db_tx,
)
.await?;
try_info!(
ctx,
"BRC-20 mint {} {} ({}) at block {}",
balance.tick,
balance.amt,
balance.address,
block.block_identifier.index
);
}
Some(VerifiedBrc20Operation::TokenTransfer(balance)) => {
let Some(token) =
brc20_cache.get_token(&balance.tick, brc20_db_tx).await?
else {
unreachable!();
};
tx.metadata.brc20_operation =
Some(Brc20Operation::Transfer(Brc20BalanceData {
tick: balance.tick.clone(),
amt: u128_amount_to_decimals_str(
balance.amt,
token.decimals.0,
),
address: balance.address.clone(),
inscription_id: reveal.inscription_id.clone(),
}));
brc20_cache
.insert_token_transfer(
&balance,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
tx_index as u64,
brc20_db_tx,
)
.await?;
try_info!(
ctx,
"BRC-20 transfer {} {} ({}) at block {}",
balance.tick,
balance.amt,
balance.address,
block.block_identifier.index
);
}
Some(VerifiedBrc20Operation::TokenTransferSend(_)) => {
unreachable!("BRC-20 token transfer send should never be generated on reveal")
}
None => {
brc20_cache.ignore_inscription(reveal.ordinal_number);
}
}
} else {
else {
brc20_cache.ignore_inscription(reveal.ordinal_number);
}
}
OrdinalOperation::InscriptionTransferred(transfer) => {
match verify_brc20_transfer(transfer, brc20_cache, &brc20_db_tx, &ctx).await? {
Some(data) => {
continue;
};
// First, verify any pending transfers as they may affect balances for the next operation.
verified_brc20_transfers.append(
&mut index_unverified_brc20_transfers(
&unverified_ordinal_transfers,
&block.block_identifier,
block.timestamp,
brc20_cache,
brc20_db_tx,
ctx,
)
.await?,
);
unverified_ordinal_transfers.clear();
// Then continue with the new operation.
let Some(operation) = verify_brc20_operation(
parsed_brc20_operation,
reveal,
&block.block_identifier,
&block.metadata.network,
brc20_cache,
&brc20_db_tx,
&ctx,
)
.await?
else {
brc20_cache.ignore_inscription(reveal.ordinal_number);
continue;
};
match operation {
VerifiedBrc20Operation::TokenDeploy(token) => {
tx.metadata.brc20_operation =
Some(Brc20Operation::Deploy(Brc20TokenDeployData {
tick: token.tick.clone(),
max: u128_amount_to_decimals_str(token.max, token.dec),
lim: u128_amount_to_decimals_str(token.lim, token.dec),
dec: token.dec.to_string(),
address: token.address.clone(),
inscription_id: reveal.inscription_id.clone(),
self_mint: token.self_mint,
}));
brc20_cache.insert_token_deploy(
&token,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
tx_index as u64,
)?;
try_info!(
ctx,
"BRC-20 deploy {} ({}) at block {}",
token.tick,
token.address,
block.block_identifier.index
);
}
VerifiedBrc20Operation::TokenMint(balance) => {
let Some(token) =
brc20_cache.get_token(&data.tick, brc20_db_tx).await?
else {
unreachable!();
};
let Some(unsent_transfer) = brc20_cache
.get_unsent_token_transfer(transfer.ordinal_number, brc20_db_tx)
.await?
brc20_cache.get_token(&balance.tick, brc20_db_tx).await?
else {
unreachable!();
};
tx.metadata.brc20_operation =
Some(Brc20Operation::TransferSend(Brc20TransferData {
tick: data.tick.clone(),
amt: u128_amount_to_decimals_str(data.amt, token.decimals.0),
sender_address: data.sender_address.clone(),
receiver_address: data.receiver_address.clone(),
inscription_id: unsent_transfer.inscription_id,
Some(Brc20Operation::Mint(Brc20BalanceData {
tick: balance.tick.clone(),
amt: u128_amount_to_decimals_str(balance.amt, token.decimals.0),
address: balance.address.clone(),
inscription_id: reveal.inscription_id.clone(),
}));
brc20_cache
.insert_token_transfer_send(
&data,
&transfer,
.insert_token_mint(
&balance,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
@@ -192,20 +183,293 @@ pub async fn index_block_and_insert_brc20_operations(
.await?;
try_info!(
ctx,
"BRC-20 transfer_send {} {} ({} -> {}) at block {}",
data.tick,
data.amt,
data.sender_address,
data.receiver_address,
"BRC-20 mint {} {} ({}) at block {}",
balance.tick,
balance.amt,
balance.address,
block.block_identifier.index
);
}
_ => {}
VerifiedBrc20Operation::TokenTransfer(balance) => {
let Some(token) =
brc20_cache.get_token(&balance.tick, brc20_db_tx).await?
else {
unreachable!();
};
tx.metadata.brc20_operation =
Some(Brc20Operation::Transfer(Brc20BalanceData {
tick: balance.tick.clone(),
amt: u128_amount_to_decimals_str(balance.amt, token.decimals.0),
address: balance.address.clone(),
inscription_id: reveal.inscription_id.clone(),
}));
brc20_cache
.insert_token_transfer(
&balance,
reveal,
&block.block_identifier,
block.timestamp,
&tx.transaction_identifier,
tx_index as u64,
brc20_db_tx,
)
.await?;
try_info!(
ctx,
"BRC-20 transfer {} {} ({}) at block {}",
balance.tick,
balance.amt,
balance.address,
block.block_identifier.index
);
}
VerifiedBrc20Operation::TokenTransferSend(_) => {
unreachable!(
"BRC-20 token transfer send should never be generated on reveal"
)
}
}
}
OrdinalOperation::InscriptionTransferred(transfer) => {
unverified_ordinal_transfers.push((&tx.transaction_identifier, transfer));
}
}
}
}
// Verify any dangling ordinal transfers and augment these results back to the block.
verified_brc20_transfers.append(
&mut index_unverified_brc20_transfers(
&unverified_ordinal_transfers,
&block.block_identifier,
block.timestamp,
brc20_cache,
brc20_db_tx,
ctx,
)
.await?,
);
for (tx_index, verified_transfer) in verified_brc20_transfers.into_iter() {
block
.transactions
.get_mut(tx_index)
.unwrap()
.metadata
.brc20_operation = Some(verified_transfer);
}
// Write all changes to DB.
brc20_cache.db_cache.flush(brc20_db_tx).await?;
Ok(())
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use chainhook_postgres::{pg_begin, pg_pool_client};
use chainhook_sdk::types::{
Brc20BalanceData, Brc20Operation, Brc20TokenDeployData, Brc20TransferData,
OrdinalInscriptionTransferDestination, OrdinalOperation,
};
use crate::{
core::{
meta_protocols::brc20::{
brc20_pg,
cache::Brc20MemoryCache,
index::index_block_and_insert_brc20_operations,
parser::{
ParsedBrc20BalanceData, ParsedBrc20Operation, ParsedBrc20TokenDeployData,
},
test_utils::{get_test_ctx, Brc20RevealBuilder, Brc20TransferBuilder},
},
test_builders::{TestBlockBuilder, TestTransactionBuilder},
},
db::{pg_test_clear_db, pg_test_connection, pg_test_connection_pool},
};
#[tokio::test]
async fn test_full_block_indexing() -> Result<(), String> {
let ctx = get_test_ctx();
let mut pg_client = pg_test_connection().await;
let _ = brc20_pg::migrate(&mut pg_client).await;
let result = {
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
// Deploy a token, mint and transfer some balance.
let mut operation_map: HashMap<String, ParsedBrc20Operation> = HashMap::new();
operation_map.insert(
"01d6876703d25747bf5767f3d830548ebe09ffcade91d49e558eb9b6fd2d6d56i0".to_string(),
ParsedBrc20Operation::Deploy(ParsedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: "100".to_string(),
lim: "1".to_string(),
dec: "0".to_string(),
self_mint: false,
}),
);
operation_map.insert(
"2e72578e1259b7dab363cb422ae1979ea329ffc0978c4a7552af907238db354ci0".to_string(),
ParsedBrc20Operation::Mint(ParsedBrc20BalanceData {
tick: "pepe".to_string(),
amt: "1".to_string(),
}),
);
operation_map.insert(
"a8494261df7d4980af988dfc0241bb7ec95051afdbb86e3bea9c3ab055e898f3i0".to_string(),
ParsedBrc20Operation::Transfer(ParsedBrc20BalanceData {
tick: "pepe".to_string(),
amt: "1".to_string(),
}),
);
let mut block = TestBlockBuilder::new()
.hash(
"00000000000000000000a646fc25f31be344cab3e6e31ec26010c40173ad4bd3".to_string(),
)
.height(818000)
.add_transaction(
TestTransactionBuilder::new()
.add_ordinal_operation(OrdinalOperation::InscriptionRevealed(
Brc20RevealBuilder::new()
.inscription_number(0)
.ordinal_number(100)
.inscription_id("01d6876703d25747bf5767f3d830548ebe09ffcade91d49e558eb9b6fd2d6d56i0")
.inscriber_address(Some("19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string()))
.build(),
))
.build(),
)
.add_transaction(
TestTransactionBuilder::new()
.add_ordinal_operation(OrdinalOperation::InscriptionRevealed(
Brc20RevealBuilder::new()
.inscription_number(1)
.ordinal_number(200)
.inscription_id("2e72578e1259b7dab363cb422ae1979ea329ffc0978c4a7552af907238db354ci0")
.inscriber_address(Some("19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string()))
.build(),
))
.build(),
)
.add_transaction(
TestTransactionBuilder::new()
.add_ordinal_operation(OrdinalOperation::InscriptionRevealed(
Brc20RevealBuilder::new()
.inscription_number(2)
.ordinal_number(300)
.inscription_id("a8494261df7d4980af988dfc0241bb7ec95051afdbb86e3bea9c3ab055e898f3i0")
.inscriber_address(Some("19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string()))
.build(),
))
.build(),
)
.add_transaction(
TestTransactionBuilder::new()
.add_ordinal_operation(OrdinalOperation::InscriptionTransferred(
Brc20TransferBuilder::new()
.tx_index(3)
.ordinal_number(300)
.destination(
OrdinalInscriptionTransferDestination::Transferred("3Ezed1AvfdnXFTMZqhMdhdq9hBMTqfx8Yz".to_string()
))
.build()
))
.build(),
)
.build();
let mut cache = Brc20MemoryCache::new(10);
let result = index_block_and_insert_brc20_operations(
&mut block,
&mut operation_map,
&mut cache,
&client,
&ctx,
)
.await;
assert_eq!(
block
.transactions
.get(0)
.unwrap()
.metadata
.brc20_operation
.as_ref()
.unwrap(),
&Brc20Operation::Deploy(Brc20TokenDeployData {
tick: "pepe".to_string(),
max: "100".to_string(),
lim: "1".to_string(),
dec: "0".to_string(),
self_mint: false,
address: "19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string(),
inscription_id:
"01d6876703d25747bf5767f3d830548ebe09ffcade91d49e558eb9b6fd2d6d56i0"
.to_string(),
})
);
assert_eq!(
block
.transactions
.get(1)
.unwrap()
.metadata
.brc20_operation
.as_ref()
.unwrap(),
&Brc20Operation::Mint(Brc20BalanceData {
tick: "pepe".to_string(),
amt: "1".to_string(),
address: "19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string(),
inscription_id:
"2e72578e1259b7dab363cb422ae1979ea329ffc0978c4a7552af907238db354ci0"
.to_string()
})
);
assert_eq!(
block
.transactions
.get(2)
.unwrap()
.metadata
.brc20_operation
.as_ref()
.unwrap(),
&Brc20Operation::Transfer(Brc20BalanceData {
tick: "pepe".to_string(),
amt: "1".to_string(),
address: "19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string(),
inscription_id:
"a8494261df7d4980af988dfc0241bb7ec95051afdbb86e3bea9c3ab055e898f3i0"
.to_string()
})
);
assert_eq!(
block
.transactions
.get(3)
.unwrap()
.metadata
.brc20_operation
.as_ref()
.unwrap(),
&Brc20Operation::TransferSend(Brc20TransferData {
tick: "pepe".to_string(),
amt: "1".to_string(),
sender_address: "19PFYXeUuArA3vRDHh2zz8tupAYNFqjBCP".to_string(),
receiver_address: "3Ezed1AvfdnXFTMZqhMdhdq9hBMTqfx8Yz".to_string(),
inscription_id:
"a8494261df7d4980af988dfc0241bb7ec95051afdbb86e3bea9c3ab055e898f3i0"
.to_string()
})
);
result
};
pg_test_clear_db(&mut pg_client).await;
result
}
}

View File

@@ -53,6 +53,9 @@ pub fn decimals_str_amount_to_u128(amt: &String, decimals: u8) -> Result<u128, S
/// Transform a BRC-20 amount which was stored in Postgres as a `u128` back to a `String` with decimals included.
pub fn u128_amount_to_decimals_str(amount: u128, decimals: u8) -> String {
let num_str = amount.to_string();
if decimals == 0 {
return num_str;
}
let decimal_point = num_str.len() as i32 - decimals as i32;
if decimal_point < 0 {
let padding = "0".repeat(decimal_point.abs() as usize);

View File

@@ -98,6 +98,7 @@ pub struct Brc20TransferBuilder {
pub ordinal_number: u64,
pub destination: OrdinalInscriptionTransferDestination,
pub satpoint_post_transfer: String,
pub tx_index: usize,
}
impl Brc20TransferBuilder {
@@ -107,7 +108,8 @@ impl Brc20TransferBuilder {
destination: OrdinalInscriptionTransferDestination::Transferred(
"bc1pls75sfwullhygkmqap344f5cqf97qz95lvle6fvddm0tpz2l5ffslgq3m0".to_string(),
),
satpoint_post_transfer: "e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065d:0:0".to_string()
satpoint_post_transfer: "e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065d:0:0".to_string(),
tx_index: 0,
}
}
@@ -121,6 +123,11 @@ impl Brc20TransferBuilder {
self
}
pub fn tx_index(mut self, val: usize) -> Self {
self.tx_index = val;
self
}
pub fn build(self) -> OrdinalInscriptionTransferData {
OrdinalInscriptionTransferData {
ordinal_number: self.ordinal_number,
@@ -128,7 +135,7 @@ impl Brc20TransferBuilder {
satpoint_pre_transfer: "".to_string(),
satpoint_post_transfer: self.satpoint_post_transfer,
post_transfer_output_value: Some(500),
tx_index: 0,
tx_index: self.tx_index,
}
}
}

View File

@@ -1,7 +1,9 @@
use std::collections::HashMap;
use chainhook_postgres::deadpool_postgres::Transaction;
use chainhook_sdk::types::{
BitcoinNetwork, BlockIdentifier, OrdinalInscriptionRevealData, OrdinalInscriptionTransferData,
OrdinalInscriptionTransferDestination,
OrdinalInscriptionTransferDestination, TransactionIdentifier,
};
use chainhook_sdk::utils::Context;
@@ -214,49 +216,73 @@ pub async fn verify_brc20_operation(
};
}
pub async fn verify_brc20_transfer(
transfer: &OrdinalInscriptionTransferData,
/// Given a list of ordinal transfers, verify which of them are valid `transfer_send` BRC-20 operations we haven't yet processed.
/// Return verified transfer data for each valid operation.
pub async fn verify_brc20_transfers(
transfers: &Vec<(&TransactionIdentifier, &OrdinalInscriptionTransferData)>,
cache: &mut Brc20MemoryCache,
db_tx: &Transaction<'_>,
ctx: &Context,
) -> Result<Option<VerifiedBrc20TransferData>, String> {
let Some(transfer_row) = cache
.get_unsent_token_transfer(transfer.ordinal_number, db_tx)
.await?
else {
try_debug!(
ctx,
"BRC-20: No BRC-20 transfer in ordinal {} or transfer already sent",
transfer.ordinal_number
);
return Ok(None);
};
match &transfer.destination {
OrdinalInscriptionTransferDestination::Transferred(receiver_address) => {
return Ok(Some(VerifiedBrc20TransferData {
tick: transfer_row.ticker.clone(),
amt: transfer_row.amount.0,
sender_address: transfer_row.address.clone(),
receiver_address: receiver_address.to_string(),
}));
) -> Result<
Vec<(
String,
VerifiedBrc20TransferData,
OrdinalInscriptionTransferData,
TransactionIdentifier,
)>,
String,
> {
try_debug!(ctx, "BRC-20 verifying {} ordinal transfers", transfers.len());
// Select ordinal numbers to analyze for pending BRC20 transfers.
let mut ordinal_numbers = vec![];
let mut candidate_transfers = HashMap::new();
for (tx_identifier, data) in transfers.iter() {
if !candidate_transfers.contains_key(&data.ordinal_number) {
ordinal_numbers.push(&data.ordinal_number);
candidate_transfers.insert(&data.ordinal_number, (*tx_identifier, *data));
}
OrdinalInscriptionTransferDestination::SpentInFees => {
return Ok(Some(VerifiedBrc20TransferData {
tick: transfer_row.ticker.clone(),
amt: transfer_row.amount.0,
sender_address: transfer_row.address.clone(),
receiver_address: transfer_row.address.clone(), // Return to sender
}));
}
OrdinalInscriptionTransferDestination::Burnt(_) => {
return Ok(Some(VerifiedBrc20TransferData {
}
// Check cache for said transfers.
let db_operations = cache
.get_unsent_token_transfers(&ordinal_numbers, db_tx)
.await?;
if db_operations.is_empty() {
return Ok(vec![]);
}
// Return any resulting `transfer_send` operations.
let mut results = vec![];
for transfer_row in db_operations.into_iter() {
let (tx_identifier, data) = candidate_transfers
.get(&transfer_row.ordinal_number.0)
.unwrap();
let verified = match &data.destination {
OrdinalInscriptionTransferDestination::Transferred(receiver_address) => {
VerifiedBrc20TransferData {
tick: transfer_row.ticker.clone(),
amt: transfer_row.amount.0,
sender_address: transfer_row.address.clone(),
receiver_address: receiver_address.to_string(),
}
}
OrdinalInscriptionTransferDestination::SpentInFees => {
VerifiedBrc20TransferData {
tick: transfer_row.ticker.clone(),
amt: transfer_row.amount.0,
sender_address: transfer_row.address.clone(),
receiver_address: transfer_row.address.clone(), // Return to sender
}
}
OrdinalInscriptionTransferDestination::Burnt(_) => VerifiedBrc20TransferData {
tick: transfer_row.ticker.clone(),
amt: transfer_row.amount.0,
sender_address: transfer_row.address.clone(),
receiver_address: "".to_string(),
}));
}
};
},
};
results.push((transfer_row.inscription_id, verified, (*data).clone(), (*tx_identifier).clone()));
}
return Ok(results);
}
#[cfg(test)]
@@ -282,7 +308,7 @@ mod test {
db::{pg_test_clear_db, pg_test_connection, pg_test_connection_pool},
};
use super::{verify_brc20_operation, verify_brc20_transfer, VerifiedBrc20TransferData};
use super::{verify_brc20_operation, verify_brc20_transfers, VerifiedBrc20TransferData};
#[test_case(
ParsedBrc20Operation::Deploy(ParsedBrc20TokenDeployData {
@@ -863,38 +889,39 @@ mod test {
let ctx = get_test_ctx();
let mut pg_client = pg_test_connection().await;
let _ = brc20_pg::migrate(&mut pg_client).await;
let result = {
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let result =
{
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "8c8e37ce3ddd869767f8d839d16acc7ea4ec9dd7e3c73afd42a0abb859d7d391"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new().inscription_number(0).build(),
&block,
0,
&tx,
0,
)?;
// Mint from 2 addresses
cache.insert_token_mint(
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "8c8e37ce3ddd869767f8d839d16acc7ea4ec9dd7e3c73afd42a0abb859d7d391"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new().inscription_number(0).build(),
&block,
0,
&tx,
0,
)?;
// Mint from 2 addresses
cache.insert_token_mint(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 1000_000000000000000000,
@@ -912,7 +939,7 @@ mod test {
1,
&client
).await?;
cache.insert_token_mint(
cache.insert_token_mint(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 1000_000000000000000000,
@@ -930,17 +957,17 @@ mod test {
2,
&client
).await?;
verify_brc20_operation(
&op,
&reveal,
&block,
&BitcoinNetwork::Mainnet,
&mut cache,
&client,
&ctx,
)
.await
};
verify_brc20_operation(
&op,
&reveal,
&block,
&BitcoinNetwork::Mainnet,
&mut cache,
&client,
&ctx,
)
.await
};
pg_test_clear_db(&mut pg_client).await;
result
}
@@ -993,42 +1020,43 @@ mod test {
let ctx = get_test_ctx();
let mut pg_client = pg_test_connection().await;
let _ = brc20_pg::migrate(&mut pg_client).await;
let result = {
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let result =
{
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "8c8e37ce3ddd869767f8d839d16acc7ea4ec9dd7e3c73afd42a0abb859d7d391"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new()
.inscription_number(0)
.inscription_id(
"e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065di0",
)
.build(),
&block,
0,
&tx,
0,
)?;
cache.insert_token_mint(
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "8c8e37ce3ddd869767f8d839d16acc7ea4ec9dd7e3c73afd42a0abb859d7d391"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new()
.inscription_number(0)
.inscription_id(
"e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065di0",
)
.build(),
&block,
0,
&tx,
0,
)?;
cache.insert_token_mint(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 1000_000000000000000000,
@@ -1046,7 +1074,7 @@ mod test {
1,
&client
).await?;
cache.insert_token_transfer(
cache.insert_token_transfer(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 500_000000000000000000,
@@ -1065,10 +1093,13 @@ mod test {
2,
&client
).await?;
verify_brc20_transfer(&transfer, &mut cache, &client, &ctx).await
};
verify_brc20_transfers(&vec![(&tx, &transfer)], &mut cache, &client, &ctx).await?
};
pg_test_clear_db(&mut pg_client).await;
result
let Some(result) = result.first() else {
return Ok(None);
};
Ok(Some(result.1.clone()))
}
#[test_case(
@@ -1083,42 +1114,43 @@ mod test {
let ctx = get_test_ctx();
let mut pg_client = pg_test_connection().await;
let _ = brc20_pg::migrate(&mut pg_client).await;
let result = {
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let result =
{
let mut brc20_client = pg_pool_client(&pg_test_connection_pool()).await?;
let client = pg_begin(&mut brc20_client).await?;
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065d"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new()
.inscription_number(0)
.inscription_id(
"e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065di0",
)
.build(),
&block,
0,
&tx,
0,
)?;
cache.insert_token_mint(
let block = BlockIdentifier {
index: 835727,
hash: "00000000000000000002d8ba402150b259ddb2b30a1d32ab4a881d4653bceb5b"
.to_string(),
};
let tx = TransactionIdentifier {
hash: "e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065d"
.to_string(),
};
let mut cache = Brc20MemoryCache::new(10);
cache.insert_token_deploy(
&VerifiedBrc20TokenDeployData {
tick: "pepe".to_string(),
display_tick: "pepe".to_string(),
max: 21000000_000000000000000000,
lim: 1000_000000000000000000,
dec: 18,
address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
self_mint: false,
},
&Brc20RevealBuilder::new()
.inscription_number(0)
.inscription_id(
"e45957c419f130cd5c88cdac3eb1caf2d118aee20c17b15b74a611be395a065di0",
)
.build(),
&block,
0,
&tx,
0,
)?;
cache.insert_token_mint(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 1000_000000000000000000,
@@ -1136,7 +1168,7 @@ mod test {
1,
&client,
).await?;
cache.insert_token_transfer(
cache.insert_token_transfer(
&VerifiedBrc20BalanceData {
tick: "pepe".to_string(),
amt: 500_000000000000000000,
@@ -1155,27 +1187,30 @@ mod test {
2,
&client,
).await?;
cache
.insert_token_transfer_send(
&VerifiedBrc20TransferData {
tick: "pepe".to_string(),
amt: 500_000000000000000000,
sender_address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
receiver_address:
"bc1pls75sfwullhygkmqap344f5cqf97qz95lvle6fvddm0tpz2l5ffslgq3m0"
.to_string(),
},
&Brc20TransferBuilder::new().ordinal_number(5000).build(),
&block,
3,
&tx,
3,
&client,
)
.await?;
verify_brc20_transfer(&transfer, &mut cache, &client, &ctx).await
};
cache
.insert_token_transfer_send(
&VerifiedBrc20TransferData {
tick: "pepe".to_string(),
amt: 500_000000000000000000,
sender_address: "324A7GHA2azecbVBAFy4pzEhcPT1GjbUAp".to_string(),
receiver_address:
"bc1pls75sfwullhygkmqap344f5cqf97qz95lvle6fvddm0tpz2l5ffslgq3m0"
.to_string(),
},
&Brc20TransferBuilder::new().ordinal_number(5000).build(),
&block,
3,
&tx,
3,
&client,
)
.await?;
verify_brc20_transfers(&vec![(&tx, &transfer)], &mut cache, &client, &ctx).await?
};
pg_test_clear_db(&mut pg_client).await;
result
let Some(result) = result.first() else {
return Ok(None);
};
Ok(Some(result.1.clone()))
}
}