Skip to content

Commit 9e53963

Browse files
committed
feat: include lockup events in nakamoto blocks without coinbase
1 parent bac1b07 commit 9e53963

File tree

6 files changed

+241
-10
lines changed

6 files changed

+241
-10
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ jobs:
136136
- tests::nakamoto_integrations::mock_mining
137137
- tests::nakamoto_integrations::multiple_miners
138138
- tests::nakamoto_integrations::follower_bootup_across_multiple_cycles
139+
- tests::nakamoto_integrations::nakamoto_lockup_events
139140
- tests::nakamoto_integrations::utxo_check_on_startup_panic
140141
- tests::nakamoto_integrations::utxo_check_on_startup_recover
141142
- tests::nakamoto_integrations::v3_signer_api_endpoint

stackslib/src/chainstate/nakamoto/mod.rs

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ use super::stacks::db::{
7373
use super::stacks::events::{StacksTransactionReceipt, TransactionOrigin};
7474
use super::stacks::{
7575
Error as ChainstateError, StacksBlock, StacksBlockHeader, StacksMicroblock, StacksTransaction,
76-
TenureChangeError, TenureChangePayload, TransactionPayload,
76+
TenureChangeError, TenureChangePayload, TokenTransferMemo, TransactionPayload,
77+
TransactionVersion,
7778
};
7879
use crate::burnchains::{Burnchain, PoxConstants, Txid};
7980
use crate::chainstate::burn::db::sortdb::SortitionDB;
@@ -108,8 +109,7 @@ use crate::core::{
108109
};
109110
use crate::net::stackerdb::{StackerDBConfig, MINER_SLOT_COUNT};
110111
use crate::net::Error as net_error;
111-
use crate::util_lib::boot;
112-
use crate::util_lib::boot::boot_code_id;
112+
use crate::util_lib::boot::{self, boot_code_addr, boot_code_id, boot_code_tx_auth};
113113
use crate::util_lib::db::{
114114
query_int, query_row, query_row_columns, query_row_panic, query_rows, sqlite_open,
115115
tx_begin_immediate, u64_to_sql, DBConn, Error as DBError, FromRow,
@@ -2048,7 +2048,8 @@ impl NakamotoChainState {
20482048
return Err(e);
20492049
};
20502050

2051-
let (receipt, clarity_commit, reward_set_data) = ok_opt.expect("FATAL: unreachable");
2051+
let (receipt, clarity_commit, reward_set_data, phantom_unlock_events) =
2052+
ok_opt.expect("FATAL: unreachable");
20522053

20532054
assert_eq!(
20542055
receipt.header.anchored_header.block_hash(),
@@ -2102,6 +2103,19 @@ impl NakamotoChainState {
21022103
&receipt.header.anchored_header.block_hash()
21032104
);
21042105

2106+
let mut tx_receipts = receipt.tx_receipts.clone();
2107+
if let Some(unlock_receipt) =
2108+
// For the event dispatcher, attach any STXMintEvents that
2109+
// could not be included in the block (e.g. because the
2110+
// block didn't have a Coinbase transaction).
2111+
Self::generate_phantom_unlock_tx(
2112+
phantom_unlock_events,
2113+
&stacks_chain_state.config(),
2114+
)
2115+
{
2116+
tx_receipts.push(unlock_receipt);
2117+
}
2118+
21052119
// announce the block, if we're connected to an event dispatcher
21062120
if let Some(dispatcher) = dispatcher_opt {
21072121
let block_event = (
@@ -2112,7 +2126,7 @@ impl NakamotoChainState {
21122126
dispatcher.announce_block(
21132127
&block_event,
21142128
&receipt.header.clone(),
2115-
&receipt.tx_receipts,
2129+
&tx_receipts,
21162130
&parent_block_id,
21172131
next_ready_block_snapshot.winning_block_txid,
21182132
&receipt.matured_rewards,
@@ -3915,6 +3929,7 @@ impl NakamotoChainState {
39153929
StacksEpochReceipt,
39163930
PreCommitClarityBlock<'a>,
39173931
Option<RewardSetData>,
3932+
Vec<StacksTransactionEvent>,
39183933
),
39193934
ChainstateError,
39203935
> {
@@ -4215,18 +4230,23 @@ impl NakamotoChainState {
42154230
Ok(lockup_events) => lockup_events,
42164231
};
42174232

4233+
// Track events that we couldn't attach to a coinbase receipt
4234+
let mut phantom_lockup_events = lockup_events.clone();
42184235
// if any, append lockups events to the coinbase receipt
42194236
if lockup_events.len() > 0 {
42204237
// Receipts are appended in order, so the first receipt should be
42214238
// the one of the coinbase transaction
42224239
if let Some(receipt) = tx_receipts.get_mut(0) {
42234240
if receipt.is_coinbase_tx() {
42244241
receipt.events.append(&mut lockup_events);
4242+
phantom_lockup_events.clear();
42254243
}
4226-
} else {
4227-
warn!("Unable to attach lockups events, block's first transaction is not a coinbase transaction")
42284244
}
42294245
}
4246+
if phantom_lockup_events.len() > 0 {
4247+
info!("Unable to attach lockup events, block's first transaction is not a coinbase transaction. Will attach as a phantom tx.");
4248+
}
4249+
42304250
// if any, append auto unlock events to the coinbase receipt
42314251
if auto_unlock_events.len() > 0 {
42324252
// Receipts are appended in order, so the first receipt should be
@@ -4394,7 +4414,12 @@ impl NakamotoChainState {
43944414
coinbase_height,
43954415
};
43964416

4397-
Ok((epoch_receipt, clarity_commit, reward_set_data))
4417+
Ok((
4418+
epoch_receipt,
4419+
clarity_commit,
4420+
reward_set_data,
4421+
phantom_lockup_events,
4422+
))
43984423
}
43994424

44004425
/// Create a StackerDB config for the .miners contract.
@@ -4555,6 +4580,42 @@ impl NakamotoChainState {
45554580
clarity.save_analysis(&contract_id, &analysis).unwrap();
45564581
})
45574582
}
4583+
4584+
/// Generate a "phantom" transaction to include STXMintEvents for
4585+
/// lockups that could not be attached to a Coinbase transaction
4586+
/// (because the block doesn't have a Coinbase transaction).
4587+
fn generate_phantom_unlock_tx(
4588+
events: Vec<StacksTransactionEvent>,
4589+
config: &ChainstateConfig,
4590+
) -> Option<StacksTransactionReceipt> {
4591+
if events.is_empty() {
4592+
return None;
4593+
}
4594+
info!("Generating phantom unlock tx");
4595+
let version = if config.mainnet {
4596+
TransactionVersion::Mainnet
4597+
} else {
4598+
TransactionVersion::Testnet
4599+
};
4600+
let boot_code_address = boot_code_addr(config.mainnet);
4601+
let boot_code_auth = boot_code_tx_auth(boot_code_address.clone());
4602+
let unlock_tx = StacksTransaction::new(
4603+
version,
4604+
boot_code_auth,
4605+
TransactionPayload::TokenTransfer(
4606+
PrincipalData::Standard(boot_code_address.into()),
4607+
0,
4608+
TokenTransferMemo([0u8; 34]),
4609+
),
4610+
);
4611+
let unlock_receipt = StacksTransactionReceipt::from_stx_transfer(
4612+
unlock_tx,
4613+
events,
4614+
Value::okay_true(),
4615+
ExecutionCost::zero(),
4616+
);
4617+
Some(unlock_receipt)
4618+
}
45584619
}
45594620

45604621
impl StacksMessageCodec for NakamotoBlock {

stackslib/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,8 @@ fn replay_block_nakamoto(
783783
return Err(e);
784784
};
785785

786-
let (receipt, _clarity_commit, _reward_set_data) = ok_opt.expect("FATAL: unreachable");
786+
let (receipt, _clarity_commit, _reward_set_data, _phantom_events) =
787+
ok_opt.expect("FATAL: unreachable");
787788

788789
assert_eq!(
789790
receipt.header.anchored_header.block_hash(),

stx-genesis/chainstate-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,5 @@ SM1ZH700J7CEDSEHM5AJ4C4MKKWNESTS35DD3SZM5,13888889,2267
6969
SM260QHD6ZM2KKPBKZB8PFE5XWP0MHSKTD1B7BHYR,208333333,45467
7070
SM260QHD6ZM2KKPBKZB8PFE5XWP0MHSKTD1B7BHYR,208333333,6587
7171
SM260QHD6ZM2KKPBKZB8PFE5XWP0MHSKTD1B7BHYR,208333333,2267
72+
SP2CTPPV8BHBVSQR727A3MK00ZD85RNY903KAG9F3,12345678,35
7273
-----END STX VESTING-----
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
014402b47d53b0716402c172fa746adf308b03a826ebea91944a5eb6a304a823
1+
088c3caea982a8f6f74dda48ec5f06f51f7605def9760a971b1acd763ee6b7cf

testnet/stacks-node/src/tests/nakamoto_integrations.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9301,6 +9301,173 @@ fn v3_signer_api_endpoint() {
93019301
run_loop_thread.join().unwrap();
93029302
}
93039303

9304+
/// Verify that lockup events are attached to a phantom tx receipt
9305+
/// if the block does not have a coinbase tx
9306+
#[test]
9307+
#[ignore]
9308+
fn nakamoto_lockup_events() {
9309+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
9310+
return;
9311+
}
9312+
9313+
let (mut conf, _miner_account) = naka_neon_integration_conf(None);
9314+
let password = "12345".to_string();
9315+
conf.connection_options.auth_token = Some(password.clone());
9316+
conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
9317+
let stacker_sk = setup_stacker(&mut conf);
9318+
let signer_sk = Secp256k1PrivateKey::new();
9319+
let signer_addr = tests::to_addr(&signer_sk);
9320+
let _signer_pubkey = Secp256k1PublicKey::from_private(&signer_sk);
9321+
let sender_sk = Secp256k1PrivateKey::new();
9322+
// setup sender + recipient for some test stx transfers
9323+
// these are necessary for the interim blocks to get mined at all
9324+
let sender_addr = tests::to_addr(&sender_sk);
9325+
let send_amt = 100;
9326+
let send_fee = 180;
9327+
conf.add_initial_balance(
9328+
PrincipalData::from(sender_addr).to_string(),
9329+
(send_amt + send_fee) * 100,
9330+
);
9331+
conf.add_initial_balance(PrincipalData::from(signer_addr).to_string(), 100000);
9332+
let recipient = PrincipalData::from(StacksAddress::burn_address(false));
9333+
9334+
// only subscribe to the block proposal events
9335+
test_observer::spawn();
9336+
test_observer::register_any(&mut conf);
9337+
9338+
let mut btcd_controller = BitcoinCoreController::new(conf.clone());
9339+
btcd_controller
9340+
.start_bitcoind()
9341+
.expect("Failed starting bitcoind");
9342+
let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None);
9343+
btc_regtest_controller.bootstrap_chain(201);
9344+
9345+
let mut run_loop = boot_nakamoto::BootRunLoop::new(conf.clone()).unwrap();
9346+
let run_loop_stopper = run_loop.get_termination_switch();
9347+
let Counters {
9348+
blocks_processed,
9349+
naka_submitted_commits: commits_submitted,
9350+
naka_proposed_blocks: proposals_submitted,
9351+
..
9352+
} = run_loop.counters();
9353+
9354+
let coord_channel = run_loop.coordinator_channels();
9355+
9356+
let run_loop_thread = thread::spawn(move || run_loop.start(None, 0));
9357+
let mut signers = TestSigners::new(vec![signer_sk]);
9358+
wait_for_runloop(&blocks_processed);
9359+
boot_to_epoch_3(
9360+
&conf,
9361+
&blocks_processed,
9362+
&[stacker_sk],
9363+
&[signer_sk],
9364+
&mut Some(&mut signers),
9365+
&mut btc_regtest_controller,
9366+
);
9367+
9368+
info!("------------------------- Reached Epoch 3.0 -------------------------");
9369+
blind_signer(&conf, &signers, proposals_submitted);
9370+
let burnchain = conf.get_burnchain();
9371+
let sortdb = burnchain.open_sortition_db(true).unwrap();
9372+
let (chainstate, _) = StacksChainState::open(
9373+
conf.is_mainnet(),
9374+
conf.burnchain.chain_id,
9375+
&conf.get_chainstate_path_str(),
9376+
None,
9377+
)
9378+
.unwrap();
9379+
// TODO (hack) instantiate the sortdb in the burnchain
9380+
_ = btc_regtest_controller.sortdb_mut();
9381+
9382+
info!("------------------------- Setup finished, run test -------------------------");
9383+
9384+
next_block_and_mine_commit(
9385+
&mut btc_regtest_controller,
9386+
60,
9387+
&coord_channel,
9388+
&commits_submitted,
9389+
)
9390+
.unwrap();
9391+
9392+
let http_origin = format!("http://{}", &conf.node.rpc_bind);
9393+
9394+
let get_stacks_height = || {
9395+
let tip = NakamotoChainState::get_canonical_block_header(chainstate.db(), &sortdb)
9396+
.unwrap()
9397+
.unwrap();
9398+
tip.stacks_block_height
9399+
};
9400+
let initial_block_height = get_stacks_height();
9401+
9402+
// This matches the data in `stx-genesis/chainstate-test.txt`
9403+
// Recipient: ST2CTPPV8BHBVSQR727A3MK00ZD85RNY9015WGW2D
9404+
let unlock_recipient = "ST2CTPPV8BHBVSQR727A3MK00ZD85RNY9015WGW2D";
9405+
let unlock_height = 35_u64;
9406+
let interims_to_mine = unlock_height - initial_block_height;
9407+
9408+
info!(
9409+
"----- Mining to unlock height -----";
9410+
"unlock_height" => unlock_height,
9411+
"initial_height" => initial_block_height,
9412+
"interims_to_mine" => interims_to_mine,
9413+
);
9414+
9415+
// submit a tx so that the miner will mine an extra stacks block
9416+
let mut sender_nonce = 0;
9417+
9418+
for _ in 0..interims_to_mine {
9419+
let height_before = get_stacks_height();
9420+
info!("----- Mining interim block -----";
9421+
"height" => %height_before,
9422+
"nonce" => %sender_nonce,
9423+
);
9424+
let transfer_tx = make_stacks_transfer(
9425+
&sender_sk,
9426+
sender_nonce,
9427+
send_fee,
9428+
conf.burnchain.chain_id,
9429+
&recipient,
9430+
send_amt,
9431+
);
9432+
submit_tx(&http_origin, &transfer_tx);
9433+
sender_nonce += 1;
9434+
9435+
wait_for(30, || Ok(get_stacks_height() > height_before)).unwrap();
9436+
}
9437+
9438+
let blocks = test_observer::get_blocks();
9439+
let block = blocks.last().unwrap();
9440+
assert_eq!(
9441+
block.get("block_height").unwrap().as_u64().unwrap(),
9442+
unlock_height
9443+
);
9444+
9445+
let events = block.get("events").unwrap().as_array().unwrap();
9446+
let mut found_event = false;
9447+
for event in events {
9448+
let mint_event = event.get("stx_mint_event");
9449+
if mint_event.is_some() {
9450+
found_event = true;
9451+
let mint_event = mint_event.unwrap();
9452+
let recipient = mint_event.get("recipient").unwrap().as_str().unwrap();
9453+
assert_eq!(recipient, unlock_recipient);
9454+
let amount = mint_event.get("amount").unwrap().as_str().unwrap();
9455+
assert_eq!(amount, "12345678");
9456+
}
9457+
}
9458+
assert!(found_event);
9459+
9460+
info!("------------------------- Test finished, clean up -------------------------");
9461+
9462+
coord_channel
9463+
.lock()
9464+
.expect("Mutex poisoned")
9465+
.stop_chains_coordinator();
9466+
run_loop_stopper.store(false, Ordering::SeqCst);
9467+
9468+
run_loop_thread.join().unwrap();
9469+
}
9470+
93049471
#[test]
93059472
#[ignore]
93069473
/// This test spins up a nakamoto-neon node.

0 commit comments

Comments
 (0)