Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ jobs:
key: ccache-${{ runner.os }}-${{ github.sha }}
restore-keys: ccache-${{ runner.os }}-
- name: Checkout Bitcoin Core
run: git clone --depth 1 --branch master https://github.com/bitcoin/bitcoin.git
run: |
git clone --depth 1 https://github.com/bitcoin/bitcoin.git
# DO NOT MERGE: switch to bitcoin/bitcoin#35671 PR branch
cd bitcoin
git fetch --depth 1 origin pull/35671/head:pr-35671
git checkout pr-35671
- name: Build Bitcoin Core
run: |
cd bitcoin
Expand Down
8 changes: 8 additions & 0 deletions capnp/mining.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ interface Mining $Proxy.wrap("interfaces::Mining") {
submitBlock @7 (context :Proxy.Context, block: Data) -> (reason: Text, debug: Text, result: Bool);
getTransactionsByTxID @8 (context :Proxy.Context, txids: List(Data)) -> (result: List(Data));
getTransactionsByWitnessID @9 (context :Proxy.Context, wtxids: List(Data)) -> (result: List(Data));
collectTxs @10 (context :Proxy.Context, wtxids: List(Data)) -> (result: TxCollection);
}

interface TxCollection $Proxy.wrap("interfaces::TxCollection") {
destroy @0 (context :Proxy.Context) -> ();
unknownTxPos @1 (context: Proxy.Context) -> (result: List(UInt32));
addMissingTxs @2 (context: Proxy.Context, txs: List(Data)) -> ();
makeTemplate @3 (context: Proxy.Context, prevhash: Data, coinbase: Data) -> (reason: Text, debug: Text, result: BlockTemplate);
}

interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") {
Expand Down
269 changes: 266 additions & 3 deletions tests/test.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
use bitcoin_capnp_types::{mining_capnp, proxy_capnp::thread};
use encoding::encode_to_vec;
use bitcoin::Block as BitcoinBlock;
use bitcoin_capnp_types::{
mining_capnp::{self, block_template, tx_collection},
proxy_capnp::thread,
};

mod util;

use encoding::{decode_from_slice, encode_to_vec};
use serde_json::{Value, json};
use util::bitcoin_core::{
destroy_template, make_block_template, mempool_tx_count, with_init_client, with_mining_client,
with_rpc_client,
};
use util::bitcoin_core_wallet::{
bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded,
bitcoin_rpc_json, bitcoin_test_wallet, create_mempool_self_transfer,
create_unbroadcast_self_transfer, ensure_wallet_loaded_and_funded, mine_blocks_to_new_address,
};
use util::block::{block_solution, block_with_pow};

Expand Down Expand Up @@ -46,6 +51,124 @@ async fn get_template_block(
resp.get().unwrap().get_result().unwrap().to_vec()
}

async fn get_tip_hash(mining: &mining_capnp::mining::Client, thread: &thread::Client) -> Vec<u8> {
let mut req = mining.get_tip_request();
req.get().get_context().unwrap().set_thread(thread.clone());
let resp = req.send().promise.await.unwrap();
resp.get()
.unwrap()
.get_result()
.unwrap()
.get_hash()
.unwrap()
.to_vec()
}

async fn collect_txs(
mining: &mining_capnp::mining::Client,
thread: &thread::Client,
wtxids: &[&[u8]],
) -> tx_collection::Client {
let mut req = mining.collect_txs_request();
req.get().get_context().unwrap().set_thread(thread.clone());
{
let mut request_wtxids = req.get().init_wtxids(wtxids.len() as u32);
for (pos, wtxid) in wtxids.iter().enumerate() {
request_wtxids.set(pos as u32, wtxid);
}
}
let resp = req.send().promise.await.unwrap();
resp.get().unwrap().get_result().unwrap()
}

async fn tx_collection_unknown_pos(
collection: &tx_collection::Client,
thread: &thread::Client,
) -> Vec<u32> {
let mut req = collection.unknown_tx_pos_request();
req.get().get_context().unwrap().set_thread(thread.clone());
let resp = req.send().promise.await.unwrap();
let positions = resp.get().unwrap().get_result().unwrap();
(0..positions.len()).map(|pos| positions.get(pos)).collect()
}

