diff --git a/chronik/bitcoinsuite-slp/src/alp/group.rs b/chronik/bitcoinsuite-slp/src/alp/group.rs new file mode 100644 --- /dev/null +++ b/chronik/bitcoinsuite-slp/src/alp/group.rs @@ -0,0 +1,33 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +use bitcoinsuite_core::{ + bytes::{read_array, read_bytes}, + tx::TxId, +}; +use bytes::Bytes; + +use crate::{structs::GenesisInfo, token_id::TokenId}; + +const GROUP_PREFIX: &[u8] = b"GRP0"; + +/// Parse the `data` field of a tx as group. +/// The format is: "GRP0...". +/// After the group assignment, arbitrary data may follow. +pub fn parse_bytes_group(mut data: Bytes) -> Option { + let prefix = read_bytes(&mut data, GROUP_PREFIX.len()).ok()?; + if prefix.as_ref() != GROUP_PREFIX { + return None; + } + let group_token_id = read_array::<32>(&mut data).ok()?; + Some(TokenId::new(TxId::new(group_token_id))) +} + +/// Parse the `data` field of the given GenesisInfo as a group. +pub fn parse_genesis_info_group( + genesis_info: Option<&GenesisInfo>, +) -> Option { + let data = genesis_info?.data.clone()?; + parse_bytes_group(data) +} diff --git a/chronik/bitcoinsuite-slp/src/alp/mod.rs b/chronik/bitcoinsuite-slp/src/alp/mod.rs --- a/chronik/bitcoinsuite-slp/src/alp/mod.rs +++ b/chronik/bitcoinsuite-slp/src/alp/mod.rs @@ -9,7 +9,9 @@ mod build; pub mod consts; +mod group; mod parse; pub use crate::alp::build::*; +pub use crate::alp::group::*; pub use crate::alp::parse::*; diff --git a/chronik/bitcoinsuite-slp/src/verify.rs b/chronik/bitcoinsuite-slp/src/verify.rs --- a/chronik/bitcoinsuite-slp/src/verify.rs +++ b/chronik/bitcoinsuite-slp/src/verify.rs @@ -11,12 +11,12 @@ use thiserror::Error; use crate::{ - alp::consts::MAX_TX_INPUTS, + alp::{consts::MAX_TX_INPUTS, parse_genesis_info_group}, color::{ColoredTx, ColoredTxSection}, parsed::ParsedTxType, structs::{GenesisInfo, Token, TokenMeta, TokenVariant, TxType}, token_tx::{TokenTx, TokenTxEntry}, - token_type::{SlpTokenType, TokenType}, + token_type::{AlpTokenType, SlpTokenType, TokenType}, verify::BurnError::*, }; @@ -254,7 +254,27 @@ } // All other GENESIS txs are self-evident - TxType::GENESIS => entry, + TxType::GENESIS => { + // ALP GROUPs + match ( + parse_genesis_info_group(section.genesis_info.as_ref()), + self.spent_tokens.first(), + ) { + (Some(group_token_id), Some(Some(spent_token))) + if spent_token.token.meta.token_id + == group_token_id + && spent_token.token.meta.token_type + == TokenType::Alp(AlpTokenType::Standard) + && spent_token.token.variant.amount() > 0 => + { + TokenTxEntry { + group_token_meta: Some(spent_token.token.meta), + ..entry + } + } + _ => entry, + } + } // SLP V2 Mint Vault must have a given Script as input TxType::MINT if section.is_mint_vault_mint() => { @@ -433,6 +453,16 @@ { continue; } + // Same for ALP; validation is already done otherwise + // group_token_meta would be None. + if first_entry.meta.token_type + == TokenType::Alp(AlpTokenType::Standard) + && first_entry.tx_type == Some(TxType::GENESIS) + && first_entry.group_token_meta.is_some() + && !first_entry.is_invalid + { + continue; + } } } diff --git a/chronik/bitcoinsuite-slp/tests/test_verify_alp_genesis.rs b/chronik/bitcoinsuite-slp/tests/test_verify_alp_genesis.rs --- a/chronik/bitcoinsuite-slp/tests/test_verify_alp_genesis.rs +++ b/chronik/bitcoinsuite-slp/tests/test_verify_alp_genesis.rs @@ -5,13 +5,16 @@ use bitcoinsuite_slp::{ alp::{genesis_section, sections_opreturn}, parsed::ParsedMintData, - structs::{GenesisInfo, TxType}, + structs::{GenesisInfo, TokenMeta, TxType}, test_helpers::{ empty_entry, meta_alp as meta, spent_amount, token_amount, token_baton, verify, TOKEN_ID1, TOKEN_ID2, TOKEN_ID3, }, token_tx::{TokenTx, TokenTxEntry}, - token_type::AlpTokenType::*, + token_type::{ + AlpTokenType::{self, *}, + SlpTokenType, TokenType, + }, }; use pretty_assertions::assert_eq; @@ -109,3 +112,167 @@ }, ); } + +#[test] +fn test_verify_alp_genesis_missing_group() { + let genesis_info = GenesisInfo { + data: Some([b"GRP0".as_ref(), &[2; 32]].concat().into()), + auth_pubkey: Some(b"".as_ref().into()), + ..Default::default() + }; + assert_eq!( + verify::<2>( + sections_opreturn(vec![genesis_section( + Standard, + &genesis_info, + &ParsedMintData { + amounts: vec![100], + num_batons: 1, + }, + )]), + &[], + ), + TokenTx { + entries: vec![TokenTxEntry { + meta: meta(TOKEN_ID1), + tx_type: Some(TxType::GENESIS), + genesis_info: Some(genesis_info), + ..empty_entry() + }], + outputs: vec![None, token_amount::<0>(100), token_baton::<0>()], + failed_parsings: vec![], + }, + ); +} + +#[test] +fn test_verify_alp_genesis_invalid_group() { + let genesis_info = GenesisInfo { + data: Some([b"GRP0".as_ref(), &[2; 32]].concat().into()), + auth_pubkey: Some(b"".as_ref().into()), + ..Default::default() + }; + for token_type in [ + TokenType::Slp(SlpTokenType::Fungible), + TokenType::Slp(SlpTokenType::MintVault), + TokenType::Slp(SlpTokenType::Nft1Child), + TokenType::Slp(SlpTokenType::Nft1Group), + TokenType::Slp(SlpTokenType::Unknown(123)), + TokenType::Alp(AlpTokenType::Unknown(21)), + ] { + let spent_meta = TokenMeta { + token_id: TOKEN_ID2, + token_type, + }; + assert_eq!( + verify::<2>( + sections_opreturn(vec![genesis_section( + Standard, + &genesis_info, + &ParsedMintData { + amounts: vec![100], + num_batons: 1, + }, + )]), + &[spent_amount(spent_meta, 100)], + ), + TokenTx { + entries: vec![ + TokenTxEntry { + meta: meta(TOKEN_ID1), + tx_type: Some(TxType::GENESIS), + genesis_info: Some(genesis_info.clone()), + ..empty_entry() + }, + TokenTxEntry { + meta: spent_meta, + actual_burn_amount: 100, + is_invalid: true, + ..empty_entry() + }, + ], + outputs: vec![None, token_amount::<0>(100), token_baton::<0>()], + failed_parsings: vec![], + }, + ); + } +} + +#[test] +fn test_verify_alp_genesis_mismatch_group_token_id() { + let genesis_info = GenesisInfo { + data: Some([b"GRP0".as_ref(), &[2; 32]].concat().into()), + auth_pubkey: Some(b"".as_ref().into()), + ..Default::default() + }; + assert_eq!( + verify::<2>( + sections_opreturn(vec![genesis_section( + Standard, + &genesis_info, + &ParsedMintData { + amounts: vec![100], + num_batons: 1, + }, + )]), + &[spent_amount(meta(TOKEN_ID3), 100)], + ), + TokenTx { + entries: vec![ + TokenTxEntry { + meta: meta(TOKEN_ID1), + tx_type: Some(TxType::GENESIS), + genesis_info: Some(genesis_info.clone()), + ..empty_entry() + }, + TokenTxEntry { + meta: meta(TOKEN_ID3), + actual_burn_amount: 100, + is_invalid: true, + ..empty_entry() + }, + ], + outputs: vec![None, token_amount::<0>(100), token_baton::<0>()], + failed_parsings: vec![], + }, + ); +} + +#[test] +fn test_verify_alp_genesis_success_group() { + let genesis_info = GenesisInfo { + data: Some([b"GRP0".as_ref(), &[2; 32]].concat().into()), + auth_pubkey: Some(b"".as_ref().into()), + ..Default::default() + }; + assert_eq!( + verify::<2>( + sections_opreturn(vec![genesis_section( + Standard, + &genesis_info, + &ParsedMintData { + amounts: vec![100], + num_batons: 1, + }, + )]), + &[spent_amount(meta(TOKEN_ID2), 100)], + ), + TokenTx { + entries: vec![ + TokenTxEntry { + meta: meta(TOKEN_ID1), + tx_type: Some(TxType::GENESIS), + genesis_info: Some(genesis_info.clone()), + group_token_meta: Some(meta(TOKEN_ID2)), + ..empty_entry() + }, + TokenTxEntry { + meta: meta(TOKEN_ID2), + ..empty_entry() + }, + ], + outputs: vec![None, token_amount::<0>(100), token_baton::<0>()], + failed_parsings: vec![], + }, + ); +} diff --git a/test/functional/chronik_token_alp_group.py b/test/functional/chronik_token_alp_group.py new file mode 100644 --- /dev/null +++ b/test/functional/chronik_token_alp_group.py @@ -0,0 +1,246 @@ +# Copyright (c) 2024 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +Test Chronik indexes ALP GRP0 groups correctly. +""" + +from test_framework.address import ( + ADDRESS_ECREG_P2SH_OP_TRUE, + ADDRESS_ECREG_UNSPENDABLE, + P2SH_OP_TRUE, + SCRIPTSIG_OP_TRUE, +) +from test_framework.chronik.alp import alp_genesis, alp_opreturn, alp_send +from test_framework.chronik.token_tx import TokenTx +from test_framework.messages import COutPoint, CTransaction, CTxIn, CTxOut +from test_framework.test_framework import BitcoinTestFramework + + +class ChronikTokenAlpGroup(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [["-chronik"]] + + def skip_test_if_missing_module(self): + self.skip_if_no_chronik() + + def run_test(self): + from test_framework.chronik.client import pb + + def token(token_type=None, **kwargs) -> pb.Token: + return pb.Token( + token_type=token_type or pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + **kwargs, + ) + + node = self.nodes[0] + chronik = node.get_chronik_client() + + mocktime = 1300000000 + node.setmocktime(mocktime) + + coinblockhash = self.generatetoaddress(node, 1, ADDRESS_ECREG_P2SH_OP_TRUE)[0] + coinblock = node.getblock(coinblockhash) + cointx = coinblock["tx"][0] + + self.generatetoaddress(node, 100, ADDRESS_ECREG_UNSPENDABLE) + + coinvalue = 5000000000 + + txs = [] + + tx = CTransaction() + tx.vin = [CTxIn(COutPoint(int(cointx, 16), 0), SCRIPTSIG_OP_TRUE)] + tx.vout = [ + alp_opreturn( + alp_genesis( + token_ticker=b"ALP GROUP", + token_name=b"ALP GROUP token", + mint_amounts=[10], + num_batons=0, + ) + ), + CTxOut(10000, P2SH_OP_TRUE), + CTxOut(coinvalue - 400000, P2SH_OP_TRUE), + ] + tx.rehash() + group_genesis = TokenTx( + tx=tx, + status=pb.TOKEN_STATUS_NORMAL, + entries=[ + pb.TokenEntry( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + tx_type=pb.GENESIS, + actual_burn_amount="0", + ), + ], + inputs=[pb.Token()], + outputs=[ + pb.Token(), + token(token_id=tx.hash, amount=10), + pb.Token(), + ], + token_info=pb.TokenInfo( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + genesis_info=pb.GenesisInfo( + token_ticker=b"ALP GROUP", + token_name=b"ALP GROUP token", + ), + ), + ) + txs.append(group_genesis) + group_genesis.send(chronik) + group_genesis.test(chronik) + + # ALP CHILD GENESIS + tx = CTransaction() + tx.vin = [CTxIn(COutPoint(int(group_genesis.txid, 16), 1), SCRIPTSIG_OP_TRUE)] + tx.vout = [ + alp_opreturn( + alp_genesis( + token_ticker=b"ALP CHILD", + token_name=b"ALP CHILD token", + data=b"GRP0" + bytes.fromhex(group_genesis.txid)[::-1], + mint_amounts=[4], + num_batons=0, + ) + ), + CTxOut(9000, P2SH_OP_TRUE), + ] + tx.rehash() + child_genesis1 = TokenTx( + tx=tx, + status=pb.TOKEN_STATUS_NORMAL, + entries=[ + pb.TokenEntry( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + tx_type=pb.GENESIS, + group_token_id=group_genesis.txid, + actual_burn_amount="0", + ), + pb.TokenEntry( + token_id=group_genesis.txid, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + actual_burn_amount="0", + ), + ], + inputs=[token(token_id=group_genesis.txid, entry_idx=1, amount=10)], + outputs=[ + pb.Token(), + token(token_id=tx.hash, amount=4), + ], + token_info=pb.TokenInfo( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + genesis_info=pb.GenesisInfo( + token_ticker=b"ALP CHILD", + token_name=b"ALP CHILD token", + data=b"GRP0" + bytes.fromhex(group_genesis.txid)[::-1], + ), + ), + ) + txs.append(child_genesis1) + child_genesis1.send(chronik) + child_genesis1.test(chronik) + + # ALP CHILD SEND + tx = CTransaction() + tx.vin = [CTxIn(COutPoint(int(child_genesis1.txid, 16), 1), SCRIPTSIG_OP_TRUE)] + tx.vout = [ + alp_opreturn(alp_send(child_genesis1.txid, [4])), + CTxOut(8000, P2SH_OP_TRUE), + ] + tx.rehash() + child_send1 = TokenTx( + tx=tx, + status=pb.TOKEN_STATUS_NORMAL, + entries=[ + pb.TokenEntry( + token_id=child_genesis1.txid, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + tx_type=pb.SEND, + group_token_id=group_genesis.txid, + actual_burn_amount="0", + ), + ], + inputs=[token(token_id=child_genesis1.txid, amount=4)], + outputs=[ + pb.Token(), + token(token_id=child_genesis1.txid, amount=4), + ], + ) + txs.append(child_send1) + child_send1.send(chronik) + child_send1.test(chronik) + + # Nested ALP CHILD GENESIS + tx = CTransaction() + tx.vin = [CTxIn(COutPoint(int(child_send1.txid, 16), 1), SCRIPTSIG_OP_TRUE)] + tx.vout = [ + alp_opreturn( + alp_genesis( + token_ticker=b"NESTED ALP CHILD", + token_name=b"NESTED ALP CHILD token", + data=b"GRP0" + bytes.fromhex(child_genesis1.txid)[::-1], + mint_amounts=[400], + num_batons=0, + ), + ), + CTxOut(7000, P2SH_OP_TRUE), + ] + tx.rehash() + child_genesis2 = TokenTx( + tx=tx, + status=pb.TOKEN_STATUS_NORMAL, + entries=[ + pb.TokenEntry( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + tx_type=pb.GENESIS, + group_token_id=child_genesis1.txid, + actual_burn_amount="0", + ), + pb.TokenEntry( + token_id=child_genesis1.txid, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + group_token_id=group_genesis.txid, + actual_burn_amount="0", + ), + ], + inputs=[token(token_id=child_genesis1.txid, entry_idx=1, amount=4)], + outputs=[ + pb.Token(), + token(token_id=tx.hash, amount=400), + ], + token_info=pb.TokenInfo( + token_id=tx.hash, + token_type=pb.TokenType(alp=pb.ALP_TOKEN_TYPE_STANDARD), + genesis_info=pb.GenesisInfo( + token_ticker=b"NESTED ALP CHILD", + token_name=b"NESTED ALP CHILD token", + data=b"GRP0" + bytes.fromhex(child_genesis1.txid)[::-1], + ), + ), + ) + txs.append(child_genesis2) + child_genesis2.send(chronik) + child_genesis2.test(chronik) + + # After mining, all txs still work fine + block_hash = self.generatetoaddress(node, 1, ADDRESS_ECREG_UNSPENDABLE)[0] + for tx in txs: + tx.test(chronik, block_hash) + + # Undo block + test again + node.invalidateblock(block_hash) + for tx in txs: + tx.test(chronik) + + +if __name__ == "__main__": + ChronikTokenAlpGroup().main()