Skip to content

Commit

Permalink
fix: register receive operations correctly on edicts (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelcr authored Jul 6, 2024
1 parent 3221578 commit de48a59
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 319 deletions.
19 changes: 11 additions & 8 deletions api/src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,15 @@ export class PgStore extends BasePgStore {
cte?: PgSqlQuery
): Promise<DbPaginatedResult<DbItemWithRune<DbLedgerEntry>>> {
const results = await this.sql<DbCountedQueryResult<DbItemWithRune<DbLedgerEntry>>[]>`
${cte ? cte : this.sql``}
SELECT l.*, r.name, r.spaced_name, r.divisibility, ${count} AS total
FROM ledger AS l
INNER JOIN runes AS r ON r.id = l.rune_id
WHERE ${filter}
ORDER BY l.block_height DESC, l.tx_index DESC, l.event_index DESC
WITH ${cte ? cte : this.sql`none AS (SELECT NULL)`},
results AS (
SELECT l.*, r.name, r.spaced_name, r.divisibility, ${count} AS total
FROM ledger AS l
INNER JOIN runes AS r ON r.id = l.rune_id
WHERE ${filter}
)
SELECT * FROM results
ORDER BY block_height DESC, tx_index DESC, event_index DESC
OFFSET ${offset} LIMIT ${limit}
`;
return {
Expand All @@ -164,7 +167,7 @@ export class PgStore extends BasePgStore {
this.sql`COALESCE((SELECT total_operations FROM count), 0)`,
offset,
limit,
this.sql`WITH count AS (
this.sql`count AS (
SELECT total_operations FROM supply_changes
WHERE rune_id = (SELECT id FROM runes WHERE ${runeFilter(this.sql, runeId)})
ORDER BY block_height DESC LIMIT 1
Expand All @@ -187,7 +190,7 @@ export class PgStore extends BasePgStore {
this.sql`COALESCE((SELECT total_operations FROM count), 0)`,
offset,
limit,
this.sql`WITH recent AS (
this.sql`recent AS (
SELECT DISTINCT ON (rune_id) total_operations
FROM balance_changes
WHERE address = ${address}
Expand Down
2 changes: 1 addition & 1 deletion migrations/V1__runes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS runes (
id TEXT NOT NULL PRIMARY KEY,
number BIGINT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
spaced_name TEXT NOT NULL,
spaced_name TEXT NOT NULL UNIQUE,
block_hash TEXT NOT NULL,
block_height NUMERIC NOT NULL,
tx_index BIGINT NOT NULL,
Expand Down
314 changes: 6 additions & 308 deletions src/db/cache/transaction_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ use std::{
vec,
};

use bitcoin::{Address, Network, ScriptBuf};
use bitcoin::{Network, ScriptBuf};
use chainhook_sdk::{types::bitcoin::TxOut, utils::Context};
use ordinals::{Cenotaph, Edict, Etching, Rune, RuneId, Runestone};

use crate::{
db::models::{
db::{cache::utils::{is_rune_mintable, new_ledger_entry}, models::{
db_ledger_entry::DbLedgerEntry, db_ledger_operation::DbLedgerOperation, db_rune::DbRune,
},
}},
try_debug, try_info, try_warn,
};

use super::transaction_location::TransactionLocation;
use super::{transaction_location::TransactionLocation, utils::move_rune_balance_to_output};

#[derive(Debug, Clone)]
pub struct InputRuneBalance {
Expand Down Expand Up @@ -247,7 +247,7 @@ impl TransactionCache {
db_rune: &DbRune,
ctx: &Context,
) -> Option<DbLedgerEntry> {
if !is_valid_mint(db_rune, total_mints, &self.location) {
if !is_rune_mintable(db_rune, total_mints, &self.location) {
try_debug!(ctx, "Invalid mint {} {}", rune_id, self.location);
return None;
}
Expand Down Expand Up @@ -286,7 +286,7 @@ impl TransactionCache {
db_rune: &DbRune,
ctx: &Context,
) -> Option<DbLedgerEntry> {
if !is_valid_mint(db_rune, total_mints, &self.location) {
if !is_rune_mintable(db_rune, total_mints, &self.location) {
try_debug!(ctx, "Invalid mint {} {}", rune_id, self.location);
return None;
}
Expand Down Expand Up @@ -461,305 +461,3 @@ impl TransactionCache {
}
}
}

/// Determines if a mint is valid depending on the rune's mint terms.
fn is_valid_mint(db_rune: &DbRune, total_mints: u128, location: &TransactionLocation) -> bool {
if db_rune.terms_amount.is_none() {
return false;
}
if let Some(terms_cap) = db_rune.terms_cap {
if total_mints >= terms_cap.0 {
return false;
}
}
if let Some(terms_height_start) = db_rune.terms_height_start {
if location.block_height < terms_height_start.0 {
return false;
}
}
if let Some(terms_height_end) = db_rune.terms_height_end {
if location.block_height > terms_height_end.0 {
return false;
}
}
if let Some(terms_offset_start) = db_rune.terms_offset_start {
if location.block_height < db_rune.block_height.0 + terms_offset_start.0 {
return false;
}
}
if let Some(terms_offset_end) = db_rune.terms_offset_end {
if location.block_height > db_rune.block_height.0 + terms_offset_end.0 {
return false;
}
}
true
}

/// Creates a new ledger entry.
fn new_ledger_entry(
location: &TransactionLocation,
amount: Option<u128>,
rune_id: RuneId,
output: Option<u32>,
address: Option<&String>,
receiver_address: Option<&String>,
operation: DbLedgerOperation,
next_event_index: &mut u32,
) -> DbLedgerEntry {
let entry = DbLedgerEntry::from_values(
amount,
rune_id,
&location.block_hash,
location.block_height,
location.tx_index,
*next_event_index,
&location.tx_id,
output,
address,
receiver_address,
operation,
location.timestamp,
);
*next_event_index += 1;
entry
}

/// Takes `amount` rune balance from `available_inputs` and moves it to `output` by generating the correct ledger entries.
/// Modifies `available_inputs` to consume balance that is already moved. If `amount` is zero, all remaining balances will be
/// transferred. If `output` is `None`, the runes will be burnt.
fn move_rune_balance_to_output(
location: &TransactionLocation,
output: Option<u32>,
rune_id: &RuneId,
available_inputs: &mut VecDeque<InputRuneBalance>,
eligible_outputs: &HashMap<u32, ScriptBuf>,
amount: u128,
next_event_index: &mut u32,
ctx: &Context,
) -> Vec<DbLedgerEntry> {
let mut results = vec![];
// Who is this balance going to?
let receiver_address = if let Some(output) = output {
match eligible_outputs.get(&output) {
Some(script) => match Address::from_script(script, location.network) {
Ok(address) => Some(address.to_string()),
Err(e) => {
try_warn!(
ctx,
"Unable to decode address for output {}, {} {}",
output,
e,
location
);
None
}
},
None => {
try_info!(
ctx,
"Attempted move to non-eligible output {}, runes will be burnt {}",
output,
location
);
None
}
}
} else {
None
};
let operation = if receiver_address.is_some() {
DbLedgerOperation::Send
} else {
DbLedgerOperation::Burn
};

// Gather balance to be received by taking it from the available inputs until the amount to move is satisfied.
let mut total_sent = 0;
let mut senders = vec![];
loop {
let Some(input_bal) = available_inputs.pop_front() else {
// Unallocated balance ran out.
break;
};
let balance_taken = if amount == 0 {
input_bal.amount
} else {
input_bal.amount.min(amount - total_sent)
};
// Empty sender address means this balance was minted or premined, so we have no "send" entry to add.
if let Some(sender_address) = input_bal.address.clone() {
senders.push((balance_taken, sender_address));
}
if balance_taken < input_bal.amount {
// There's still some balance left on this input, keep it for later.
available_inputs.push_front(InputRuneBalance {
address: input_bal.address,
amount: input_bal.amount - balance_taken,
});
break;
}
total_sent += balance_taken;
if total_sent == amount {
break;
}
}
// Add the "receive" entry, if applicable.
if receiver_address.is_some() && total_sent > 0 {
results.push(new_ledger_entry(
location,
Some(total_sent),
*rune_id,
output,
receiver_address.as_ref(),
None,
DbLedgerOperation::Receive,
next_event_index,
));
try_info!(
ctx,
"{} {} ({}) {} {}",
DbLedgerOperation::Receive,
rune_id,
total_sent,
receiver_address.as_ref().unwrap(),
location
);
}
// Add the "send"/"burn" entries.
for (balance_taken, sender_address) in senders.iter() {
results.push(new_ledger_entry(
location,
Some(*balance_taken),
*rune_id,
output,
Some(sender_address),
receiver_address.as_ref(),
operation.clone(),
next_event_index,
));
try_info!(
ctx,
"{} {} ({}) {} -> {:?} {}",
operation,
rune_id,
balance_taken,
sender_address,
receiver_address,
location
);
}
results
}

#[cfg(test)]
mod test {
use std::collections::{HashMap, VecDeque};
use test_case::test_case;

use bitcoin::ScriptBuf;
use chainhook_sdk::utils::Context;
use ordinals::RuneId;

use crate::db::{
cache::transaction_location::TransactionLocation,
models::{db_ledger_operation::DbLedgerOperation, db_rune::DbRune},
types::{pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64},
};

use super::{is_valid_mint, move_rune_balance_to_output, InputRuneBalance};

#[test]
fn receives_are_registered_first() {
let ctx = Context::empty();
let location = TransactionLocation {
network: bitcoin::Network::Bitcoin,
block_hash: "00000000000000000002c0cc73626b56fb3ee1ce605b0ce125cc4fb58775a0a9"
.to_string(),
block_height: 840002,
timestamp: 0,
tx_id: "37cd29676d626492cd9f20c60bc4f20347af9c0d91b5689ed75c05bb3e2f73ef".to_string(),
tx_index: 2936,
};
let mut available_inputs = VecDeque::new();
// An input from a previous tx
available_inputs.push_back(InputRuneBalance {
address: Some(
"bc1p8zxlhgdsq6dmkzk4ammzcx55c3hfrg69ftx0gzlnfwq0wh38prds0nzqwf".to_string(),
),
amount: 1000,
});
// A mint
available_inputs.push_back(InputRuneBalance {
address: None,
amount: 1000,
});
let mut eligible_outputs = HashMap::new();
eligible_outputs.insert(
0u32,
ScriptBuf::from_hex(
"5120388dfba1b0069bbb0ad5eef62c1a94c46e91a3454accf40bf34b80f75e2708db",
)
.unwrap(),
);
let mut next_event_index = 0;
let results = move_rune_balance_to_output(
&location,
Some(0),
&RuneId::new(840000, 25).unwrap(),
&mut available_inputs,
&eligible_outputs,
0,
&mut next_event_index,
&ctx,
);

let receive = results.get(0).unwrap();
assert_eq!(receive.event_index.0, 0u32);
assert_eq!(receive.operation, DbLedgerOperation::Receive);
assert_eq!(receive.amount.unwrap().0, 2000u128);

let send = results.get(1).unwrap();
assert_eq!(send.event_index.0, 1u32);
assert_eq!(send.operation, DbLedgerOperation::Send);
assert_eq!(send.amount.unwrap().0, 1000u128);

assert_eq!(results.len(), 2);
}

#[test_case(840000 => false; "early block")]
#[test_case(840500 => false; "late block")]
#[test_case(840150 => true; "block in window")]
#[test_case(840100 => true; "first block")]
#[test_case(840200 => true; "last block")]
fn mint_block_height_terms_are_validated(block_height: u64) -> bool {
let mut rune = DbRune::factory();
rune.terms_height_start(Some(PgNumericU64(840100)));
rune.terms_height_end(Some(PgNumericU64(840200)));
let mut location = TransactionLocation::factory();
location.block_height(block_height);
is_valid_mint(&rune, 0, &location)
}

#[test_case(840000 => false; "early block")]
#[test_case(840500 => false; "late block")]
#[test_case(840150 => true; "block in window")]
#[test_case(840100 => true; "first block")]
#[test_case(840200 => true; "last block")]
fn mint_block_offset_terms_are_validated(block_height: u64) -> bool {
let mut rune = DbRune::factory();
rune.terms_offset_start(Some(PgNumericU64(100)));
rune.terms_offset_end(Some(PgNumericU64(200)));
let mut location = TransactionLocation::factory();
location.block_height(block_height);
is_valid_mint(&rune, 0, &location)
}

#[test_case(0 => true; "first mint")]
#[test_case(49 => true; "last mint")]
#[test_case(50 => false; "out of range")]
fn mint_cap_is_validated(cap: u128) -> bool {
let mut rune = DbRune::factory();
rune.terms_cap(Some(PgNumericU128(50)));
is_valid_mint(&rune, cap, &TransactionLocation::factory())
}
}
Loading

0 comments on commit de48a59

Please sign in to comment.