async fn add_missing_txs(
collection: &tx_collection::Client,
thread: &thread::Client,
txs: &[&[u8]],
) {
let mut req = collection.add_missing_txs_request();
req.get().get_context().unwrap().set_thread(thread.clone());
{
let mut request_txs = req.get().init_txs(txs.len() as u32);
for (pos, tx) in txs.iter().enumerate() {
request_txs.set(pos as u32, tx);
}
}
req.send().promise.await.unwrap();
}

async fn make_tx_collection_template(
collection: &tx_collection::Client,
thread: &thread::Client,
prevhash: &[u8],
coinbase: Option<&[u8]>,
) -> (String, String, Option<block_template::Client>) {
let mut req = collection.make_template_request();
req.get().get_context().unwrap().set_thread(thread.clone());
req.get().set_prevhash(prevhash);
if let Some(coinbase) = coinbase {
req.get().set_coinbase(coinbase);
}
let resp = req.send().promise.await.unwrap();
let results = resp.get().unwrap();
let reason = results.get_reason().unwrap().to_string().unwrap();
let debug = results.get_debug().unwrap().to_string().unwrap();
let template = results.has_result().then(|| results.get_result().unwrap());
(reason, debug, template)
}

/// Builds a minimal serialized coinbase transaction for the given block
/// height: one null input with a BIP34 height push, one zero-value output.
fn build_coinbase_tx_bytes(next_height: u32) -> Vec<u8> {
// Encode the height as a minimally pushed little-endian integer (BIP34 style).
let mut height_bytes = Vec::new();
let mut value = next_height;
while value > 0 {
height_bytes.push((value & 0xff) as u8);
value >>= 8;
}
if height_bytes.last().is_some_and(|byte| byte & 0x80 != 0) {
height_bytes.push(0x00);
}
let mut script_sig = vec![height_bytes.len() as u8];
script_sig.extend_from_slice(&height_bytes);
// scriptSig must be at least 2 bytes to avoid bad-cb-length
if script_sig.len() < 2 {
script_sig.push(0x00);
}

let mut tx = Vec::new();
tx.extend_from_slice(&2u32.to_le_bytes()); // version
tx.push(1); // input count
tx.extend_from_slice(&[0u8; 32]); // null prevout hash
tx.extend_from_slice(&u32::MAX.to_le_bytes()); // null prevout index
tx.push(script_sig.len() as u8);
tx.extend_from_slice(&script_sig);
tx.extend_from_slice(&u32::MAX.to_le_bytes()); // sequence
tx.push(1); // output count
tx.extend_from_slice(&0u64.to_le_bytes()); // value
tx.push(0); // empty script pubkey
tx.extend_from_slice(&0u32.to_le_bytes()); // lock time
tx
}

async fn destroy_tx_collection(collection: &tx_collection::Client, thread: &thread::Client) {
let mut req = collection.destroy_request();
req.get().get_context().unwrap().set_thread(thread.clone());
req.send().promise.await.unwrap();
}

#[tokio::test]
#[serial_test::parallel]
async fn integration() {
Expand Down Expand Up @@ -398,6 +521,146 @@ async fn mining_submit_block_insufficient_pow() {
.await;
}

/// collectTxs + TxCollection workflow with one mempool transaction and one
/// transaction supplied by the client.
#[tokio::test]
#[serial_test::serial]
async fn mining_tx_collection_workflow() {
with_mining_client(|_client, thread, mining| async move {
let wallet = bitcoin_test_wallet();
ensure_wallet_loaded_and_funded(&wallet);
if mempool_tx_count() > 0 {
mine_blocks_to_new_address(&wallet, 1).unwrap_or_else(|e| {
panic!("failed to clear mempool before tx collection test: {e}")
});
}

let mempool_tx = create_mempool_self_transfer(&wallet);
let client_tx = create_unbroadcast_self_transfer(&wallet);
let mempool_wtxid = mempool_tx.compute_wtxid().to_byte_array();
let client_wtxid = client_tx.compute_wtxid().to_byte_array();
let client_raw_tx = encode_to_vec(&client_tx);

let mut duplicate_req = mining.collect_txs_request();
duplicate_req
.get()
.get_context()
.unwrap()
.set_thread(thread.clone());
{
let mut wtxids = duplicate_req.get().init_wtxids(2);
wtxids.set(0, &mempool_wtxid);
wtxids.set(1, &mempool_wtxid);
}
assert!(
duplicate_req.send().promise.await.is_err(),
"collectTxs must reject duplicate wtxids"
);

let collection = collect_txs(&mining, &thread, &[&mempool_wtxid, &client_wtxid]).await;
assert_eq!(
tx_collection_unknown_pos(&collection, &thread).await,
vec![1],
"client-only transaction should be reported missing"
);

let tip = get_tip_hash(&mining, &thread).await;
let (reason, debug, template) =
make_tx_collection_template(&collection, &thread, &tip, None).await;
assert_eq!(reason, "missing-txs");
assert!(
!debug.is_empty(),
"missing transaction should explain failure"
);
assert!(
template.is_none(),
"missing transaction should block template"
);

add_missing_txs(&collection, &thread, &[&client_raw_tx]).await;
assert_eq!(
tx_collection_unknown_pos(&collection, &thread).await,
Vec::<u32>::new(),
"all requested transactions should be available after addMissingTxs"
);

let (reason, debug, template) =
make_tx_collection_template(&collection, &thread, &tip, None).await;
assert_eq!(reason, "");
assert_eq!(debug, "");
let template = template.expect("complete collection should create a block template");
let block = get_template_block(&template, &thread).await;
let block: BitcoinBlock =
decode_from_slice(&block).unwrap_or_else(|e| panic!("failed to decode block: {e}"));
let (_, transactions) = block.into_parts();
assert_eq!(
transactions[1].compute_wtxid().to_byte_array(),
mempool_wtxid,
"mempool transaction should keep the requested position"
);
assert_eq!(
transactions[2].compute_wtxid().to_byte_array(),
client_wtxid,
"client-supplied transaction should keep the requested position"
);

// A client-provided "coinbase" that is not actually a coinbase is rejected.
let (reason, _debug, bad_template) =
make_tx_collection_template(&collection, &thread, &tip, Some(&client_raw_tx)).await;
assert_eq!(reason, "bad-cb-missing");
assert!(
bad_template.is_none(),
"non-coinbase transaction should block template"
);

destroy_template(&template, &thread).await;
destroy_tx_collection(&collection, &thread).await;
})
.await;
}

/// makeTemplate with a client-provided coinbase validates the block with it.
#[tokio::test]
#[serial_test::serial]
async fn mining_tx_collection_client_coinbase() {
with_mining_client(|_client, thread, mining| async move {
let collection = collect_txs(&mining, &thread, &[]).await;
let tip = get_tip_hash(&mining, &thread).await;
let height: u32 = bitcoin_rpc_json(None, &["getblockcount"])
.unwrap_or_else(|e| panic!("failed to get block count: {e}"));
let coinbase = build_coinbase_tx_bytes(height + 1);

let (reason, debug, template) =
make_tx_collection_template(&collection, &thread, &tip, Some(&coinbase)).await;
assert_eq!(reason, "");
assert_eq!(debug, "");
let template = template.expect("valid client coinbase should create a block template");
let block = get_template_block(&template, &thread).await;
let block: BitcoinBlock =
decode_from_slice(&block).unwrap_or_else(|e| panic!("failed to decode block: {e}"));
let (_, transactions) = block.into_parts();
assert_eq!(
encode_to_vec(&transactions[0]),
coinbase,
"template should use the client-provided coinbase"
);

// A coinbase for the wrong height fails BIP34 validation.
let bad_coinbase = build_coinbase_tx_bytes(height + 2);
let (reason, _debug, bad_template) =
make_tx_collection_template(&collection, &thread, &tip, Some(&bad_coinbase)).await;
assert_eq!(reason, "bad-cb-height");
assert!(
bad_template.is_none(),
"wrong-height coinbase should block template"
);

destroy_template(&template, &thread).await;
destroy_tx_collection(&collection, &thread).await;
})
.await;
}

/// submitBlock with invalid contents should be rejected even with sufficient PoW.
#[tokio::test]
#[serial_test::serial]
Expand Down
Loading
Loading