From cb70ec33569ee50d8f18cee93d6db25a94d3b3a8 Mon Sep 17 00:00:00 2001 From: fi3 Date: Wed, 6 May 2026 16:57:55 +0200 Subject: [PATCH 1/3] ADD authenticated JD OP_RETURN injection through the existing API server Queue a zero-value OP_RETURN output from the API and apply it when the next JD template arrives so the modified coinbase is part of the declared custom job flow instead of being mutated after declaration. This keeps the pool-side declaration, token generation, and downstream work on the same coinbase while reusing the existing API server and gating access with a shared secret. --- src/op_return_injector.rs | 316 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/op_return_injector.rs diff --git a/src/op_return_injector.rs b/src/op_return_injector.rs new file mode 100644 index 0000000..15726d3 --- /dev/null +++ b/src/op_return_injector.rs @@ -0,0 +1,316 @@ +use bitcoin::{consensus::Encodable, hex::FromHex, script::PushBytesBuf, Amount, ScriptBuf, TxOut}; +use roles_logic_sv2::template_distribution_sv2::NewTemplate; +use std::{ + convert::TryInto, + fmt, + sync::{Arc, Mutex as StdMutex, OnceLock}, +}; +use tokio::sync::Mutex; + +const MAX_OP_RETURN_PAYLOAD_BYTES: usize = 80; +const MAX_ADDITIONAL_COINBASE_OUTPUT_SIZE: usize = 100; +static ACTIVE_OP_RETURN_PAYLOAD_HEX: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Default)] +pub struct OpReturnInjector { + pending: Arc>>, +} + +#[derive(Clone, Debug)] +struct PendingOpReturn { + payload_hex: String, + payload_len_bytes: usize, + tx_out_len_bytes: usize, + tx_out_bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueuedOpReturn { + pub payload_hex: String, + pub payload_len_bytes: usize, + pub tx_out_len_bytes: usize, + pub replaced_pending: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppliedOpReturn { + pub payload_hex: String, + pub payload_len_bytes: usize, + pub tx_out_len_bytes: usize, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum OpReturnInjectorError { + EmptyPayload, + InvalidHex(String), + PayloadTooLarge(usize), + TxOutTooLarge(usize), + TooManyCoinbaseOutputs, + CoinbaseOutputsTooLarge, + Encoding(String), +} + +impl fmt::Display for OpReturnInjectorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OpReturnInjectorError::EmptyPayload => { + f.write_str("OP_RETURN payload cannot be empty") + } + OpReturnInjectorError::InvalidHex(error) => { + write!(f, "OP_RETURN payload must be hex encoded: {error}") + } + OpReturnInjectorError::PayloadTooLarge(len) => write!( + f, + "OP_RETURN payload is {len} bytes, maximum supported payload is {MAX_OP_RETURN_PAYLOAD_BYTES} bytes" + ), + OpReturnInjectorError::TxOutTooLarge(len) => write!( + f, + "Serialized OP_RETURN output is {len} bytes, maximum supported size is {MAX_ADDITIONAL_COINBASE_OUTPUT_SIZE} bytes" + ), + OpReturnInjectorError::TooManyCoinbaseOutputs => { + f.write_str("Coinbase output count overflowed") + } + OpReturnInjectorError::CoinbaseOutputsTooLarge => { + f.write_str("Coinbase outputs exceed the SV2 B064K size limit") + } + OpReturnInjectorError::Encoding(error) => { + write!(f, "Failed to encode OP_RETURN output: {error}") + } + } + } +} + +impl OpReturnInjector { + pub async fn queue_from_hex( + &self, + data_hex: &str, + ) -> Result { + let pending = PendingOpReturn::from_hex(data_hex)?; + let mut guard = self.pending.lock().await; + let replaced_pending = guard.replace(pending.clone()).is_some(); + + Ok(QueuedOpReturn { + payload_hex: pending.payload_hex.clone(), + payload_len_bytes: pending.payload_len_bytes, + tx_out_len_bytes: pending.tx_out_len_bytes, + replaced_pending, + }) + } + + pub async fn apply_pending_to_template<'a>( + &self, + template: &mut NewTemplate<'a>, + ) -> Result, OpReturnInjectorError> { + let mut guard = self.pending.lock().await; + let Some(pending) = guard.as_ref().cloned() else { + return Ok(None); + }; + + append_to_template(template, &pending)?; + set_active_op_return_payload_hex(pending.payload_hex.clone()); + *guard = None; + + Ok(Some(AppliedOpReturn { + payload_hex: pending.payload_hex, + payload_len_bytes: pending.payload_len_bytes, + tx_out_len_bytes: pending.tx_out_len_bytes, + })) + } +} + +fn active_op_return_payload_hex() -> &'static StdMutex> { + ACTIVE_OP_RETURN_PAYLOAD_HEX.get_or_init(|| StdMutex::new(None)) +} + +fn set_active_op_return_payload_hex(payload_hex: String) { + if let Ok(mut active) = active_op_return_payload_hex().lock() { + *active = Some(payload_hex); + } +} + +impl PendingOpReturn { + fn from_hex(data_hex: &str) -> Result { + let payload_hex = data_hex.trim().to_ascii_lowercase(); + if payload_hex.is_empty() { + return Err(OpReturnInjectorError::EmptyPayload); + } + + let payload = Vec::from_hex(&payload_hex) + .map_err(|error| OpReturnInjectorError::InvalidHex(error.to_string()))?; + if payload.is_empty() { + return Err(OpReturnInjectorError::EmptyPayload); + } + let payload_len_bytes = payload.len(); + if payload_len_bytes > MAX_OP_RETURN_PAYLOAD_BYTES { + return Err(OpReturnInjectorError::PayloadTooLarge(payload_len_bytes)); + } + + let push_bytes = PushBytesBuf::try_from(payload) + .map_err(|error| OpReturnInjectorError::Encoding(error.to_string()))?; + let tx_out = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return(push_bytes), + }; + let mut tx_out_bytes = Vec::new(); + tx_out + .consensus_encode(&mut tx_out_bytes) + .map_err(|error| OpReturnInjectorError::Encoding(error.to_string()))?; + + if tx_out_bytes.len() > MAX_ADDITIONAL_COINBASE_OUTPUT_SIZE { + return Err(OpReturnInjectorError::TxOutTooLarge(tx_out_bytes.len())); + } + + Ok(Self { + payload_hex, + payload_len_bytes, + tx_out_len_bytes: tx_out_bytes.len(), + tx_out_bytes, + }) + } +} + +fn append_to_template<'a>( + template: &mut NewTemplate<'a>, + pending: &PendingOpReturn, +) -> Result<(), OpReturnInjectorError> { + let next_outputs_count = template + .coinbase_tx_outputs_count + .checked_add(1) + .ok_or(OpReturnInjectorError::TooManyCoinbaseOutputs)?; + + let mut outputs = template.coinbase_tx_outputs.to_vec(); + outputs.extend_from_slice(&pending.tx_out_bytes); + template.coinbase_tx_outputs = outputs + .try_into() + .map_err(|_| OpReturnInjectorError::CoinbaseOutputsTooLarge)?; + template.coinbase_tx_outputs_count = next_outputs_count; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use binary_sv2::{Seq0255, B0255, B064K, U256}; + use bitcoin::{consensus::Decodable, hex::FromHex}; + + fn make_new_template() -> NewTemplate<'static> { + let coinbase_prefix: B0255<'static> = Vec::new() + .try_into() + .expect("coinbase prefix should fit in B0255"); + let coinbase_tx_outputs: B064K<'static> = Vec::new() + .try_into() + .expect("coinbase outputs should fit in B064K"); + let merkle_path: Seq0255<'static, U256<'static>> = Vec::new().into(); + NewTemplate { + template_id: 1, + future_template: false, + version: 0, + coinbase_tx_version: 1, + coinbase_prefix, + coinbase_tx_input_sequence: 0, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs, + coinbase_tx_locktime: 0, + merkle_path, + } + } + + fn decode_only_tx_out(bytes: &[u8]) -> TxOut { + let mut bytes = bytes; + let tx_out = TxOut::consensus_decode(&mut bytes).expect("valid tx out"); + assert!(bytes.is_empty(), "expected a single serialized tx out"); + tx_out + } + + #[tokio::test] + async fn queue_and_apply_appends_zero_value_op_return() { + let injector = OpReturnInjector::default(); + let queued = injector + .queue_from_hex("deadbeef") + .await + .expect("queue should succeed"); + let mut template = make_new_template(); + + let applied = injector + .apply_pending_to_template(&mut template) + .await + .expect("apply should succeed") + .expect("pending output should be applied"); + + assert_eq!(queued.payload_len_bytes, 4); + assert_eq!(queued.tx_out_len_bytes, applied.tx_out_len_bytes); + assert_eq!(template.coinbase_tx_outputs_count, 1); + + let tx_out = decode_only_tx_out(&template.coinbase_tx_outputs.to_vec()); + assert_eq!(tx_out.value, Amount::ZERO); + assert_eq!( + tx_out.script_pubkey, + ScriptBuf::new_op_return( + PushBytesBuf::try_from(Vec::from_hex("deadbeef").unwrap()).unwrap() + ) + ); + + let second_apply = injector + .apply_pending_to_template(&mut make_new_template()) + .await + .expect("second apply should succeed"); + assert!(second_apply.is_none(), "pending output should be cleared"); + } + + #[tokio::test] + async fn newest_queue_replaces_pending_output() { + let injector = OpReturnInjector::default(); + let first = injector + .queue_from_hex("aa") + .await + .expect("first queue should succeed"); + let second = injector + .queue_from_hex("bb") + .await + .expect("second queue should succeed"); + let mut template = make_new_template(); + + let applied = injector + .apply_pending_to_template(&mut template) + .await + .expect("apply should succeed") + .expect("pending output should be applied"); + + assert!(!first.replaced_pending); + assert!(second.replaced_pending); + assert_eq!(applied.payload_len_bytes, 1); + + let tx_out = decode_only_tx_out(&template.coinbase_tx_outputs.to_vec()); + assert_eq!( + tx_out.script_pubkey, + ScriptBuf::new_op_return(PushBytesBuf::try_from(Vec::from_hex("bb").unwrap()).unwrap()) + ); + } + + #[tokio::test] + async fn rejects_invalid_hex_payload() { + let injector = OpReturnInjector::default(); + let error = injector + .queue_from_hex("zz") + .await + .expect_err("invalid hex should be rejected"); + + assert!(matches!(error, OpReturnInjectorError::InvalidHex(_))); + } + + #[tokio::test] + async fn rejects_payloads_larger_than_standard_op_return() { + let injector = OpReturnInjector::default(); + let payload = "aa".repeat(MAX_OP_RETURN_PAYLOAD_BYTES + 1); + let error = injector + .queue_from_hex(&payload) + .await + .expect_err("oversized payload should be rejected"); + + assert_eq!( + error, + OpReturnInjectorError::PayloadTooLarge(MAX_OP_RETURN_PAYLOAD_BYTES + 1) + ); + } +} From f5271e3355e1cb09b474ee4cb7ce924956968dd2 Mon Sep 17 00:00:00 2001 From: fi3 Date: Wed, 13 May 2026 10:49:59 +0200 Subject: [PATCH 2/3] ADD authenticated proxy-side JD merge-mining export so the bridge can submit exact found blocks The proxy reuses the existing API server behind a shared secret to accept the desired RSK OP_RETURN payload and target, and to let the sibling bridge poll complete found-job data without depending on raw block lookups from bitcoind. The desired merge-mining payload stays active across templates, but the source of truth for a found job is the exact payload and target that were actually applied to that template. Found jobs are exported only when the solved coinbase still proves that template-scoped RSK data, and they carry the witness-stripped coinbase, rebuilt Bitcoin header and block hash, block transaction count, and the RSKIP92 sibling-only merkle proof expected for multi-transaction Rootstock submissions. Single-transaction jobs still remain on the coinbase-only path. Job and chain-state tracking are also kept aligned with JD job rotation so delayed or stale shares are matched to the correct template instead of the latest refresh, which prevents duplicate or false found jobs and stops stale custom-job submissions from being turned into invalid Bitcoin block candidates. --- Cargo.lock | 211 ++- src/api/mod.rs | 21 +- src/api/routes.rs | 419 ++++- src/config.rs | 19 + src/jd_client/job_declarator/mod.rs | 171 +- src/jd_client/mining_downstream/mod.rs | 337 +++- src/jd_client/mod.rs | 43 +- src/jd_client/template_receiver/mod.rs | 27 + src/lib.rs | 15 +- src/op_return_injector.rs | 439 ++++- src/router/mod.rs | 1 + src/valid_job_tracker.rs | 2020 ++++++++++++++++++++++++ tests/library_init.rs | 1 + 13 files changed, 3472 insertions(+), 252 deletions(-) create mode 100644 src/valid_job_tracker.rs diff --git a/Cargo.lock b/Cargo.lock index 0a53112..51a9af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "binary_sv2" version = "5.0.1" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "buffer_sv2 3.0.1", "derive_codec_sv2 1.1.2", @@ -313,9 +313,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.8" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" dependencies = [ "base58ck", "base64 0.21.7", @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "bitcoin-capnp-types" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e20759e30b46af17a13f2e34c9e090c3672938b5a0c22358cba971d1a8f5d492" +checksum = "a58af19b421a85566a1d46617f40b18eea77d565a0271bdff2deabf0d27c075c" dependencies = [ "capnp", "capnpc", @@ -367,8 +367,8 @@ dependencies = [ [[package]] name = "bitcoin_core_sv2" -version = "0.1.1" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.2.0" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "bitcoin-capnp-types", @@ -469,7 +469,7 @@ dependencies = [ [[package]] name = "buffer_sv2" version = "3.0.1" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "aes-gcm", ] @@ -500,18 +500,18 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "capnp" -version = "0.21.7" +version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e92edec8974fcd7ece90bb021db782abe14a61c10c817f197f700fef7430eb8" +checksum = "63da65e5e9ffc3b8f993d4ad222a548152549351a643f6b850a7773cb6ff2809" dependencies = [ "embedded-io", ] [[package]] name = "capnp-futures" -version = "0.21.0" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d04478adeb234836f886ec554a0d96e3af3a939ba7b3962af5addddf7ab71231" +checksum = "73b69dfddccc57844f9a90f9d72b44b97c326914851ea94fb7da40ef9cad6e8d" dependencies = [ "capnp", "futures-channel", @@ -520,9 +520,9 @@ dependencies = [ [[package]] name = "capnp-rpc" -version = "0.21.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e9c19ef52ff1b9c9822fb21bfa68a72bc58711676295ff06eb88e64c7877f7" +checksum = "e3c74c337e87f75f3174ffb3513738ab0acc631c04f64cc33b867c60f84771da" dependencies = [ "capnp", "capnp-futures", @@ -531,18 +531,18 @@ dependencies = [ [[package]] name = "capnpc" -version = "0.21.4" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6da96dcb0a0e0c526daf42bac55e1550f18ad973df9ef9ba75204f332c80ad16" +checksum = "fca02be865c8c5a78bfc24b9819006ab6b59bef238467203928e26459557af93" dependencies = [ "capnp", ] [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "channels_sv2" version = "5.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "bitcoin", @@ -665,7 +665,7 @@ dependencies = [ [[package]] name = "codec_sv2" version = "5.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "buffer_sv2 3.0.1", @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "common_messages_sv2" version = "7.1.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", ] @@ -1040,7 +1040,7 @@ dependencies = [ [[package]] name = "derive_codec_sv2" version = "1.1.2" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" [[package]] name = "derive_codec_sv2" @@ -1112,7 +1112,7 @@ dependencies = [ [[package]] name = "dmnd-client" -version = "0.3.15" +version = "0.3.17" dependencies = [ "async-recursion", "axum", @@ -1184,9 +1184,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "embedded-io" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" [[package]] name = "encode_unicode" @@ -1228,7 +1228,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "extensions_sv2" version = "0.1.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", ] @@ -1247,13 +1247,12 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1333,7 +1332,7 @@ dependencies = [ [[package]] name = "framing_sv2" version = "6.0.1" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "buffer_sv2 3.0.1", @@ -1496,9 +1495,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1516,7 +1515,7 @@ dependencies = [ [[package]] name = "handlers_sv2" version = "0.3.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "common_messages_sv2 7.1.0", @@ -1550,9 +1549,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1601,9 +1600,12 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366fa3443ac84474447710ec17bb00b05dfbd096137817981e86f992f21a2793" +checksum = "a7289f6b628ce69fb1a371d0fdcf8ff38cd93ec00e3010eb055d1e044998c8d1" +dependencies = [ + "arrayvec", +] [[package]] name = "hex_lit" @@ -1886,7 +1888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1915,8 +1917,8 @@ dependencies = [ [[package]] name = "integration_tests_sv2" -version = "0.1.1" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.2.0" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "clap", @@ -1944,16 +1946,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1968,8 +1960,8 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jd_client_sv2" -version = "0.1.4" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.2.0" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "bitcoin_core_sv2", @@ -1985,8 +1977,8 @@ dependencies = [ [[package]] name = "jd_server_sv2" -version = "0.1.2" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.2.0" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "async-trait", @@ -2031,16 +2023,16 @@ dependencies = [ [[package]] name = "job_declaration_sv2" version = "7.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", ] [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -2123,10 +2115,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -2218,7 +2207,7 @@ dependencies = [ [[package]] name = "mining_sv2" version = "9.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", ] @@ -2231,7 +2220,7 @@ checksum = "867b1f11e0545ad5ebbddd8a9f18756d9657a6758bf10d96cf15ddd0b726b650" dependencies = [ "bech32", "bitcoin", - "hex-conservative 1.0.1", + "hex-conservative 1.1.0", ] [[package]] @@ -2307,7 +2296,7 @@ dependencies = [ [[package]] name = "noise_sv2" version = "1.4.2" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "aes-gcm", "chacha20poly1305", @@ -2394,15 +2383,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2426,9 +2414,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -2498,7 +2486,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2506,7 +2494,7 @@ dependencies = [ [[package]] name = "parsers_sv2" version = "0.3.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "common_messages_sv2 7.1.0", @@ -2603,12 +2591,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "poly1305" version = "0.8.0" @@ -2634,8 +2616,8 @@ dependencies = [ [[package]] name = "pool_sv2" -version = "0.2.2" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.3.0" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "bitcoin_core_sv2", @@ -2919,15 +2901,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3505,9 +3478,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3556,7 +3529,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stratum-apps" version = "0.4.0" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "axum", @@ -3593,7 +3566,7 @@ dependencies = [ [[package]] name = "stratum-core" version = "0.3.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "bitcoin", @@ -3616,7 +3589,7 @@ dependencies = [ [[package]] name = "stratum_translation" version = "0.2.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "bitcoin", @@ -3655,7 +3628,7 @@ dependencies = [ [[package]] name = "sv1_api" version = "4.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", "bitcoin_hashes 0.3.2", @@ -3779,7 +3752,7 @@ dependencies = [ [[package]] name = "template_distribution_sv2" version = "5.0.0" -source = "git+https://github.com/stratum-mining/stratum?branch=main#f14226ae146c9dcde1e7191ccb8fe185e1751d3c" +source = "git+https://github.com/stratum-mining/stratum?branch=main#6ab03af290c94fb080419cb2a744bdd10e51c279" dependencies = [ "binary_sv2 5.0.1", ] @@ -3900,9 +3873,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4050,20 +4023,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4166,8 +4139,8 @@ dependencies = [ [[package]] name = "translator_sv2" -version = "0.2.3" -source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#ecc9eb8ddabe24e4999b671107a60e993c8d788f" +version = "0.2.5" +source = "git+https://github.com/stratum-mining/sv2-apps.git?branch=main#4c0a65680c91f308fa37e855c4e30c4458434f71" dependencies = [ "async-channel", "clap", @@ -4291,9 +4264,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ "indexmap", "serde", @@ -4303,9 +4276,9 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", @@ -4394,9 +4367,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -4407,9 +4380,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -4417,9 +4390,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4427,9 +4400,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -4440,9 +4413,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -4483,9 +4456,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -5002,9 +4975,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/src/api/mod.rs b/src/api/mod.rs index 4faae7a..1913842 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,7 +4,10 @@ pub mod stats; mod utils; use std::sync::Arc; -use crate::{api::bitcoin_rpc::BitcoindRpc, router::Router, Configuration}; +use crate::{ + api::bitcoin_rpc::BitcoindRpc, op_return_injector::OpReturnInjector, router::Router, + valid_job_tracker::ValidJobTracker, Configuration, +}; use axum::{ routing::{get, post}, Router as AxumRouter, @@ -19,6 +22,9 @@ pub struct AppState { stats_sender: StatsSender, downstream_handoff: crate::DownstreamHandoffSender, prioritizing_txs: Option, + op_return_injector: OpReturnInjector, + valid_job_tracker: ValidJobTracker, + api_secret: Option, } #[derive(Clone)] @@ -31,6 +37,8 @@ pub(crate) async fn start( router: Router, stats_sender: StatsSender, downstream_handoff: crate::DownstreamHandoffSender, + op_return_injector: OpReturnInjector, + valid_job_tracker: ValidJobTracker, ) { let prioritizing_txs = Configuration::bitcoind_rpc_config().map(|config| { let rpc = Arc::new(BitcoindRpc::new( @@ -50,6 +58,9 @@ pub(crate) async fn start( stats_sender, downstream_handoff, prioritizing_txs, + op_return_injector, + valid_job_tracker, + api_secret: Configuration::api_secret(), }; let app = AxumRouter::new() .route("/api/health", get(Api::health_check)) @@ -58,6 +69,14 @@ pub(crate) async fn start( "/api/tx/prioritized", get(Api::get_prioritized_transactions), ) + .route( + "/api/coinbase/op-return", + post(Api::queue_coinbase_op_return), + ) + .route( + "/api/merge-mining/found-job", + get(Api::poll_found_valid_job), + ) .route("/api/pool/info", get(Api::get_pool_info)) .route("/api/stats/miners", get(Api::get_downstream_stats)) .route("/api/stats/aggregate", get(Api::get_aggregate_stats)) diff --git a/src/api/routes.rs b/src/api/routes.rs index 5c99628..5c777b6 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -1,13 +1,16 @@ use super::{utils::get_cpu_and_memory_usage, AppState}; use crate::config::Configuration; use crate::proxy_state::ProxyState; +use crate::valid_job_tracker::FoundValidJob; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::IntoResponse, Json, }; -use serde::Serialize; +#[cfg(test)] +use bitcoin::hashes::Hash; +use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; pub struct Api {} @@ -225,6 +228,106 @@ impl Api { (StatusCode::OK, Json(APIResponse::success(Some(response)))) } + + pub async fn queue_coinbase_op_return( + State(state): State, + Json(request): Json, + ) -> impl IntoResponse { + let Some(secret) = state.api_secret.as_deref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(APIResponse::::error(Some( + "OP_RETURN API is disabled: API_SECRET is not configured".to_string(), + ))), + ) + .into_response(); + }; + + if request.secret != secret { + return ( + StatusCode::UNAUTHORIZED, + Json(APIResponse::::error(Some( + "Invalid API secret".to_string(), + ))), + ) + .into_response(); + } + + match state + .op_return_injector + .queue_from_hex_with_rsk_target(&request.data_hex, request.rsk_target_hex.as_deref()) + .await + { + Ok(queued) => { + info!( + "Set desired OP_RETURN for JD templates (payload={} bytes, txout={} bytes, replaced_pending={})", + queued.payload_len_bytes, + queued.tx_out_len_bytes, + queued.replaced_pending + ); + ( + StatusCode::ACCEPTED, + Json(APIResponse::success(Some(QueueCoinbaseOpReturnResponse { + payload_len_bytes: queued.payload_len_bytes, + tx_out_len_bytes: queued.tx_out_len_bytes, + replaced_pending: queued.replaced_pending, + }))), + ) + .into_response() + } + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(APIResponse::::error(Some( + error.to_string(), + ))), + ) + .into_response(), + } + } + + pub async fn poll_found_valid_job( + State(state): State, + Query(query): Query, + ) -> impl IntoResponse { + let Some(secret) = state.api_secret.as_deref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(APIResponse::::error(Some( + "Merge-mining polling API is disabled: API_SECRET is not configured" + .to_string(), + ))), + ) + .into_response(); + }; + + if query.secret != secret { + return ( + StatusCode::UNAUTHORIZED, + Json(APIResponse::::error(Some( + "Invalid API secret".to_string(), + ))), + ) + .into_response(); + } + + match state.valid_job_tracker.take_found_job() { + Ok(Some(found_job)) => { + (StatusCode::OK, Json(APIResponse::success(Some(found_job)))).into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(APIResponse::::success(None)), + ) + .into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(APIResponse::::error(Some( + error.to_string(), + ))), + ) + .into_response(), + } + } } fn is_authorized_for_tx_prioritization(headers: &HeaderMap, expected_token: &str) -> bool { @@ -257,6 +360,26 @@ struct APIResponse { data: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct QueueCoinbaseOpReturnRequest { + secret: String, + data_hex: String, + #[serde(default)] + rsk_target_hex: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PollFoundValidJobQuery { + secret: String, +} + +#[derive(Debug, Serialize)] +struct QueueCoinbaseOpReturnResponse { + payload_len_bytes: usize, + tx_out_len_bytes: usize, + replaced_pending: bool, +} + impl APIResponse { fn success(data: Option) -> Self { APIResponse { @@ -310,6 +433,9 @@ async fn health_check_reports_full_translator_handoff() { )), api_tx_token: "api-token".to_string(), }), + op_return_injector: crate::op_return_injector::OpReturnInjector::default(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: None, }; let response = Api::health_check(State(state)).await.into_response(); @@ -332,6 +458,9 @@ async fn send_tx_reports_unavailable_when_rpc_is_disabled() { stats_sender: crate::api::stats::StatsSender::new(), downstream_handoff: handoff_tx, prioritizing_txs: None, + op_return_injector: crate::op_return_injector::OpReturnInjector::default(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: None, }; let response = Api::send_tx_to_bitcoind( @@ -368,6 +497,9 @@ async fn send_tx_rejects_missing_api_tx_token_header() { )), api_tx_token: "api-token".to_string(), }), + op_return_injector: crate::op_return_injector::OpReturnInjector::default(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: None, }; let response = Api::send_tx_to_bitcoind( @@ -408,6 +540,9 @@ async fn get_prioritized_transactions_returns_snapshot() { )), api_tx_token: "api-token".to_string(), }), + op_return_injector: crate::op_return_injector::OpReturnInjector::default(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: None, }; let mut headers = HeaderMap::new(); @@ -439,3 +574,283 @@ fn tx_prioritization_auth_accepts_matching_bearer_token() { assert!(is_authorized_for_tx_prioritization(&headers, "api-token")); } + +#[tokio::test] +async fn queue_coinbase_op_return_rejects_invalid_secret() { + use axum::extract::State; + use axum::response::IntoResponse; + use tokio::sync::mpsc; + + let auth_pub_k = crate::AUTH_PUB_KEY.parse().expect("Invalid public key"); + let router = crate::router::Router::new(vec![], auth_pub_k, None, None); + + let (handoff_tx, _handoff_rx) = mpsc::channel(1); + let injector = crate::op_return_injector::OpReturnInjector::default(); + let state = AppState { + router, + stats_sender: crate::api::stats::StatsSender::new(), + downstream_handoff: handoff_tx, + prioritizing_txs: None, + op_return_injector: injector.clone(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: Some("topsecret".to_string()), + }; + + let response = Api::queue_coinbase_op_return( + State(state), + Json(QueueCoinbaseOpReturnRequest { + secret: "wrong".to_string(), + data_hex: "aa".to_string(), + rsk_target_hex: None, + }), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let mut template = make_empty_template_for_tests(); + let applied = injector + .apply_pending_to_template(&mut template) + .await + .expect("apply should succeed"); + assert!(applied.is_none(), "invalid secret must not queue payload"); +} + +#[tokio::test] +async fn queue_coinbase_op_return_accepts_valid_secret() { + use axum::extract::State; + use axum::response::IntoResponse; + use tokio::sync::mpsc; + + let auth_pub_k = crate::AUTH_PUB_KEY.parse().expect("Invalid public key"); + let router = crate::router::Router::new(vec![], auth_pub_k, None, None); + + let (handoff_tx, _handoff_rx) = mpsc::channel(1); + let injector = crate::op_return_injector::OpReturnInjector::default(); + let state = AppState { + router, + stats_sender: crate::api::stats::StatsSender::new(), + downstream_handoff: handoff_tx, + prioritizing_txs: None, + op_return_injector: injector.clone(), + valid_job_tracker: crate::valid_job_tracker::ValidJobTracker::default(), + api_secret: Some("topsecret".to_string()), + }; + + let response = Api::queue_coinbase_op_return( + State(state), + Json(QueueCoinbaseOpReturnRequest { + secret: "topsecret".to_string(), + data_hex: "deadbeef".to_string(), + rsk_target_hex: None, + }), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let mut template = make_empty_template_for_tests(); + let applied = injector + .apply_pending_to_template(&mut template) + .await + .expect("apply should succeed"); + assert!(applied.is_some(), "valid secret should queue payload"); + assert_eq!(template.coinbase_tx_outputs_count, 1); +} + +#[tokio::test] +async fn poll_found_valid_job_returns_recorded_event() { + use axum::response::IntoResponse; + use axum::{ + body::to_bytes, + extract::{Query, State}, + }; + use bitcoin::{consensus::serialize, hex::FromHex}; + use tokio::sync::mpsc; + + let auth_pub_k = crate::AUTH_PUB_KEY.parse().expect("Invalid public key"); + let router = crate::router::Router::new(vec![], auth_pub_k, None, None); + + let (handoff_tx, _handoff_rx) = mpsc::channel(1); + let op_return_injector = crate::op_return_injector::OpReturnInjector::default(); + let valid_job_tracker = + crate::valid_job_tracker::ValidJobTracker::new(op_return_injector.clone()); + let payload_hex = "52534b424c4f434b3adeadbeef"; + let payload_bytes = Vec::from_hex(payload_hex).expect("payload should decode"); + let mut template = make_empty_template_for_tests(); + template.template_id = 7; + op_return_injector + .queue_from_hex(payload_hex) + .await + .expect("payload should queue"); + op_return_injector + .apply_pending_to_template(&mut template) + .await + .expect("payload should apply") + .expect("template should receive payload"); + let coinbase_tx = serialize(&make_coinbase_tx_for_found_job_tests(&payload_bytes)); + let n_bits = 0x207f_ffff; + let header_nonce = find_pow_valid_nonce_for_found_job_tests( + [0x11; 32], + n_bits, + 8, + 9, + &bitcoin::consensus::deserialize::(&coinbase_tx) + .expect("coinbase bytes should decode"), + ); + valid_job_tracker + .cache_template_context(7, [0x11; 32], n_bits, vec![], 1) + .expect("tracker should cache template context"); + let found_job = valid_job_tracker + .record_found_job(7, 8, 9, header_nonce, &coinbase_tx) + .expect("tracker should record event") + .expect("template context should exist"); + crate::valid_job_tracker::validate_found_job_against_rskj_contract_for_tests(&found_job, None) + .expect("single-tx found job should satisfy the bridge/RskJ contract"); + + let state = AppState { + router, + stats_sender: crate::api::stats::StatsSender::new(), + downstream_handoff: handoff_tx, + prioritizing_txs: None, + op_return_injector, + valid_job_tracker, + api_secret: Some("topsecret".to_string()), + }; + + let response = Api::poll_found_valid_job( + State(state), + Query(PollFoundValidJobQuery { + secret: "topsecret".to_string(), + }), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should be readable"); + let json: serde_json::Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(json["success"], true); + assert_eq!(json["data"]["template_id"], 7); + assert_eq!(json["data"]["version"], 8); + assert_eq!(json["data"]["header_timestamp"], 9); + assert_eq!(json["data"]["header_nonce"], header_nonce); + assert_eq!( + json["data"]["bitcoin_block_hash_hex"], + found_job.bitcoin_block_hash_hex + ); + assert_eq!(json["data"]["block_header_hex"], found_job.block_header_hex); + assert_eq!(json["data"]["coinbase_tx_hex"], found_job.coinbase_tx_hex); + assert_eq!(json["data"]["merkle_hashes_hex"], serde_json::json!([])); + assert_eq!(json["data"]["block_tx_count"], 1); + assert_eq!(json["data"]["op_return_payload_hex"], payload_hex); + let returned_coinbase = + Vec::::from_hex(json["data"]["coinbase_tx_hex"].as_str().unwrap()).unwrap(); + let returned_coinbase: bitcoin::Transaction = + bitcoin::consensus::deserialize(&returned_coinbase).unwrap(); + assert!( + returned_coinbase.input[0].witness.is_empty(), + "API must return witness-stripped coinbase bytes for RskJ compatibility" + ); + assert!( + crate::valid_job_tracker::coinbase_contains_expected_op_return_payload( + &returned_coinbase, + &payload_bytes + ), + "returned coinbase must contain the same payload reported by op_return_payload_hex" + ); +} + +#[cfg(test)] +fn make_empty_template_for_tests( +) -> roles_logic_sv2::template_distribution_sv2::NewTemplate<'static> { + use binary_sv2::{Seq0255, B0255, B064K, U256}; + use std::convert::TryInto; + + let coinbase_prefix: B0255<'static> = Vec::new() + .try_into() + .expect("coinbase prefix should fit in B0255"); + let coinbase_tx_outputs: B064K<'static> = Vec::new() + .try_into() + .expect("coinbase outputs should fit in B064K"); + let merkle_path: Seq0255<'static, U256<'static>> = Vec::new().into(); + + roles_logic_sv2::template_distribution_sv2::NewTemplate { + template_id: 1, + future_template: false, + version: 0, + coinbase_tx_version: 1, + coinbase_prefix, + coinbase_tx_input_sequence: 0, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs, + coinbase_tx_locktime: 0, + merkle_path, + } +} + +#[cfg(test)] +fn make_coinbase_tx_for_found_job_tests(payload: &[u8]) -> bitcoin::Transaction { + use bitcoin::{ + absolute::LockTime, script::PushBytesBuf, Amount, OutPoint, ScriptBuf, Sequence, TxIn, + TxOut, Witness, + }; + + bitcoin::Transaction { + version: bitcoin::transaction::Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: vec![0x03, 0x01, 0x51].into(), + sequence: Sequence::MAX, + witness: Witness::from_slice(&[vec![0x42; 32]]), + }], + output: vec![ + TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload.to_vec()).unwrap(), + ), + }, + ], + } +} + +#[cfg(test)] +fn find_pow_valid_nonce_for_found_job_tests( + prev_hash: [u8; 32], + n_bits: u32, + version: u32, + header_timestamp: u32, + coinbase_tx: &bitcoin::Transaction, +) -> u32 { + let coinbase_txid = coinbase_tx.compute_txid(); + for nonce in 0..=u32::MAX { + let header = bitcoin::block::Header { + version: bitcoin::block::Version::from_consensus(version as i32), + prev_blockhash: bitcoin::BlockHash::from_byte_array(prev_hash), + merkle_root: bitcoin::TxMerkleNode::from_byte_array( + coinbase_txid.to_raw_hash().to_byte_array(), + ), + time: header_timestamp, + bits: bitcoin::CompactTarget::from_consensus(n_bits), + nonce, + }; + if header.target().is_met_by(header.block_hash()) { + return nonce; + } + } + + panic!("failed to find a PoW-valid nonce for found-job API test"); +} diff --git a/src/config.rs b/src/config.rs index 4f3bd14..5ba1445 100644 --- a/src/config.rs +++ b/src/config.rs @@ -81,6 +81,7 @@ struct Args { rpc_fee_delta: Option, #[clap(long)] api_tx_token: Option, + api_secret: Option, } #[derive(Serialize, Deserialize)] @@ -112,6 +113,7 @@ struct ConfigFile { rpc_pwd: Option, rpc_fee_delta: Option, api_tx_token: Option, + api_secret: Option, } impl ConfigFile { @@ -144,6 +146,7 @@ impl ConfigFile { rpc_pwd: None, rpc_fee_delta: None, api_tx_token: None, + api_secret: None, } } } @@ -176,6 +179,7 @@ pub struct Configuration { miner_name: Option, prioritizing_txs_config: Option, missing_prioritizing_txs_variables: Vec<&'static str>, + api_secret: Option, } #[derive(Clone, Debug)] @@ -238,6 +242,7 @@ and make that test pass." rpc_pwd: String, rpc_fee_delta: String, api_tx_token: String, + api_secret: Option, ) -> Self { if let Err(error) = Self::validate_supported_delay(delay) { panic!("{error}"); @@ -279,6 +284,7 @@ and make that test pass." miner_name, prioritizing_txs_config, missing_prioritizing_txs_variables, + api_secret, } } @@ -365,6 +371,7 @@ and make that test pass." "password".to_string(), "100000000".to_string(), "api-token".to_string(), + None, ) } @@ -556,6 +563,13 @@ and make that test pass." } } + pub fn api_secret() -> Option { + Self::cfg() + .api_secret + .clone() + .filter(|secret| !secret.trim().is_empty()) + } + // Loads config from CLI args, config file, and env vars with precedence: CLI > file > env. pub fn from_cli() -> Self { let args = Args::parse(); @@ -630,6 +644,10 @@ and make that test pass." .or(config.api_tx_token) .or_else(|| std::env::var("API_TX_TOKEN").ok()) .unwrap_or_default(); + let api_secret = args + .api_secret + .or(config.api_secret) + .or_else(|| std::env::var("API_SECRET").ok()); if let Some(ref miner_name) = miner_name { validate_miner_name(miner_name).unwrap_or_else(|e| panic!("{e}")); } @@ -804,6 +822,7 @@ and make that test pass." rpc_pwd, rpc_fee_delta, api_tx_token, + api_secret, ) } } diff --git a/src/jd_client/job_declarator/mod.rs b/src/jd_client/job_declarator/mod.rs index 00b7e3c..1ff0d1e 100644 --- a/src/jd_client/job_declarator/mod.rs +++ b/src/jd_client/job_declarator/mod.rs @@ -40,6 +40,7 @@ use setup_connection::SetupConnectionHandler; use crate::{ proxy_state::{JdState, PoolState, ProxyState}, shared::utils::AbortOnDrop, + valid_job_tracker::ValidJobTracker, }; use super::{error::Error, mining_upstream::Upstream}; @@ -50,6 +51,7 @@ pub struct LastDeclareJob { template: NewTemplate<'static>, coinbase_pool_output: Vec, tx_list: Seq064K<'static, B016M<'static>>, + block_tx_count: u32, } #[derive(Debug)] @@ -71,12 +73,14 @@ pub struct JobDeclarator { NewTemplate<'static>, // pool's outputs Vec, + u32, ), BuildNoHashHasher, >, up: Arc>, pub coinbase_tx_prefix: B064K<'static>, pub coinbase_tx_suffix: B064K<'static>, + valid_job_tracker: ValidJobTracker, pub task_manager: Arc>, } @@ -86,6 +90,7 @@ impl JobDeclarator { authority_public_key: [u8; 32], up: Arc>, should_log_when_connected: bool, + valid_job_tracker: ValidJobTracker, ) -> Result<(Arc>, AbortOnDrop), Error> { let stream = tokio::net::TcpStream::connect(address).await?; let initiator = Initiator::from_raw_k(authority_public_key)?; @@ -118,6 +123,7 @@ impl JobDeclarator { up, coinbase_tx_prefix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), coinbase_tx_suffix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), + valid_job_tracker, set_new_prev_hash_counter: 0, task_manager, })); @@ -246,6 +252,8 @@ impl JobDeclarator { .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; let template_transactions = tx_list_.to_vec(); + let block_tx_count = + block_tx_count_from_request_transaction_list_len(template_transactions.len()); let prioritized_txids = crate::prioritized_transactions::snapshot(); let mut template_txids = HashSet::with_capacity(template_transactions.len()); let mut tx_list: Vec = Vec::new(); @@ -297,6 +305,7 @@ impl JobDeclarator { template, coinbase_pool_output, tx_list: tx_list_.clone(), + block_tx_count, }; Self::update_last_declare_job_sent(self_mutex, id, last_declare)?; let frame: StdFrame = @@ -377,6 +386,7 @@ impl JobDeclarator { merkle_path, template, last_declare.coinbase_pool_output, + last_declare.block_tx_count, ), ); }) { @@ -398,20 +408,53 @@ impl JobDeclarator { let mut pool_outs = last_declare.coinbase_pool_output; pool_outs.append(&mut template_outs); match set_new_prev_hash { - Some(p) => if let Err(e) = Upstream::set_custom_jobs( - &up, - last_declare_mining_job_sent, - p, - merkle_path, - new_token, - template.coinbase_tx_version, - template.coinbase_prefix, - template.coinbase_tx_input_sequence, - template.coinbase_tx_value_remaining, - pool_outs, - template.coinbase_tx_locktime, - template.template_id - ).await {error!("Failed to set custom jobd: {e}"); ProxyState::update_jd_state(JdState::Down);break;}, + Some(p) => { + let (valid_job_tracker, coinbase_tx_prefix, coinbase_tx_suffix) = + match self_mutex.safe_lock(|s| { + ( + s.valid_job_tracker.clone(), + s.coinbase_tx_prefix.to_vec(), + s.coinbase_tx_suffix.to_vec(), + ) + }) + { + Ok(context) => context, + Err(e) => { + error!("{e}"); + ProxyState::update_jd_state(JdState::Down); + break; + } + }; + cache_ready_template_context( + &valid_job_tracker, + template.template_id, + &p, + &merkle_path, + last_declare.block_tx_count, + &coinbase_tx_prefix, + &coinbase_tx_suffix, + ); + if let Err(e) = Upstream::set_custom_jobs( + &up, + last_declare_mining_job_sent, + p, + merkle_path, + new_token, + template.coinbase_tx_version, + template.coinbase_prefix, + template.coinbase_tx_input_sequence, + template.coinbase_tx_value_remaining, + pool_outs, + template.coinbase_tx_locktime, + template.template_id, + ) + .await + { + error!("Failed to set custom jobd: {e}"); + ProxyState::update_jd_state(JdState::Down); + break; + } + } None => panic!("Invalid state we received a NewTemplate not future, without having received a set new prev hash") } } @@ -471,7 +514,7 @@ impl JobDeclarator { error!("{}", Error::JobDeclaratorMutexCorrupted); return; }; - let (job, up, merkle_path, template, mut pool_outs) = loop { + let (job, up, merkle_path, template, mut pool_outs, block_tx_count, valid_job_tracker) = loop { match self_mutex.safe_lock(|s| { if s.set_new_prev_hash_counter > 1 && s.last_set_new_prev_hash != Some(set_new_prev_hash.clone()) @@ -480,13 +523,21 @@ impl JobDeclarator { s.set_new_prev_hash_counter -= 1; Some(None) } else { - s.future_jobs - .remove(&id) - .map(|(job, merkle_path, template, pool_outs)| { + s.future_jobs.remove(&id).map( + |(job, merkle_path, template, pool_outs, block_tx_count)| { s.future_jobs = HashMap::with_hasher(BuildNoHashHasher::default()); s.set_new_prev_hash_counter -= 1; - Some((job, s.up.clone(), merkle_path, template, pool_outs)) - }) + Some(( + job, + s.up.clone(), + merkle_path, + template, + pool_outs, + block_tx_count, + s.valid_job_tracker.clone(), + )) + }, + ) } }) { Ok(Some(Some(future_job_tuple))) => break future_job_tuple, @@ -509,6 +560,24 @@ impl JobDeclarator { let signed_token = job.mining_job_token.clone(); let mut template_outs = template.coinbase_tx_outputs.to_vec(); pool_outs.append(&mut template_outs); + let (coinbase_tx_prefix, coinbase_tx_suffix) = match self_mutex + .safe_lock(|s| (s.coinbase_tx_prefix.to_vec(), s.coinbase_tx_suffix.to_vec())) + { + Ok(context) => context, + Err(_) => { + error!("{}", Error::JobDeclaratorMutexCorrupted); + return; + } + }; + cache_ready_template_context( + &valid_job_tracker, + template.template_id, + &set_new_prev_hash, + &merkle_path, + block_tx_count, + &coinbase_tx_prefix, + &coinbase_tx_suffix, + ); if let Err(e) = Upstream::set_custom_jobs( &up, job, @@ -647,9 +716,63 @@ async fn check_missing_prioritized_txids(missing_txids: Vec, template_id } } +// Template-distribution `RequestTransactionDataSuccess.transaction_list` explicitly excludes the +// coinbase transaction, so the full block transaction count is always `len + 1`. +fn block_tx_count_from_request_transaction_list_len(non_coinbase_tx_count: usize) -> u32 { + let non_coinbase_tx_count: u32 = non_coinbase_tx_count + .try_into() + .expect("request transaction list length should fit in u32"); + non_coinbase_tx_count + .checked_add(1) + .expect("block transaction count overflowed") +} + +fn cache_ready_template_context( + valid_job_tracker: &ValidJobTracker, + template_id: u64, + set_new_prev_hash: &SetNewPrevHash<'static>, + merkle_path: &Seq0255<'static, U256<'static>>, + block_tx_count: u32, + coinbase_tx_prefix: &[u8], + coinbase_tx_suffix: &[u8], +) { + let prev_hash: [u8; 32] = set_new_prev_hash + .prev_hash + .to_vec() + .try_into() + .expect("SetNewPrevHash prev_hash should always be 32 bytes"); + // `NewTemplate.merkle_path` is already the bottom-up sibling path in the exact raw byte order + // consumed by `merkle_root_from_path_`. Keep it unchanged in cached template context; only the + // RSK RPC serialization layer reverses bytes for wire hex. + let merkle_hashes = merkle_path + .to_vec() + .into_iter() + .map(|hash| { + hash.to_vec() + .try_into() + .expect("merkle path hashes should always be 32 bytes") + }) + .collect(); + + if let Err(error) = valid_job_tracker.cache_template_context_with_coinbase_parts( + template_id, + prev_hash, + set_new_prev_hash.n_bits, + merkle_hashes, + block_tx_count, + coinbase_tx_prefix.to_vec(), + coinbase_tx_suffix.to_vec(), + ) { + error!( + "Failed to cache merge-mining template context for template {}: {}", + template_id, error + ); + } +} + #[cfg(test)] mod tests { - use super::missing_prioritized_txids; + use super::{block_tx_count_from_request_transaction_list_len, missing_prioritized_txids}; use std::collections::HashSet; #[test] @@ -670,4 +793,10 @@ mod tests { vec!["b".to_string()] ); } + + #[test] + fn block_tx_count_adds_coinbase_to_request_transaction_data_len() { + assert_eq!(block_tx_count_from_request_transaction_list_len(0), 1); + assert_eq!(block_tx_count_from_request_transaction_list_len(3), 4); + } } diff --git a/src/jd_client/mining_downstream/mod.rs b/src/jd_client/mining_downstream/mod.rs index 4b27852..37b6286 100644 --- a/src/jd_client/mining_downstream/mod.rs +++ b/src/jd_client/mining_downstream/mod.rs @@ -2,6 +2,7 @@ mod task_manager; use crate::{ proxy_state::{DownstreamType, JdState, ProxyState}, shared::utils::AbortOnDrop, + valid_job_tracker::{BitcoinBlockCandidateValidation, ValidJobTracker}, }; use tokio::time::{timeout, Duration}; @@ -25,6 +26,8 @@ use tokio::{ }; use tracing::{debug, error, warn}; +use std::{collections::HashMap, sync::Arc}; + use codec_sv2::{StandardEitherFrame, StandardSv2Frame}; use bitcoin::{consensus::Decodable, TxOut}; @@ -47,6 +50,8 @@ pub struct DownstreamMiningNode { miner_coinbase_output: Vec, // used to retreive the job id of the share that we send upstream last_template_id: u64, + valid_job_tracker: ValidJobTracker, + job_template_ids: HashMap, pub jd: Option>>, } @@ -111,7 +116,6 @@ impl DownstreamMiningNodeStatus { } use core::convert::TryInto; -use std::sync::Arc; impl DownstreamMiningNode { #[allow(clippy::too_many_arguments)] @@ -121,6 +125,7 @@ impl DownstreamMiningNode { solution_sender: TSender>, withhold: bool, miner_coinbase_output: Vec, + valid_job_tracker: ValidJobTracker, jd: Option>>, ) -> Self { let status = match upstream { @@ -138,6 +143,8 @@ impl DownstreamMiningNode { // Is used before sending the share to upstream in the main loop when we have a share. // Is upated in the message handler that si called earlier in the main loop. last_template_id: 0, + valid_job_tracker, + job_template_ids: HashMap::new(), jd, } } @@ -348,6 +355,8 @@ impl DownstreamMiningNode { mut new_template: NewTemplate<'static>, pool_output: &[u8], ) -> Result<(), JdClientError> { + let template_id = new_template.template_id; + let template_is_future = new_template.future_template; // Make sure to set the template handled to true since we do not have a channel opened yet // and template can not be handled without it we will lock template handling forever. if !self_mutex @@ -387,6 +396,17 @@ impl DownstreamMiningNode { let to_send = to_send.into_values(); for message in to_send { let message = if let Mining::NewExtendedMiningJob(job) = message { + self_mutex + .safe_lock(|s| { + remember_template_job( + &mut s.job_template_ids, + &mut s.prev_job_id, + template_is_future, + job.job_id, + template_id, + ); + }) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; let jd = self_mutex .safe_lock(|s| s.jd.clone()) .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)? @@ -429,6 +449,41 @@ impl DownstreamMiningNode { channel.on_new_prev_hash_from_tp(&new_prev_hash) }) .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)??; + self_mutex + .safe_lock(|s| { + s.prev_job_id = Some(job_id); + s.job_template_ids.clear(); + s.job_template_ids.insert(job_id, new_prev_hash.template_id); + }) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; + let valid_job_tracker = self_mutex + .safe_lock(|s| s.valid_job_tracker.clone()) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; + + let tracked_prev_hash: [u8; 32] = new_prev_hash + .prev_hash + .to_vec() + .try_into() + .expect("SetNewPrevHash prev_hash should always be 32 bytes"); + match valid_job_tracker.refresh_template_chain_state( + new_prev_hash.template_id, + tracked_prev_hash, + new_prev_hash.n_bits, + ) { + Ok(true) => {} + Ok(false) => { + debug!( + template_id = new_prev_hash.template_id, + "No cached merge-mining template context was available to refresh on SetNewPrevHash", + ); + } + Err(error) => { + warn!( + template_id = new_prev_hash.template_id, + "Failed to refresh merge-mining template context on SetNewPrevHash: {}", error + ); + } + } let channel_ids = self_mutex .safe_lock(|s| { @@ -584,6 +639,63 @@ impl &mut self, m: SubmitSharesExtended, ) -> Result, Error> { + let is_solo_miner = self.status.is_solo_miner(); + if share_uses_stale_job(self.prev_job_id, is_solo_miner, m.job_id) { + if !is_solo_miner && share_is_newer_than_current_job(self.prev_job_id, m.job_id) { + let full_extranonce = match self.status.get_channel() { + Ok(channel) => channel + .extranonce_from_downstream_extranonce(m.extranonce.clone().into()) + .map(|extranonce| extranonce.to_vec()), + Err(_) => None, + }; + + if let Some(full_extranonce) = full_extranonce.as_deref() { + if let Some(template_id) = self.job_template_ids.get(&m.job_id).copied() { + match self.valid_job_tracker.record_rsk_target_share( + template_id, + m.version, + m.ntime, + m.nonce, + full_extranonce, + ) { + Ok(Some(_)) => {} + Ok(None) => {} + Err(error) => { + error!( + "Failed to record stale-response RSK-target valid job for polling API: {error}" + ); + } + } + } else { + debug!( + submitted_job_id = m.job_id, + "Skipping stale-response RSK-target share because no current-prevhash template_id was cached for the JD job" + ); + } + } else { + debug!( + submitted_job_id = m.job_id, + "Skipping stale-response RSK-target share because the full extranonce could not be reconstructed" + ); + } + } + + warn!( + submitted_job_id = m.job_id, + current_job_id = self.prev_job_id, + "Rejecting JD share because it references a non-current clean job" + ); + let error = SubmitSharesError { + channel_id: m.channel_id, + sequence_number: m.sequence_number, + error_code: SubmitSharesError::stale_share_error_code() + .to_string() + .try_into() + .expect("static stale-share error code should fit in Str0255"), + }; + return Ok(SendTo::Respond(Mining::SubmitSharesError(error))); + } + match self .status .get_channel() @@ -598,6 +710,40 @@ impl if !self.status.is_solo_miner() { match m { Share::Extended(share) => { + if let Ok(channel) = self.status.get_channel() { + if let Some(full_extranonce) = channel + .extranonce_from_downstream_extranonce( + share.extranonce.clone().into(), + ) + { + match self.valid_job_tracker.record_rsk_target_share( + template_id, + share.version, + share.ntime, + share.nonce, + full_extranonce.as_ref(), + ) { + Ok(Some(_)) => {} + Ok(None) => {} + Err(error) => { + error!( + "Failed to record RSK-target valid job for polling API: {error}" + ); + } + } + } else { + debug!( + template_id, + "Skipping RSK-target share because the full extranonce could not be reconstructed" + ); + } + } else { + debug!( + template_id, + "Skipping RSK-target share because the channel state was unavailable" + ); + } + let for_upstream = Mining::SubmitSharesExtended(share); self.last_template_id = template_id; Ok(SendTo::RelayNewMessage(for_upstream)) @@ -618,36 +764,90 @@ impl )) => { match share { Share::Extended(share) => { - let solution_sender = self.solution_sender.clone(); - let solution = SubmitSolution { - template_id, - version: share.version, - header_timestamp: share.ntime, - header_nonce: share.nonce, - coinbase_tx: coinbase.try_into()?, - }; - tokio::spawn(async move { - if solution_sender.send(solution).await.is_err() { - error!("Downstream channel closed, couldn't send solution"); - } - }); - if !self.status.is_solo_miner() { - { - let jd = self.jd.clone(); - let mut share = share.clone(); - share.extranonce = extranonce.try_into()?; - // This do not need to be put in a task manager it always return - // fastly - tokio::task::spawn(async move { - if let Some(jd) = jd { - if let Err(e) = JobDeclarator::on_solution(&jd, share).await - { - error!("Jd Error on solution: {e:?}"); - // Set the proxy state to internal inconsistency - ProxyState::update_inconsistency(Some(1)); + let allow_local_block_submission = if self.status.is_solo_miner() { + true + } else { + match self.valid_job_tracker.validate_bitcoin_block_candidate( + template_id, + share.version, + share.ntime, + share.nonce, + coinbase.as_ref(), + ) { + Ok(BitcoinBlockCandidateValidation::Valid(_)) => { + match self.valid_job_tracker.record_found_job( + template_id, + share.version, + share.ntime, + share.nonce, + coinbase.as_ref(), + ) { + Ok(Some(_)) => {} + Ok(None) => {} + Err(error) => { + error!( + "Failed to record found valid job for polling API: {error}" + ); } } - }); + true + } + Ok(BitcoinBlockCandidateValidation::InvalidPow(candidate)) => { + warn!( + template_id, + bitcoin_block_hash_hex = %candidate.block_hash_hex, + "Suppressing local Bitcoin-target solution because the reconstructed header does not satisfy its own nBits" + ); + false + } + Ok(BitcoinBlockCandidateValidation::MissingTemplateContext) => { + warn!( + template_id, + "Suppressing local Bitcoin-target solution because exact template context was unavailable for consensus revalidation" + ); + false + } + Err(error) => { + error!( + "Failed to revalidate Bitcoin-target block candidate before local submission: {error}" + ); + false + } + } + }; + if allow_local_block_submission { + let solution_sender = self.solution_sender.clone(); + let solution = SubmitSolution { + template_id, + version: share.version, + header_timestamp: share.ntime, + header_nonce: share.nonce, + coinbase_tx: coinbase.try_into()?, + }; + tokio::spawn(async move { + if solution_sender.send(solution).await.is_err() { + error!("Downstream channel closed, couldn't send solution"); + } + }); + if !self.status.is_solo_miner() { + { + let jd = self.jd.clone(); + let mut share = share.clone(); + share.extranonce = extranonce.try_into()?; + // This do not need to be put in a task manager it always return + // fastly + tokio::task::spawn(async move { + if let Some(jd) = jd { + if let Err(e) = + JobDeclarator::on_solution(&jd, share).await + { + error!("Jd Error on solution: {e:?}"); + // Set the proxy state to internal inconsistency + ProxyState::update_inconsistency(Some(1)); + } + } + }); + } } } @@ -680,3 +880,82 @@ impl } } impl IsMiningDownstream for DownstreamMiningNode {} + +fn remember_template_job( + job_template_ids: &mut HashMap, + current_job_id: &mut Option, + template_is_future: bool, + job_id: u32, + template_id: u64, +) { + job_template_ids.insert(job_id, template_id); + if !template_is_future { + *current_job_id = Some(job_id); + } +} + +fn share_uses_stale_job( + current_job_id: Option, + is_solo_miner: bool, + submitted_job_id: u32, +) -> bool { + !is_solo_miner && current_job_id.is_some_and(|current| current != submitted_job_id) +} + +fn share_is_newer_than_current_job(current_job_id: Option, submitted_job_id: u32) -> bool { + current_job_id.is_some_and(|current| submitted_job_id > current) +} + +#[cfg(test)] +mod tests { + use super::{remember_template_job, share_is_newer_than_current_job, share_uses_stale_job}; + use std::collections::HashMap; + + #[test] + fn remember_template_job_advances_current_job_for_non_future_templates() { + let mut job_template_ids = HashMap::new(); + let mut current_job_id = Some(7); + + remember_template_job(&mut job_template_ids, &mut current_job_id, false, 8, 42); + + assert_eq!(current_job_id, Some(8)); + assert_eq!(job_template_ids.get(&8), Some(&42)); + } + + #[test] + fn remember_template_job_keeps_current_job_for_future_templates() { + let mut job_template_ids = HashMap::new(); + let mut current_job_id = Some(7); + + remember_template_job(&mut job_template_ids, &mut current_job_id, true, 8, 42); + + assert_eq!(current_job_id, Some(7)); + assert_eq!(job_template_ids.get(&8), Some(&42)); + } + + #[test] + fn newer_than_current_job_detects_false_stale_case() { + assert!(share_is_newer_than_current_job(Some(1), 7)); + } + + #[test] + fn newer_than_current_job_ignores_older_or_equal_jobs() { + assert!(!share_is_newer_than_current_job(Some(7), 7)); + assert!(!share_is_newer_than_current_job(Some(7), 6)); + } + + #[test] + fn stale_job_guard_rejects_older_non_solo_job_ids() { + assert!(share_uses_stale_job(Some(7), false, 6)); + } + + #[test] + fn stale_job_guard_allows_current_job_id() { + assert!(!share_uses_stale_job(Some(7), false, 7)); + } + + #[test] + fn stale_job_guard_is_disabled_for_solo_mode() { + assert!(!share_uses_stale_job(Some(7), true, 6)); + } +} diff --git a/src/jd_client/mod.rs b/src/jd_client/mod.rs index 9913e7a..acc4117 100644 --- a/src/jd_client/mod.rs +++ b/src/jd_client/mod.rs @@ -41,7 +41,9 @@ pub static IS_NEW_TEMPLATE_HANDLED: AtomicBool = AtomicBool::new(true); pub static IS_CUSTOM_JOB_SET: AtomicBool = AtomicBool::new(true); pub static IS_NEW_PHASH_ARRIVED: AtomicBool = AtomicBool::new(false); +use crate::op_return_injector::OpReturnInjector; use crate::proxy_state::{DownstreamType, ProxyState, TpState}; +use crate::valid_job_tracker::ValidJobTracker; use roles_logic_sv2::{parsers::Mining, utils::Mutex}; use std::{ net::{IpAddr, SocketAddr}, @@ -56,12 +58,22 @@ pub async fn start( sender: tokio::sync::mpsc::Sender>, up_receiver: tokio::sync::mpsc::Receiver>, up_sender: tokio::sync::mpsc::Sender>, + op_return_injector: OpReturnInjector, + valid_job_tracker: ValidJobTracker, ) -> Option { // This will not work when we implement support for multiple upstream IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); IS_NEW_TEMPLATE_HANDLED.store(true, std::sync::atomic::Ordering::Release); IS_NEW_PHASH_ARRIVED.store(false, std::sync::atomic::Ordering::Release); - initialize_jd(receiver, sender, up_receiver, up_sender).await + initialize_jd( + receiver, + sender, + up_receiver, + up_sender, + op_return_injector, + valid_job_tracker, + ) + .await } async fn initialize_jd( @@ -69,6 +81,8 @@ async fn initialize_jd( sender: tokio::sync::mpsc::Sender>, up_receiver: tokio::sync::mpsc::Receiver>, up_sender: tokio::sync::mpsc::Sender>, + op_return_injector: OpReturnInjector, + valid_job_tracker: ValidJobTracker, ) -> Option { let task_manager = TaskManager::initialize(); let abortable = match task_manager.safe_lock(|t| t.get_aborter()) { @@ -125,15 +139,22 @@ async fn initialize_jd( } }; - let (jd, jd_abortable) = - match JobDeclarator::new(address, auth_pub_k.into_bytes(), upstream.clone(), true).await { - Ok(c) => c, - Err(e) => { - error!("Failed to intialize Jd: {e}"); - drop(abortable); - return None; - } - }; + let (jd, jd_abortable) = match JobDeclarator::new( + address, + auth_pub_k.into_bytes(), + upstream.clone(), + true, + valid_job_tracker.clone(), + ) + .await + { + Ok(c) => c, + Err(e) => { + error!("Failed to intialize Jd: {e}"); + drop(abortable); + return None; + } + }; if TaskManager::add_job_declarator_task(task_manager.clone(), jd_abortable) .await @@ -153,6 +174,7 @@ async fn initialize_jd( send_solution, false, vec![], + valid_job_tracker, Some(jd.clone()), ))); let downstream_abortable = match DownstreamMiningNode::start(donwstream.clone(), receiver).await @@ -213,6 +235,7 @@ async fn initialize_jd( Some(jd.clone()), donwstream.clone(), vec![], + op_return_injector, None, test_only_do_not_send_solution_to_tp, ) diff --git a/src/jd_client/template_receiver/mod.rs b/src/jd_client/template_receiver/mod.rs index 6cbc8d3..2622cee 100644 --- a/src/jd_client/template_receiver/mod.rs +++ b/src/jd_client/template_receiver/mod.rs @@ -1,4 +1,5 @@ mod task_manager; +use crate::op_return_injector::OpReturnInjector; use crate::proxy_state::{DownstreamType, JdState, TpState}; use crate::shared::utils::AbortOnDrop; use crate::{ @@ -41,6 +42,7 @@ pub struct TemplateRx { down: Arc>, new_template_message: Option>, miner_coinbase_output: Vec, + op_return_injector: OpReturnInjector, test_only_do_not_send_solution_to_tp: bool, } @@ -52,6 +54,7 @@ impl TemplateRx { jd: Option>>, down: Arc>, miner_coinbase_outputs: Vec, + op_return_injector: OpReturnInjector, authority_public_key: Option, test_only_do_not_send_solution_to_tp: bool, ) -> Result { @@ -101,6 +104,7 @@ impl TemplateRx { down, new_template_message: None, miner_coinbase_output: encoded_outputs, + op_return_injector, test_only_do_not_send_solution_to_tp, })); @@ -208,6 +212,9 @@ impl TemplateRx { let miner_coinbase_output = self_mutex .safe_lock(|s| s.miner_coinbase_output.clone()) .map_err(|_| Error::TemplateRxMutexCorrupted)?; + let op_return_injector = self_mutex + .safe_lock(|s| s.op_return_injector.clone()) + .map_err(|_| Error::TemplateRxMutexCorrupted)?; let main_task = { let self_mutex = self_mutex.clone(); //? check @@ -320,6 +327,24 @@ impl TemplateRx { miner_name.as_str(), ); }; + match op_return_injector.apply_pending_to_template(&mut m).await + { + Ok(Some(applied)) => { + info!( + "Desired OP_RETURN applied to template {} (payload={} bytes, txout={} bytes)", + m.template_id, + applied.payload_len_bytes, + applied.tx_out_len_bytes + ); + } + Ok(None) => {} + Err(e) => { + warn!( + "Failed to apply desired OP_RETURN to template {}: {}", + m.template_id, e + ); + } + } let new_phash = super::IS_NEW_PHASH_ARRIVED .load(std::sync::atomic::Ordering::Acquire); let last_is_future = match self_mutex @@ -836,6 +861,7 @@ mod tests { solution_tx, false, Vec::new(), + crate::valid_job_tracker::ValidJobTracker::default(), None, ))); @@ -849,6 +875,7 @@ mod tests { down, new_template_message: None, miner_coinbase_output: encoded_outputs, + op_return_injector: OpReturnInjector::default(), test_only_do_not_send_solution_to_tp: true, })); let _abortable = TemplateRx::start_templates(self_mutex, tp_to_client_rx) diff --git a/src/lib.rs b/src/lib.rs index 28a15db..9068d71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub use config::Configuration; pub mod jd_client; mod minin_pool_connection; mod monitor; +mod op_return_injector; mod prioritized_transactions; mod proxy_state; mod router; @@ -41,6 +42,7 @@ mod share_accounter; mod shared; mod shutdown; mod translator; +mod valid_job_tracker; const TRANSLATOR_BUFFER_SIZE: usize = 128; const DOWNSTREAM_ACCEPT_BUFFER_SIZE: usize = 1024; @@ -201,6 +203,8 @@ async fn initialize_proxy( signature: String, ) { let shutdown_signal = shutdown::handle_shutdown(); + let op_return_injector = op_return_injector::OpReturnInjector::default(); + let valid_job_tracker = valid_job_tracker::ValidJobTracker::new(op_return_injector.clone()); loop { if *shutdown_signal.clone().borrow() { break; @@ -272,6 +276,8 @@ async fn initialize_proxy( jdc_to_translator_sender, from_share_accounter_to_jdc_recv, from_jdc_to_share_accounter_send, + op_return_injector.clone(), + valid_job_tracker.clone(), ) .await; if jdc_abortable.is_none() { @@ -320,8 +326,13 @@ async fn initialize_proxy( if let Some(jdc_handle) = jdc_abortable { abort_handles.push((jdc_handle, "jdc".to_string())); } - let server_handle = - tokio::spawn(api::start(router.clone(), stats_sender, downstream_handoff)); + let server_handle = tokio::spawn(api::start( + router.clone(), + stats_sender, + downstream_handoff, + op_return_injector.clone(), + valid_job_tracker.clone(), + )); abort_handles.push((server_handle.into(), "api_server".to_string())); match monitor(router, abort_handles, epsilon, shutdown_signal.clone()).await { Reconnect::NewUpstream(new_pool_addr) => { diff --git a/src/op_return_injector.rs b/src/op_return_injector.rs index 15726d3..38c30cb 100644 --- a/src/op_return_injector.rs +++ b/src/op_return_injector.rs @@ -1,6 +1,13 @@ -use bitcoin::{consensus::Encodable, hex::FromHex, script::PushBytesBuf, Amount, ScriptBuf, TxOut}; -use roles_logic_sv2::template_distribution_sv2::NewTemplate; +use bitcoin::{ + consensus::{Decodable, Encodable}, + hex::FromHex, + opcodes::all::OP_RETURN, + script::{Instruction, PushBytesBuf}, + Amount, ScriptBuf, TxOut, +}; +use roles_logic_sv2::{mining_sv2::Target as Sv2Target, template_distribution_sv2::NewTemplate}; use std::{ + collections::VecDeque, convert::TryInto, fmt, sync::{Arc, Mutex as StdMutex, OnceLock}, @@ -9,19 +16,34 @@ use tokio::sync::Mutex; const MAX_OP_RETURN_PAYLOAD_BYTES: usize = 80; const MAX_ADDITIONAL_COINBASE_OUTPUT_SIZE: usize = 100; +const MAX_APPLIED_TEMPLATE_PAYLOADS: usize = 128; static ACTIVE_OP_RETURN_PAYLOAD_HEX: OnceLock>> = OnceLock::new(); +pub const RSK_MERGED_MINING_TAG: &[u8] = b"RSKBLOCK:"; -#[derive(Clone, Default)] +#[derive(Debug, Clone, Default)] pub struct OpReturnInjector { - pending: Arc>>, + desired: Arc>>, + applied_to_templates: Arc>>, } #[derive(Clone, Debug)] -struct PendingOpReturn { +struct DesiredOpReturn { payload_hex: String, + payload_bytes: Vec, payload_len_bytes: usize, tx_out_len_bytes: usize, tx_out_bytes: Vec, + rsk_target_hex: Option, + rsk_target: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppliedTemplatePayload { + pub(crate) template_id: u64, + pub(crate) payload_hex: String, + pub(crate) payload_bytes: Vec, + pub(crate) rsk_target_hex: Option, + pub(crate) rsk_target: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -43,11 +65,13 @@ pub struct AppliedOpReturn { pub enum OpReturnInjectorError { EmptyPayload, InvalidHex(String), + InvalidRskTarget(String), PayloadTooLarge(usize), TxOutTooLarge(usize), TooManyCoinbaseOutputs, CoinbaseOutputsTooLarge, Encoding(String), + AppliedPayloadCacheCorrupted, } impl fmt::Display for OpReturnInjectorError { @@ -59,6 +83,9 @@ impl fmt::Display for OpReturnInjectorError { OpReturnInjectorError::InvalidHex(error) => { write!(f, "OP_RETURN payload must be hex encoded: {error}") } + OpReturnInjectorError::InvalidRskTarget(error) => { + write!(f, "RSK target must be a 32-byte big-endian hex string: {error}") + } OpReturnInjectorError::PayloadTooLarge(len) => write!( f, "OP_RETURN payload is {len} bytes, maximum supported payload is {MAX_OP_RETURN_PAYLOAD_BYTES} bytes" @@ -76,6 +103,9 @@ impl fmt::Display for OpReturnInjectorError { OpReturnInjectorError::Encoding(error) => { write!(f, "Failed to encode OP_RETURN output: {error}") } + OpReturnInjectorError::AppliedPayloadCacheCorrupted => { + f.write_str("Applied OP_RETURN template cache corrupted") + } } } } @@ -85,14 +115,23 @@ impl OpReturnInjector { &self, data_hex: &str, ) -> Result { - let pending = PendingOpReturn::from_hex(data_hex)?; - let mut guard = self.pending.lock().await; - let replaced_pending = guard.replace(pending.clone()).is_some(); + self.queue_from_hex_with_rsk_target(data_hex, None).await + } + + pub async fn queue_from_hex_with_rsk_target( + &self, + data_hex: &str, + rsk_target_hex: Option<&str>, + ) -> Result { + let desired = DesiredOpReturn::from_hex_with_rsk_target(data_hex, rsk_target_hex)?; + let mut guard = self.desired.lock().await; + let replaced_pending = guard.replace(desired.clone()).is_some(); + set_active_op_return_payload_hex(desired.payload_hex.clone()); Ok(QueuedOpReturn { - payload_hex: pending.payload_hex.clone(), - payload_len_bytes: pending.payload_len_bytes, - tx_out_len_bytes: pending.tx_out_len_bytes, + payload_hex: desired.payload_hex.clone(), + payload_len_bytes: desired.payload_len_bytes, + tx_out_len_bytes: desired.tx_out_len_bytes, replaced_pending, }) } @@ -101,21 +140,65 @@ impl OpReturnInjector { &self, template: &mut NewTemplate<'a>, ) -> Result, OpReturnInjectorError> { - let mut guard = self.pending.lock().await; - let Some(pending) = guard.as_ref().cloned() else { + let desired = { + let guard = self.desired.lock().await; + guard.as_ref().cloned() + }; + let Some(desired) = desired else { return Ok(None); }; - append_to_template(template, &pending)?; - set_active_op_return_payload_hex(pending.payload_hex.clone()); - *guard = None; + append_to_template(template, &desired)?; + self.cache_applied_payload(template.template_id, &desired)?; Ok(Some(AppliedOpReturn { - payload_hex: pending.payload_hex, - payload_len_bytes: pending.payload_len_bytes, - tx_out_len_bytes: pending.tx_out_len_bytes, + payload_hex: desired.payload_hex, + payload_len_bytes: desired.payload_len_bytes, + tx_out_len_bytes: desired.tx_out_len_bytes, })) } + + pub(crate) fn applied_payload_for_template( + &self, + template_id: u64, + ) -> Option { + self.applied_to_templates.lock().ok().and_then(|applied| { + applied + .iter() + .rev() + .find(|payload| payload.template_id == template_id) + .cloned() + }) + } + + fn cache_applied_payload( + &self, + template_id: u64, + desired: &DesiredOpReturn, + ) -> Result<(), OpReturnInjectorError> { + let mut applied = self + .applied_to_templates + .lock() + .map_err(|_| OpReturnInjectorError::AppliedPayloadCacheCorrupted)?; + + if let Some(index) = applied + .iter() + .position(|payload| payload.template_id == template_id) + { + applied.remove(index); + } else if applied.len() >= MAX_APPLIED_TEMPLATE_PAYLOADS { + applied.pop_front(); + } + + applied.push_back(AppliedTemplatePayload { + template_id, + payload_hex: desired.payload_hex.clone(), + payload_bytes: desired.payload_bytes.clone(), + rsk_target_hex: desired.rsk_target_hex.clone(), + rsk_target: desired.rsk_target.clone(), + }); + Ok(()) + } } fn active_op_return_payload_hex() -> &'static StdMutex> { @@ -128,24 +211,35 @@ fn set_active_op_return_payload_hex(payload_hex: String) { } } -impl PendingOpReturn { - fn from_hex(data_hex: &str) -> Result { +#[allow(dead_code)] +pub fn current_active_op_return_payload_hex() -> Option { + active_op_return_payload_hex() + .lock() + .ok() + .and_then(|active| active.clone()) +} + +impl DesiredOpReturn { + fn from_hex_with_rsk_target( + data_hex: &str, + rsk_target_hex: Option<&str>, + ) -> Result { let payload_hex = data_hex.trim().to_ascii_lowercase(); if payload_hex.is_empty() { return Err(OpReturnInjectorError::EmptyPayload); } - let payload = Vec::from_hex(&payload_hex) + let payload_bytes = Vec::from_hex(&payload_hex) .map_err(|error| OpReturnInjectorError::InvalidHex(error.to_string()))?; - if payload.is_empty() { + if payload_bytes.is_empty() { return Err(OpReturnInjectorError::EmptyPayload); } - let payload_len_bytes = payload.len(); + let payload_len_bytes = payload_bytes.len(); if payload_len_bytes > MAX_OP_RETURN_PAYLOAD_BYTES { return Err(OpReturnInjectorError::PayloadTooLarge(payload_len_bytes)); } - let push_bytes = PushBytesBuf::try_from(payload) + let push_bytes = PushBytesBuf::try_from(payload_bytes.clone()) .map_err(|error| OpReturnInjectorError::Encoding(error.to_string()))?; let tx_out = TxOut { value: Amount::ZERO, @@ -160,26 +254,67 @@ impl PendingOpReturn { return Err(OpReturnInjectorError::TxOutTooLarge(tx_out_bytes.len())); } + let (rsk_target_hex, rsk_target) = parse_rsk_target(rsk_target_hex)?; + Ok(Self { payload_hex, + payload_bytes, payload_len_bytes, tx_out_len_bytes: tx_out_bytes.len(), tx_out_bytes, + rsk_target_hex, + rsk_target, }) } } +fn parse_rsk_target( + rsk_target_hex: Option<&str>, +) -> Result<(Option, Option), OpReturnInjectorError> { + let Some(rsk_target_hex) = rsk_target_hex + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok((None, None)); + }; + + let normalized = rsk_target_hex + .trim_start_matches("0x") + .trim_start_matches("0X") + .to_ascii_lowercase(); + let mut target_bytes = Vec::from_hex(&normalized) + .map_err(|error| OpReturnInjectorError::InvalidRskTarget(error.to_string()))?; + if target_bytes.len() != 32 { + return Err(OpReturnInjectorError::InvalidRskTarget(format!( + "expected 32 bytes but got {}", + target_bytes.len() + ))); + } + + target_bytes.reverse(); + let target = Sv2Target::from( + <[u8; 32]>::try_from(target_bytes) + .expect("32-byte target should always convert into a fixed array"), + ); + + Ok((Some(normalized), Some(target))) +} + fn append_to_template<'a>( template: &mut NewTemplate<'a>, - pending: &PendingOpReturn, + desired: &DesiredOpReturn, ) -> Result<(), OpReturnInjectorError> { + if template_already_contains_payload(template, desired)? { + return Ok(()); + } + let next_outputs_count = template .coinbase_tx_outputs_count .checked_add(1) .ok_or(OpReturnInjectorError::TooManyCoinbaseOutputs)?; let mut outputs = template.coinbase_tx_outputs.to_vec(); - outputs.extend_from_slice(&pending.tx_out_bytes); + outputs.extend_from_slice(&desired.tx_out_bytes); template.coinbase_tx_outputs = outputs .try_into() .map_err(|_| OpReturnInjectorError::CoinbaseOutputsTooLarge)?; @@ -187,13 +322,56 @@ fn append_to_template<'a>( Ok(()) } +fn template_already_contains_payload( + template: &NewTemplate<'_>, + desired: &DesiredOpReturn, +) -> Result { + let bytes = template.coinbase_tx_outputs.to_vec(); + let mut cursor = bytes.as_slice(); + + for _ in 0..template.coinbase_tx_outputs_count { + let tx_out = TxOut::consensus_decode(&mut cursor) + .map_err(|error| OpReturnInjectorError::Encoding(error.to_string()))?; + if extract_single_op_return_pushdata(&tx_out.script_pubkey) + .is_some_and(|payload| payload == desired.payload_bytes) + { + return Ok(true); + } + } + + Ok(false) +} + +fn extract_single_op_return_pushdata(script_pubkey: &ScriptBuf) -> Option> { + if !script_pubkey.is_op_return() { + return None; + } + + let mut instructions = script_pubkey.instructions(); + match instructions.next()? { + Ok(Instruction::Op(op)) if op == OP_RETURN => {} + _ => return None, + } + + let payload = match instructions.next()? { + Ok(Instruction::PushBytes(bytes)) => bytes.as_bytes().to_vec(), + _ => return None, + }; + + if instructions.next().is_some() { + return None; + } + + Some(payload) +} + #[cfg(test)] mod tests { use super::*; use binary_sv2::{Seq0255, B0255, B064K, U256}; use bitcoin::{consensus::Decodable, hex::FromHex}; - fn make_new_template() -> NewTemplate<'static> { + fn make_new_template(template_id: u64) -> NewTemplate<'static> { let coinbase_prefix: B0255<'static> = Vec::new() .try_into() .expect("coinbase prefix should fit in B0255"); @@ -202,7 +380,7 @@ mod tests { .expect("coinbase outputs should fit in B064K"); let merkle_path: Seq0255<'static, U256<'static>> = Vec::new().into(); NewTemplate { - template_id: 1, + template_id, future_template: false, version: 0, coinbase_tx_version: 1, @@ -224,68 +402,166 @@ mod tests { } #[tokio::test] - async fn queue_and_apply_appends_zero_value_op_return() { + async fn queued_payload_remains_applied_across_successive_templates_until_replaced() { let injector = OpReturnInjector::default(); - let queued = injector + injector .queue_from_hex("deadbeef") .await .expect("queue should succeed"); - let mut template = make_new_template(); - let applied = injector - .apply_pending_to_template(&mut template) - .await - .expect("apply should succeed") - .expect("pending output should be applied"); + let mut first_template = make_new_template(11); + let mut second_template = make_new_template(12); - assert_eq!(queued.payload_len_bytes, 4); - assert_eq!(queued.tx_out_len_bytes, applied.tx_out_len_bytes); - assert_eq!(template.coinbase_tx_outputs_count, 1); + let first_apply = injector + .apply_pending_to_template(&mut first_template) + .await + .expect("first apply should succeed") + .expect("desired output should be applied"); + let second_apply = injector + .apply_pending_to_template(&mut second_template) + .await + .expect("second apply should succeed") + .expect("desired output should still be applied"); - let tx_out = decode_only_tx_out(&template.coinbase_tx_outputs.to_vec()); - assert_eq!(tx_out.value, Amount::ZERO); + assert_eq!(first_apply.payload_hex, "deadbeef"); + assert_eq!(second_apply.payload_hex, "deadbeef"); + assert_eq!(first_template.coinbase_tx_outputs_count, 1); + assert_eq!(second_template.coinbase_tx_outputs_count, 1); assert_eq!( - tx_out.script_pubkey, - ScriptBuf::new_op_return( - PushBytesBuf::try_from(Vec::from_hex("deadbeef").unwrap()).unwrap() - ) + injector + .applied_payload_for_template(11) + .expect("template 11 should be tracked") + .payload_hex, + "deadbeef" + ); + assert_eq!( + injector + .applied_payload_for_template(12) + .expect("template 12 should be tracked") + .payload_hex, + "deadbeef" ); - - let second_apply = injector - .apply_pending_to_template(&mut make_new_template()) - .await - .expect("second apply should succeed"); - assert!(second_apply.is_none(), "pending output should be cleared"); } #[tokio::test] - async fn newest_queue_replaces_pending_output() { + async fn replacing_payload_updates_later_templates_without_mutating_older_templates() { let injector = OpReturnInjector::default(); - let first = injector + let mut template_a = make_new_template(21); + let mut template_b = make_new_template(22); + + injector .queue_from_hex("aa") .await - .expect("first queue should succeed"); - let second = injector + .expect("first payload should queue"); + injector + .apply_pending_to_template(&mut template_a) + .await + .expect("template A apply should succeed") + .expect("template A should get first payload"); + + injector .queue_from_hex("bb") .await - .expect("second queue should succeed"); - let mut template = make_new_template(); + .expect("replacement payload should queue"); + injector + .apply_pending_to_template(&mut template_b) + .await + .expect("template B apply should succeed") + .expect("template B should get replacement payload"); - let applied = injector - .apply_pending_to_template(&mut template) + assert_eq!( + decode_only_tx_out(&template_a.coinbase_tx_outputs.to_vec()).script_pubkey, + ScriptBuf::new_op_return(PushBytesBuf::try_from(Vec::from_hex("aa").unwrap()).unwrap()) + ); + assert_eq!( + decode_only_tx_out(&template_b.coinbase_tx_outputs.to_vec()).script_pubkey, + ScriptBuf::new_op_return(PushBytesBuf::try_from(Vec::from_hex("bb").unwrap()).unwrap()) + ); + assert_eq!( + injector + .applied_payload_for_template(21) + .expect("template A should stay associated to old payload") + .payload_hex, + "aa" + ); + assert_eq!( + injector + .applied_payload_for_template(22) + .expect("template B should use replacement payload") + .payload_hex, + "bb" + ); + } + + #[tokio::test] + async fn incompatible_template_is_left_untouched_and_later_template_still_gets_desired_payload() + { + let injector = OpReturnInjector::default(); + injector + .queue_from_hex("deadbeef") .await - .expect("apply should succeed") - .expect("pending output should be applied"); + .expect("payload should queue"); - assert!(!first.replaced_pending); - assert!(second.replaced_pending); - assert_eq!(applied.payload_len_bytes, 1); + let mut incompatible_template = make_new_template(31); + incompatible_template.coinbase_tx_outputs = vec![0_u8; u16::MAX as usize] + .try_into() + .expect("max-sized B064K should be allowed"); + + let apply_error = injector + .apply_pending_to_template(&mut incompatible_template) + .await + .expect_err("oversized template must be rejected"); - let tx_out = decode_only_tx_out(&template.coinbase_tx_outputs.to_vec()); + assert_eq!(apply_error, OpReturnInjectorError::CoinbaseOutputsTooLarge); + assert_eq!(incompatible_template.coinbase_tx_outputs_count, 0); assert_eq!( - tx_out.script_pubkey, - ScriptBuf::new_op_return(PushBytesBuf::try_from(Vec::from_hex("bb").unwrap()).unwrap()) + incompatible_template.coinbase_tx_outputs.to_vec().len(), + u16::MAX as usize ); + assert!( + injector.applied_payload_for_template(31).is_none(), + "failed application must not mark template as injected" + ); + + let mut later_template = make_new_template(32); + let later_apply = injector + .apply_pending_to_template(&mut later_template) + .await + .expect("later compatible template should succeed") + .expect("desired payload should remain active"); + + assert_eq!(later_apply.payload_hex, "deadbeef"); + assert_eq!(later_template.coinbase_tx_outputs_count, 1); + assert_eq!( + injector + .applied_payload_for_template(32) + .expect("later template should be tracked") + .payload_hex, + "deadbeef" + ); + } + + #[tokio::test] + async fn does_not_duplicate_payload_within_same_template() { + let injector = OpReturnInjector::default(); + injector + .queue_from_hex("deadbeef") + .await + .expect("payload should queue"); + + let mut template = make_new_template(41); + injector + .apply_pending_to_template(&mut template) + .await + .expect("first apply should succeed") + .expect("payload should apply"); + injector + .apply_pending_to_template(&mut template) + .await + .expect("second apply should succeed") + .expect("payload should still be treated as applied"); + + assert_eq!(template.coinbase_tx_outputs_count, 1); } #[tokio::test] @@ -299,6 +575,33 @@ mod tests { assert!(matches!(error, OpReturnInjectorError::InvalidHex(_))); } + #[tokio::test] + async fn queue_from_hex_with_rsk_target_tracks_normalized_target_per_template() { + let injector = OpReturnInjector::default(); + let mut template = make_new_template(61); + let rsk_target_hex = "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + + injector + .queue_from_hex_with_rsk_target("deadbeef", Some(rsk_target_hex)) + .await + .expect("payload should queue with an RSK target"); + injector + .apply_pending_to_template(&mut template) + .await + .expect("template apply should succeed") + .expect("template should receive payload"); + + let applied = injector + .applied_payload_for_template(61) + .expect("template should retain applied payload metadata"); + assert_eq!(applied.payload_hex, "deadbeef"); + assert_eq!( + applied.rsk_target_hex.as_deref(), + Some("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + ); + assert!(applied.rsk_target.is_some()); + } + #[tokio::test] async fn rejects_payloads_larger_than_standard_op_return() { let injector = OpReturnInjector::default(); diff --git a/src/router/mod.rs b/src/router/mod.rs index 032c5a7..cbe4abc 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -416,6 +416,7 @@ impl PoolLatency { authority_public_key.into_bytes(), upstream, false, + crate::valid_job_tracker::ValidJobTracker::default(), ) .await { diff --git a/src/valid_job_tracker.rs b/src/valid_job_tracker.rs new file mode 100644 index 0000000..6a786a0 --- /dev/null +++ b/src/valid_job_tracker.rs @@ -0,0 +1,2020 @@ +use crate::op_return_injector::{OpReturnInjector, RSK_MERGED_MINING_TAG}; +#[cfg(test)] +use bitcoin::consensus::Encodable; +#[cfg(test)] +use bitcoin::hashes::sha256d; +#[cfg(test)] +use bitcoin::hex::FromHex; +use bitcoin::{ + block::{Header, Version}, + consensus::{deserialize, serialize}, + hashes::Hash, + hex::DisplayHex, + opcodes::all::OP_RETURN, + script::Instruction, + BlockHash, CompactTarget, Transaction, TxMerkleNode, Txid, +}; +use roles_logic_sv2::{mining_sv2::Target as Sv2Target, utils::merkle_root_from_path_}; +use serde::Serialize; +use std::{ + collections::VecDeque, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{SystemTime, UNIX_EPOCH}, +}; +use tracing::{debug, warn}; + +const MAX_PENDING_VALID_JOBS: usize = 32; +const MAX_CACHED_TEMPLATE_CONTEXTS: usize = 128; +const MAX_CACHED_TEMPLATE_COINBASE_CONTEXTS: usize = 128; +const MAX_RECENT_FOUND_JOB_IDENTITIES: usize = 128; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct FoundValidJob { + pub id: u64, + pub observed_at_unix_ts: u64, + pub template_id: u64, + pub version: u32, + pub header_timestamp: u32, + pub header_nonce: u32, + pub bitcoin_block_hash_hex: String, + pub block_header_hex: String, + pub coinbase_tx_hex: String, + pub merkle_hashes_hex: Vec, + pub block_tx_count: u32, + pub op_return_payload_hex: Option, + pub rsk_target_hex: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MergeMiningTemplateContext { + template_id: u64, + prev_hash: [u8; 32], + n_bits: u32, + merkle_path: Vec<[u8; 32]>, + block_tx_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MergeMiningCoinbaseContext { + template_id: u64, + coinbase_tx_prefix: Vec, + coinbase_tx_suffix: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitcoinBlockCandidate { + pub header: Header, + pub block_hash_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BitcoinBlockCandidateValidation { + MissingTemplateContext, + InvalidPow(BitcoinBlockCandidate), + Valid(BitcoinBlockCandidate), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FoundJobIdentity { + block_header_hex: String, + coinbase_tx_hex: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ValidJobTrackerError { + MutexCorrupted, + InvalidCoinbaseTransaction(String), +} + +impl std::fmt::Display for ValidJobTrackerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidJobTrackerError::MutexCorrupted => { + f.write_str("Valid job tracker mutex corrupted") + } + ValidJobTrackerError::InvalidCoinbaseTransaction(error) => { + write!(f, "Invalid coinbase transaction: {error}") + } + } + } +} + +#[derive(Debug, Clone)] +pub struct ValidJobTracker { + next_id: Arc, + pending: Arc>>, + template_contexts: Arc>>, + template_coinbase_contexts: Arc>>, + recent_found_job_identities: Arc>>, + op_return_injector: OpReturnInjector, +} + +impl Default for ValidJobTracker { + fn default() -> Self { + Self::new(OpReturnInjector::default()) + } +} + +impl ValidJobTracker { + pub fn new(op_return_injector: OpReturnInjector) -> Self { + Self { + next_id: Arc::new(AtomicU64::new(0)), + pending: Arc::new(Mutex::new(VecDeque::new())), + template_contexts: Arc::new(Mutex::new(VecDeque::new())), + template_coinbase_contexts: Arc::new(Mutex::new(VecDeque::new())), + recent_found_job_identities: Arc::new(Mutex::new(VecDeque::new())), + op_return_injector, + } + } + + pub fn cache_template_context( + &self, + template_id: u64, + prev_hash: [u8; 32], + n_bits: u32, + merkle_path: Vec<[u8; 32]>, + block_tx_count: u32, + ) -> Result<(), ValidJobTrackerError> { + let context = MergeMiningTemplateContext { + template_id, + prev_hash, + n_bits, + merkle_path, + block_tx_count, + }; + + let mut template_contexts = self + .template_contexts + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + + if let Some(index) = template_contexts + .iter() + .position(|cached| cached.template_id == template_id) + { + template_contexts.remove(index); + } else if template_contexts.len() >= MAX_CACHED_TEMPLATE_CONTEXTS { + template_contexts.pop_front(); + } + + template_contexts.push_back(context); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn cache_template_context_with_coinbase_parts( + &self, + template_id: u64, + prev_hash: [u8; 32], + n_bits: u32, + merkle_path: Vec<[u8; 32]>, + block_tx_count: u32, + coinbase_tx_prefix: Vec, + coinbase_tx_suffix: Vec, + ) -> Result<(), ValidJobTrackerError> { + self.cache_template_context(template_id, prev_hash, n_bits, merkle_path, block_tx_count)?; + self.cache_template_coinbase_context(template_id, coinbase_tx_prefix, coinbase_tx_suffix) + } + + fn cache_template_coinbase_context( + &self, + template_id: u64, + coinbase_tx_prefix: Vec, + coinbase_tx_suffix: Vec, + ) -> Result<(), ValidJobTrackerError> { + let context = MergeMiningCoinbaseContext { + template_id, + coinbase_tx_prefix, + coinbase_tx_suffix, + }; + + let mut template_coinbase_contexts = self + .template_coinbase_contexts + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + + if let Some(index) = template_coinbase_contexts + .iter() + .position(|cached| cached.template_id == template_id) + { + template_coinbase_contexts.remove(index); + } else if template_coinbase_contexts.len() >= MAX_CACHED_TEMPLATE_COINBASE_CONTEXTS { + template_coinbase_contexts.pop_front(); + } + + template_coinbase_contexts.push_back(context); + Ok(()) + } + + pub fn refresh_template_chain_state( + &self, + template_id: u64, + prev_hash: [u8; 32], + n_bits: u32, + ) -> Result { + let mut template_contexts = self + .template_contexts + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + + let Some(context) = template_contexts + .iter_mut() + .rev() + .find(|cached| cached.template_id == template_id) + else { + return Ok(false); + }; + + context.prev_hash = prev_hash; + context.n_bits = n_bits; + Ok(true) + } + + pub fn record_found_job( + &self, + template_id: u64, + version: u32, + header_timestamp: u32, + header_nonce: u32, + coinbase_tx: &[u8], + ) -> Result, ValidJobTrackerError> { + let Some(template_context) = self.get_template_context(template_id)? else { + warn!( + "Dropping found merge-mining job for template_id={} because merge-mining template context was missing", + template_id + ); + return Ok(None); + }; + + let Some(applied_payload) = self + .op_return_injector + .applied_payload_for_template(template_id) + else { + warn!( + "Dropping found merge-mining job for template_id={} because no template-scoped OP_RETURN payload metadata was recorded", + template_id + ); + return Ok(None); + }; + + if !applied_payload + .payload_bytes + .starts_with(RSK_MERGED_MINING_TAG) + { + warn!( + "Dropping found merge-mining job for template_id={} because the recorded OP_RETURN payload does not start with the RSKBLOCK: tag", + template_id + ); + return Ok(None); + } + + let coinbase_transaction: Transaction = deserialize(coinbase_tx) + .map_err(|error| ValidJobTrackerError::InvalidCoinbaseTransaction(error.to_string()))?; + + if !coinbase_contains_expected_op_return_payload( + &coinbase_transaction, + &applied_payload.payload_bytes, + ) { + warn!( + "Dropping found merge-mining job for template_id={} because the serialized coinbase transaction does not contain the expected template-scoped RSK OP_RETURN payload", + template_id + ); + return Ok(None); + } + + let stripped_coinbase_transaction = strip_witness_from_transaction(&coinbase_transaction); + let stripped_coinbase_tx = serialize(&stripped_coinbase_transaction); + let coinbase_txid = stripped_coinbase_transaction.compute_txid(); + let candidate = build_bitcoin_block_candidate( + version, + header_timestamp, + header_nonce, + &template_context, + coinbase_txid, + ); + if !candidate + .header + .target() + .is_met_by(candidate.header.block_hash()) + { + warn!( + template_id, + bitcoin_block_hash_hex = %candidate.block_hash_hex, + n_bits = format_args!("{:#010x}", template_context.n_bits), + "Dropping found merge-mining job because the reconstructed block header does not satisfy its own nBits target" + ); + return Ok(None); + } + let merkle_hashes_hex = serialize_rskj_merkle_hashes_hex( + &template_context.merkle_path, + template_context.block_tx_count, + ); + + let found_job = FoundValidJob { + id: self.next_id.fetch_add(1, Ordering::Relaxed) + 1, + observed_at_unix_ts: unix_timestamp_now(), + template_id, + version, + header_timestamp, + header_nonce, + bitcoin_block_hash_hex: candidate.block_hash_hex, + block_header_hex: serialize(&candidate.header).as_hex().to_string(), + coinbase_tx_hex: stripped_coinbase_tx.as_hex().to_string(), + merkle_hashes_hex, + block_tx_count: template_context.block_tx_count, + op_return_payload_hex: Some(applied_payload.payload_hex), + rsk_target_hex: applied_payload.rsk_target_hex, + }; + + if !self.enqueue_found_job(found_job.clone())? { + debug!( + template_id, + bitcoin_block_hash_hex = %found_job.bitcoin_block_hash_hex, + "Dropping duplicate found merge-mining job" + ); + return Ok(None); + } + Ok(Some(found_job)) + } + + pub fn record_rsk_target_share( + &self, + template_id: u64, + version: u32, + header_timestamp: u32, + header_nonce: u32, + full_extranonce: &[u8], + ) -> Result, ValidJobTrackerError> { + let Some(template_context) = self.get_template_context(template_id)? else { + debug!( + template_id, + "Skipping RSK-target share because merge-mining template context was missing" + ); + return Ok(None); + }; + + let Some(coinbase_context) = self.get_template_coinbase_context(template_id)? else { + debug!( + template_id, + "Skipping RSK-target share because coinbase template context was missing" + ); + return Ok(None); + }; + + let Some(applied_payload) = self + .op_return_injector + .applied_payload_for_template(template_id) + else { + debug!( + template_id, + "Skipping RSK-target share because no template-scoped OP_RETURN payload metadata was recorded" + ); + return Ok(None); + }; + + if !applied_payload + .payload_bytes + .starts_with(RSK_MERGED_MINING_TAG) + { + debug!( + template_id, + "Skipping RSK-target share because the recorded OP_RETURN payload does not start with the RSKBLOCK: tag" + ); + return Ok(None); + } + + let Some(rsk_target) = applied_payload.rsk_target.clone() else { + debug!( + template_id, + "Skipping RSK-target share because the template-scoped RSK target was unavailable" + ); + return Ok(None); + }; + + let coinbase_tx = build_coinbase_transaction_from_parts(&coinbase_context, full_extranonce); + let coinbase_transaction: Transaction = match deserialize(&coinbase_tx) { + Ok(transaction) => transaction, + Err(error) => { + warn!( + template_id, + "Dropping RSK-target share because the reconstructed coinbase transaction was invalid: {error}" + ); + return Ok(None); + } + }; + + if !coinbase_contains_expected_op_return_payload( + &coinbase_transaction, + &applied_payload.payload_bytes, + ) { + warn!( + template_id, + "Dropping RSK-target share because the reconstructed coinbase transaction does not contain the expected template-scoped RSK OP_RETURN payload" + ); + return Ok(None); + } + + let stripped_coinbase_transaction = strip_witness_from_transaction(&coinbase_transaction); + let stripped_coinbase_tx = serialize(&stripped_coinbase_transaction); + let coinbase_txid = stripped_coinbase_transaction.compute_txid(); + let candidate = build_bitcoin_block_candidate( + version, + header_timestamp, + header_nonce, + &template_context, + coinbase_txid, + ); + let candidate_hash: Sv2Target = candidate + .header + .block_hash() + .to_raw_hash() + .to_byte_array() + .into(); + if candidate_hash > rsk_target { + return Ok(None); + } + + let merkle_hashes_hex = serialize_rskj_merkle_hashes_hex( + &template_context.merkle_path, + template_context.block_tx_count, + ); + + let found_job = FoundValidJob { + id: self.next_id.fetch_add(1, Ordering::Relaxed) + 1, + observed_at_unix_ts: unix_timestamp_now(), + template_id, + version, + header_timestamp, + header_nonce, + bitcoin_block_hash_hex: candidate.block_hash_hex, + block_header_hex: serialize(&candidate.header).as_hex().to_string(), + coinbase_tx_hex: stripped_coinbase_tx.as_hex().to_string(), + merkle_hashes_hex, + block_tx_count: template_context.block_tx_count, + op_return_payload_hex: Some(applied_payload.payload_hex), + rsk_target_hex: applied_payload.rsk_target_hex, + }; + + if !self.enqueue_found_job(found_job.clone())? { + debug!( + template_id, + bitcoin_block_hash_hex = %found_job.bitcoin_block_hash_hex, + "Dropping duplicate found merge-mining job" + ); + return Ok(None); + } + Ok(Some(found_job)) + } + + pub fn validate_bitcoin_block_candidate( + &self, + template_id: u64, + version: u32, + header_timestamp: u32, + header_nonce: u32, + coinbase_tx: &[u8], + ) -> Result { + let Some(template_context) = self.get_template_context(template_id)? else { + return Ok(BitcoinBlockCandidateValidation::MissingTemplateContext); + }; + + let coinbase_transaction: Transaction = deserialize(coinbase_tx) + .map_err(|error| ValidJobTrackerError::InvalidCoinbaseTransaction(error.to_string()))?; + let candidate = build_bitcoin_block_candidate( + version, + header_timestamp, + header_nonce, + &template_context, + coinbase_transaction.compute_txid(), + ); + + if candidate + .header + .target() + .is_met_by(candidate.header.block_hash()) + { + Ok(BitcoinBlockCandidateValidation::Valid(candidate)) + } else { + Ok(BitcoinBlockCandidateValidation::InvalidPow(candidate)) + } + } + + pub fn take_found_job(&self) -> Result, ValidJobTrackerError> { + let mut pending = self + .pending + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + Ok(pending.pop_front()) + } + + fn enqueue_found_job(&self, found_job: FoundValidJob) -> Result { + let identity = FoundJobIdentity { + block_header_hex: found_job.block_header_hex.clone(), + coinbase_tx_hex: found_job.coinbase_tx_hex.clone(), + }; + + { + let mut recent_found_job_identities = self + .recent_found_job_identities + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + if recent_found_job_identities + .iter() + .any(|cached| cached == &identity) + { + return Ok(false); + } + if recent_found_job_identities.len() >= MAX_RECENT_FOUND_JOB_IDENTITIES { + recent_found_job_identities.pop_front(); + } + recent_found_job_identities.push_back(identity); + } + + let mut pending = self + .pending + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + if pending.len() >= MAX_PENDING_VALID_JOBS { + pending.pop_front(); + } + pending.push_back(found_job); + Ok(true) + } + + fn get_template_context( + &self, + template_id: u64, + ) -> Result, ValidJobTrackerError> { + let template_contexts = self + .template_contexts + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + Ok(template_contexts + .iter() + .rev() + .find(|cached| cached.template_id == template_id) + .cloned()) + } + + fn get_template_coinbase_context( + &self, + template_id: u64, + ) -> Result, ValidJobTrackerError> { + let template_coinbase_contexts = self + .template_coinbase_contexts + .lock() + .map_err(|_| ValidJobTrackerError::MutexCorrupted)?; + Ok(template_coinbase_contexts + .iter() + .rev() + .find(|cached| cached.template_id == template_id) + .cloned()) + } +} + +fn strip_witness_from_transaction(transaction: &Transaction) -> Transaction { + let mut stripped = transaction.clone(); + stripped + .input + .iter_mut() + .for_each(|input| input.witness.clear()); + stripped +} + +fn build_coinbase_transaction_from_parts( + coinbase_context: &MergeMiningCoinbaseContext, + full_extranonce: &[u8], +) -> Vec { + [ + coinbase_context.coinbase_tx_prefix.as_slice(), + full_extranonce, + coinbase_context.coinbase_tx_suffix.as_slice(), + ] + .concat() +} + +fn build_block_header( + version: u32, + header_timestamp: u32, + header_nonce: u32, + template_context: &MergeMiningTemplateContext, + coinbase_txid: Txid, +) -> Header { + let merkle_root = merkle_root_from_path_( + coinbase_txid.to_raw_hash().to_byte_array(), + &template_context.merkle_path, + ); + + Header { + version: Version::from_consensus(version as i32), + // Template-distribution `SetNewPrevHash.prev_hash` is already specified in the exact byte + // order used inside the next Bitcoin header, so this must stay a direct wrap, not a + // display-order reversal. + prev_blockhash: BlockHash::from_byte_array(template_context.prev_hash), + merkle_root: TxMerkleNode::from_byte_array(merkle_root), + time: header_timestamp, + bits: CompactTarget::from_consensus(template_context.n_bits), + nonce: header_nonce, + } +} + +fn build_bitcoin_block_candidate( + version: u32, + header_timestamp: u32, + header_nonce: u32, + template_context: &MergeMiningTemplateContext, + coinbase_txid: Txid, +) -> BitcoinBlockCandidate { + let header = build_block_header( + version, + header_timestamp, + header_nonce, + template_context, + coinbase_txid, + ); + BitcoinBlockCandidate { + block_hash_hex: header.block_hash().to_string(), + header, + } +} + +// The raw bytes used locally to build the Bitcoin header merkle root are not the same strings RskJ +// expects on the wire. `template_context.merkle_path` is already the SV2 bottom-up sibling path in +// the raw byte order consumed by `merkle_root_from_path_`, so local header reconstruction keeps it +// unchanged. For the RSKIP92 partial-merkle RPC we must instead submit only those sibling hashes, +// still in bottom-up order but hex-encoded in display order because RskJ reverses every submitted +// hash after decoding it. Single-transaction blocks bypass the partial-merkle RPC entirely in the +// sibling bridge, so they must emit an empty list. +fn serialize_rskj_merkle_hashes_hex(merkle_path: &[[u8; 32]], block_tx_count: u32) -> Vec { + if block_tx_count <= 1 { + return Vec::new(); + } + + merkle_path + .iter() + .copied() + .map(serialize_rskj_wire_hash_hex) + .collect() +} + +fn serialize_rskj_wire_hash_hex(raw_hash: [u8; 32]) -> String { + raw_hash + .into_iter() + .rev() + .collect::>() + .as_hex() + .to_string() +} + +pub(crate) fn coinbase_contains_expected_op_return_payload( + coinbase_transaction: &Transaction, + expected_payload: &[u8], +) -> bool { + coinbase_transaction + .output + .iter() + .filter_map(|output| extract_single_op_return_pushdata(&output.script_pubkey)) + .any(|payload| payload == expected_payload) +} + +fn extract_single_op_return_pushdata(script_pubkey: &bitcoin::ScriptBuf) -> Option> { + if !script_pubkey.is_op_return() { + return None; + } + + let mut instructions = script_pubkey.instructions(); + match instructions.next()? { + Ok(Instruction::Op(op)) if op == OP_RETURN => {} + _ => return None, + } + + let payload = match instructions.next()? { + Ok(Instruction::PushBytes(bytes)) => bytes.as_bytes().to_vec(), + _ => return None, + }; + + if instructions.next().is_some() { + return None; + } + + Some(payload) +} + +fn unix_timestamp_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +fn last_index_of_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + + haystack + .windows(needle.len()) + .enumerate() + .rev() + .find_map(|(index, window)| (window == needle).then_some(index)) +} + +#[cfg(test)] +fn validate_coinbase_submission_against_rskj_contract_for_tests( + found_job: &FoundValidJob, + coinbase_tx_bytes: &[u8], + coinbase_tx: &Transaction, +) -> Result<(), String> { + if coinbase_tx_bytes.len() <= 64 { + return Err(format!( + "submitted coinbase must be longer than 64 bytes for RskJ compression, got {} bytes", + coinbase_tx_bytes.len() + )); + } + + if let Some(payload_hex) = found_job.op_return_payload_hex.as_ref() { + let expected_payload = Vec::::from_hex(payload_hex) + .map_err(|error| format!("invalid op_return_payload_hex: {error}"))?; + let expected_payload_pos = last_index_of_subslice(coinbase_tx_bytes, &expected_payload) + .ok_or_else(|| { + "submitted coinbase bytes do not contain the expected template-scoped RSK payload" + .to_string() + })?; + let last_rsk_tag_pos = last_index_of_subslice(coinbase_tx_bytes, RSK_MERGED_MINING_TAG) + .ok_or_else(|| { + "submitted coinbase bytes do not contain the RSKBLOCK: tag".to_string() + })?; + if expected_payload_pos != last_rsk_tag_pos { + return Err( + "submitted coinbase bytes contain another RSKBLOCK: tag after the expected payload" + .to_string(), + ); + } + } + + // RskJ hashes the submitted coinbase bytes themselves (after a compression step that preserves + // the effective double-SHA256). For segwit coinbases this means the wire bytes must already be + // in stripped-txid form, otherwise RskJ will derive the wtxid while the block header merkle + // root still commits to the txid. + let submitted_coinbase_hash = sha256d::Hash::hash(coinbase_tx_bytes).to_byte_array(); + let txid_hash = coinbase_tx.compute_txid().to_raw_hash().to_byte_array(); + if submitted_coinbase_hash != txid_hash { + return Err( + "submitted coinbase bytes hash to a different identifier than the coinbase txid; witness bytes are still present on the wire" + .to_string(), + ); + } + + Ok(()) +} + +#[cfg(test)] +fn build_bridge_coinbase_only_raw_block_bytes_for_tests( + found_job: &FoundValidJob, +) -> Result, String> { + if found_job.block_tx_count != 1 { + return Err(format!( + "bridge raw-block helper only supports block_tx_count == 1, got {}", + found_job.block_tx_count + )); + } + + let header_bytes = Vec::::from_hex(&found_job.block_header_hex) + .map_err(|error| format!("invalid block_header_hex: {error}"))?; + let coinbase_tx_bytes = Vec::::from_hex(&found_job.coinbase_tx_hex) + .map_err(|error| format!("invalid coinbase_tx_hex: {error}"))?; + + let mut raw_block = Vec::with_capacity(header_bytes.len() + 1 + coinbase_tx_bytes.len()); + raw_block.extend_from_slice(&header_bytes); + bitcoin::consensus::encode::VarInt(1) + .consensus_encode(&mut raw_block) + .expect("Vec should not fail to encode"); + raw_block.extend_from_slice(&coinbase_tx_bytes); + Ok(raw_block) +} + +#[cfg(test)] +fn merkle_tree_height(block_tx_count: u32) -> usize { + assert!(block_tx_count > 0, "block_tx_count must be non-zero"); + + let mut height = 0usize; + let mut width = block_tx_count as usize; + while width > 1 { + width = width.div_ceil(2); + height += 1; + } + height +} + +#[cfg(test)] +fn decode_rskj_wire_hash_hex_via_rskip92_builder(wire_hex: &str) -> Result<[u8; 32], String> { + decode_rskj_wire_hash_hex_with_reason( + wire_hex, + "Rskip92MerkleProofBuilder uses Utils.reverseBytes(Hex.decode(mh))", + ) +} + +#[cfg(test)] +fn decode_rskj_wire_hash_hex_with_reason(wire_hex: &str, reason: &str) -> Result<[u8; 32], String> { + let mut wire_bytes = Vec::::from_hex(wire_hex) + .map_err(|error| format!("invalid merkle hash hex ({reason}): {error}"))?; + if wire_bytes.len() != 32 { + return Err(format!( + "invalid merkle hash hex ({reason}): expected 32 bytes but got {}", + wire_bytes.len() + )); + } + wire_bytes.reverse(); + wire_bytes + .try_into() + .map_err(|_| format!("invalid merkle hash hex ({reason}): failed to convert to 32 bytes")) +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ExpectedHeaderFieldsForTests { + pub prev_hash: Option<[u8; 32]>, + pub version: Option, + pub time: Option, + pub n_bits: Option, + pub nonce: Option, +} + +#[cfg(test)] +pub(crate) fn validate_found_job_against_rskj_contract_for_tests( + found_job: &FoundValidJob, + expected_header_fields: Option, +) -> Result<(), String> { + let block_header_bytes = Vec::::from_hex(&found_job.block_header_hex) + .map_err(|error| format!("invalid block_header_hex: {error}"))?; + if block_header_bytes.len() != 80 { + return Err(format!( + "block_header_hex must serialize exactly one 80-byte Bitcoin header, got {} bytes", + block_header_bytes.len() + )); + } + let block_header: Header = deserialize(&block_header_bytes) + .map_err(|error| format!("invalid block_header_hex: {error}"))?; + if block_header.block_hash().to_string() != found_job.bitcoin_block_hash_hex { + return Err(format!( + "bitcoin_block_hash_hex {} does not match the serialized block header hash {}", + found_job.bitcoin_block_hash_hex, + block_header.block_hash() + )); + } + if let Some(expected) = expected_header_fields { + if let Some(version) = expected.version { + let actual_version = block_header.version.to_consensus() as u32; + if actual_version != version { + return Err(format!( + "block header version {actual_version} did not match expected {version}" + )); + } + if &block_header_bytes[0..4] != version.to_le_bytes().as_slice() { + return Err("serialized block header version bytes did not match expected little-endian bytes".to_string()); + } + } + if let Some(prev_hash) = expected.prev_hash { + if block_header.prev_blockhash.to_byte_array() != prev_hash { + return Err(format!( + "block header prev_hash {} did not match expected {}", + block_header.prev_blockhash, + serialize_rskj_wire_hash_hex(prev_hash) + )); + } + if &block_header_bytes[4..36] != prev_hash.as_slice() { + return Err( + "serialized block header prev_hash bytes did not match expected raw bytes" + .to_string(), + ); + } + } + if let Some(time) = expected.time { + if block_header.time != time { + return Err(format!( + "block header time {} did not match expected {}", + block_header.time, time + )); + } + if &block_header_bytes[68..72] != time.to_le_bytes().as_slice() { + return Err( + "serialized block header time bytes did not match expected little-endian bytes" + .to_string(), + ); + } + } + if let Some(n_bits) = expected.n_bits { + if block_header.bits.to_consensus() != n_bits { + return Err(format!( + "block header bits {} did not match expected {}", + block_header.bits.to_consensus(), + n_bits + )); + } + if &block_header_bytes[72..76] != n_bits.to_le_bytes().as_slice() { + return Err("serialized block header nBits bytes did not match expected little-endian bytes".to_string()); + } + } + if let Some(nonce) = expected.nonce { + if block_header.nonce != nonce { + return Err(format!( + "block header nonce {} did not match expected {}", + block_header.nonce, nonce + )); + } + if &block_header_bytes[76..80] != nonce.to_le_bytes().as_slice() { + return Err("serialized block header nonce bytes did not match expected little-endian bytes".to_string()); + } + } + } + + let coinbase_tx_bytes = Vec::::from_hex(&found_job.coinbase_tx_hex) + .map_err(|error| format!("invalid coinbase_tx_hex: {error}"))?; + let coinbase_tx: Transaction = deserialize(&coinbase_tx_bytes) + .map_err(|error| format!("invalid coinbase_tx_hex: {error}"))?; + validate_coinbase_submission_against_rskj_contract_for_tests( + found_job, + &coinbase_tx_bytes, + &coinbase_tx, + )?; + let coinbase_txid_raw = coinbase_tx.compute_txid().to_raw_hash().to_byte_array(); + let header_merkle_root = block_header.merkle_root.to_raw_hash().to_byte_array(); + + if found_job.block_tx_count == 1 { + if !found_job.merkle_hashes_hex.is_empty() { + return Err(format!( + "single-transaction found jobs must emit an empty merkle_hashes_hex list, got {} entries", + found_job.merkle_hashes_hex.len() + )); + } + if header_merkle_root != coinbase_txid_raw { + return Err( + "single-transaction block header merkle root does not match the coinbase txid" + .to_string(), + ); + } + let raw_block_bytes = build_bridge_coinbase_only_raw_block_bytes_for_tests(found_job)?; + let raw_block: bitcoin::Block = deserialize(&raw_block_bytes) + .map_err(|error| format!("bridge-style raw block did not deserialize: {error}"))?; + if raw_block.txdata.len() != 1 { + return Err(format!( + "bridge-style raw block should contain exactly one transaction, got {}", + raw_block.txdata.len() + )); + } + if raw_block.header != block_header { + return Err( + "bridge-style raw block header does not match the submitted block_header_hex" + .to_string(), + ); + } + if serialize(&raw_block.txdata[0]) != coinbase_tx_bytes { + return Err( + "bridge-style raw block coinbase transaction does not match coinbase_tx_hex" + .to_string(), + ); + } + return Ok(()); + } + + if found_job.merkle_hashes_hex.is_empty() { + return Err(format!( + "multi-transaction found job template {} emitted an empty merkle_hashes_hex list", + found_job.template_id + )); + } + + let rskip92_hashes: Vec<[u8; 32]> = found_job + .merkle_hashes_hex + .iter() + .map(|hash| decode_rskj_wire_hash_hex_via_rskip92_builder(hash)) + .collect::>()?; + + let expected_hash_count = merkle_tree_height(found_job.block_tx_count); + if rskip92_hashes.len() != expected_hash_count { + return Err(format!( + "submitted merkle_hashes_hex length {} does not match the expected RskJ branch size {} for block_tx_count={}", + rskip92_hashes.len(), + expected_hash_count, + found_job.block_tx_count + )); + } + let rskip92_root = merkle_root_from_path_(coinbase_txid_raw, &rskip92_hashes); + if rskip92_root != header_merkle_root { + return Err( + "RSKIP92-style RskJ merkle reconstruction does not match the block header merkle root" + .to_string(), + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use binary_sv2::{Seq0255, B0255, B064K, U256}; + use bitcoin::{ + absolute::LockTime, hashes::sha256d, hex::FromHex, script::PushBytesBuf, Amount, OutPoint, + ScriptBuf, Sequence, TxIn, TxOut, Witness, + }; + use roles_logic_sv2::template_distribution_sv2::NewTemplate; + use std::convert::TryInto; + + const EASY_TEST_N_BITS: u32 = 0x207f_ffff; + + fn make_tracker() -> (OpReturnInjector, ValidJobTracker) { + let injector = OpReturnInjector::default(); + let tracker = ValidJobTracker::new(injector.clone()); + (injector, tracker) + } + + fn make_new_template(template_id: u64) -> NewTemplate<'static> { + let coinbase_prefix: B0255<'static> = Vec::new() + .try_into() + .expect("coinbase prefix should fit in B0255"); + let coinbase_tx_outputs: B064K<'static> = Vec::new() + .try_into() + .expect("coinbase outputs should fit in B064K"); + let merkle_path: Seq0255<'static, U256<'static>> = Vec::new().into(); + NewTemplate { + template_id, + future_template: false, + version: 0, + coinbase_tx_version: 1, + coinbase_prefix, + coinbase_tx_input_sequence: 0, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs, + coinbase_tx_locktime: 0, + merkle_path, + } + } + + async fn apply_payload_to_template( + injector: &OpReturnInjector, + template_id: u64, + payload_hex: &str, + ) { + let mut template = make_new_template(template_id); + injector + .queue_from_hex(payload_hex) + .await + .expect("payload should queue"); + injector + .apply_pending_to_template(&mut template) + .await + .expect("payload should apply") + .expect("template should receive payload"); + } + + async fn apply_payload_with_rsk_target_to_template( + injector: &OpReturnInjector, + template_id: u64, + payload_hex: &str, + rsk_target_hex: &str, + ) { + let mut template = make_new_template(template_id); + injector + .queue_from_hex_with_rsk_target(payload_hex, Some(rsk_target_hex)) + .await + .expect("payload should queue"); + injector + .apply_pending_to_template(&mut template) + .await + .expect("payload should apply") + .expect("template should receive payload"); + } + + fn rsk_payload_hex(suffix_hex: &str) -> String { + format!("52534b424c4f434b3a{suffix_hex}") + } + + fn make_coinbase_transaction(tag: u8, payload: Option<&[u8]>) -> Transaction { + let mut output = vec![TxOut { + value: Amount::from_sat(5_000_000_000 - tag as u64), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }]; + + if let Some(payload) = payload { + output.push(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload.to_vec()).expect("payload should fit"), + ), + }); + } + + Transaction { + version: bitcoin::transaction::Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: vec![0x03, tag, 0x51].into(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output, + } + } + + fn make_coinbase_transaction_with_extranonce( + tag: u8, + extranonce: &[u8], + payload: Option<&[u8]>, + ) -> Transaction { + let mut transaction = make_coinbase_transaction(tag, payload); + transaction.input[0].script_sig = ScriptBuf::from_bytes( + [vec![0x03, tag, 0x51], extranonce.to_vec(), vec![0x51]].concat(), + ); + transaction + } + + fn make_segwit_coinbase_transaction(tag: u8, payload: Option<&[u8]>) -> Transaction { + let mut transaction = make_coinbase_transaction(tag, payload); + transaction.input[0].witness = Witness::from_slice(&[vec![tag; 32]]); + transaction + } + + fn make_transaction(tag: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_raw_hash(sha256d::Hash::hash(&[tag; 32])), + vout: tag as u32, + }, + script_sig: vec![tag, tag.wrapping_add(1), tag.wrapping_add(2)].into(), + sequence: Sequence(tag as u32), + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1_000 + tag as u64), + script_pubkey: ScriptBuf::from_bytes(vec![0x51, tag]), + }], + } + } + + fn build_merkle_path_for_coinbase(txids: &[Txid]) -> (Vec<[u8; 32]>, [u8; 32]) { + assert!( + !txids.is_empty(), + "at least the coinbase transaction must be present" + ); + + let mut path = Vec::new(); + let mut index = 0usize; + let mut level: Vec<[u8; 32]> = txids + .iter() + .map(|txid| txid.to_raw_hash().to_byte_array()) + .collect(); + + while level.len() > 1 { + let sibling_index = if index % 2 == 0 { + if index + 1 < level.len() { + index + 1 + } else { + index + } + } else { + index - 1 + }; + path.push(level[sibling_index]); + + let mut next_level = Vec::with_capacity(level.len().div_ceil(2)); + for pair in level.chunks(2) { + let left = pair[0]; + let right = if pair.len() == 2 { pair[1] } else { pair[0] }; + let mut combined = Vec::with_capacity(64); + combined.extend_from_slice(&left); + combined.extend_from_slice(&right); + next_level.push(sha256d::Hash::hash(&combined).to_byte_array()); + } + + level = next_level; + index /= 2; + } + + (path, level[0]) + } + + fn decode_found_job_header(found_job: &FoundValidJob) -> Header { + let header_bytes = + Vec::::from_hex(&found_job.block_header_hex).expect("header hex should decode"); + deserialize(&header_bytes).expect("header bytes should decode into a block header") + } + + fn non_symmetric_prev_hash(start: u8) -> [u8; 32] { + (start..start.saturating_add(32)) + .collect::>() + .try_into() + .expect("expected exactly 32 bytes") + } + + fn find_nonce_for_template_context( + template_context: &MergeMiningTemplateContext, + version: u32, + header_timestamp: u32, + coinbase_txid: Txid, + should_meet_pow: bool, + ) -> u32 { + for nonce in 0..=u32::MAX { + let candidate = build_bitcoin_block_candidate( + version, + header_timestamp, + nonce, + template_context, + coinbase_txid, + ); + let meets_pow = candidate + .header + .target() + .is_met_by(candidate.header.block_hash()); + if meets_pow == should_meet_pow { + return nonce; + } + } + + panic!("failed to find nonce with should_meet_pow={should_meet_pow}"); + } + + fn find_nonce_for_coinbase( + template_context: &MergeMiningTemplateContext, + version: u32, + header_timestamp: u32, + coinbase: &Transaction, + should_meet_pow: bool, + ) -> u32 { + find_nonce_for_template_context( + template_context, + version, + header_timestamp, + coinbase.compute_txid(), + should_meet_pow, + ) + } + + fn split_serialized_coinbase_around_extranonce( + serialized_coinbase: &[u8], + extranonce: &[u8], + ) -> (Vec, Vec) { + let extranonce_pos = last_index_of_subslice(serialized_coinbase, extranonce) + .expect("serialized coinbase should contain the extranonce bytes"); + ( + serialized_coinbase[..extranonce_pos].to_vec(), + serialized_coinbase[extranonce_pos + extranonce.len()..].to_vec(), + ) + } + + fn rsk_target_hex_for_block_hash(block_hash: BlockHash) -> String { + let mut target_bytes = block_hash.to_byte_array().to_vec(); + target_bytes.reverse(); + target_bytes.as_hex().to_string() + } + + #[tokio::test] + async fn payload_applied_to_template_a_stays_associated_after_template_b_gets_new_payload() { + let (injector, tracker) = make_tracker(); + let payload_a_hex = rsk_payload_hex("aaaaaaaa"); + let payload_b_hex = rsk_payload_hex("bbbbbbbb"); + let payload_a = Vec::from_hex(&payload_a_hex).expect("payload A should decode"); + let payload_b = Vec::from_hex(&payload_b_hex).expect("payload B should decode"); + let template_a_context = MergeMiningTemplateContext { + template_id: 11, + prev_hash: [1; 32], + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }; + let template_b_context = MergeMiningTemplateContext { + template_id: 22, + prev_hash: [2; 32], + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }; + let coinbase_a_tx = make_coinbase_transaction(1, Some(&payload_a)); + let coinbase_b_tx = make_coinbase_transaction(2, Some(&payload_b)); + let nonce_a = find_nonce_for_coinbase(&template_a_context, 22, 33, &coinbase_a_tx, true); + let nonce_b = find_nonce_for_coinbase(&template_b_context, 23, 34, &coinbase_b_tx, true); + + apply_payload_to_template(&injector, 11, &payload_a_hex).await; + tracker + .cache_template_context(11, [1; 32], EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template A context"); + + apply_payload_to_template(&injector, 22, &payload_b_hex).await; + tracker + .cache_template_context(22, [2; 32], EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template B context"); + + let coinbase_a = serialize(&coinbase_a_tx); + let coinbase_b = serialize(&coinbase_b_tx); + + let found_job_a = tracker + .record_found_job(11, 22, 33, nonce_a, &coinbase_a) + .expect("tracker should record template A event") + .expect("template A should remain associated with payload A"); + let found_job_b = tracker + .record_found_job(22, 23, 34, nonce_b, &coinbase_b) + .expect("tracker should record template B event") + .expect("template B should remain associated with payload B"); + + assert_eq!(found_job_a.op_return_payload_hex, Some(payload_a_hex)); + assert_eq!(found_job_b.op_return_payload_hex, Some(payload_b_hex)); + } + + #[tokio::test] + async fn take_found_job_returns_oldest_pending_event() { + let (injector, tracker) = make_tracker(); + let payload_a_hex = rsk_payload_hex("01"); + let payload_b_hex = rsk_payload_hex("02"); + let payload_a = Vec::from_hex(&payload_a_hex).expect("payload A should decode"); + let payload_b = Vec::from_hex(&payload_b_hex).expect("payload B should decode"); + let template_11_context = MergeMiningTemplateContext { + template_id: 11, + prev_hash: [1; 32], + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }; + let template_12_context = MergeMiningTemplateContext { + template_id: 12, + prev_hash: [2; 32], + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }; + let coinbase_11_tx = make_coinbase_transaction(1, Some(&payload_a)); + let coinbase_12_tx = make_coinbase_transaction(2, Some(&payload_b)); + let nonce_11 = find_nonce_for_coinbase(&template_11_context, 22, 33, &coinbase_11_tx, true); + let nonce_12 = find_nonce_for_coinbase(&template_12_context, 23, 34, &coinbase_12_tx, true); + + apply_payload_to_template(&injector, 11, &payload_a_hex).await; + apply_payload_to_template(&injector, 12, &payload_b_hex).await; + tracker + .cache_template_context(11, [1; 32], EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template 11"); + tracker + .cache_template_context(12, [2; 32], EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template 12"); + + let first = tracker + .record_found_job(11, 22, 33, nonce_11, &serialize(&coinbase_11_tx)) + .expect("tracker should record first event") + .expect("template context should exist"); + let second = tracker + .record_found_job(12, 23, 34, nonce_12, &serialize(&coinbase_12_tx)) + .expect("tracker should record second event") + .expect("template context should exist"); + + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + Some(first) + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + Some(second) + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + None + ); + } + + #[tokio::test] + async fn record_found_job_builds_coinbase_only_submission_for_single_tx_block() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("01020304"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let prev_hash = non_symmetric_prev_hash(0x10); + let version = 0x2000_0000; + let header_timestamp = 1_700_000_000; + let n_bits = EASY_TEST_N_BITS; + + apply_payload_to_template(&injector, 7, &payload_hex).await; + tracker + .cache_template_context(7, prev_hash, n_bits, vec![], 1) + .expect("tracker should cache template context"); + + let coinbase_tx = make_coinbase_transaction(7, Some(&payload)); + let header_nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 7, + prev_hash, + n_bits, + merkle_path: vec![], + block_tx_count: 1, + }, + version, + header_timestamp, + &coinbase_tx, + true, + ); + let coinbase_bytes = serialize(&coinbase_tx); + let found_job = tracker + .record_found_job(7, version, header_timestamp, header_nonce, &coinbase_bytes) + .expect("tracker should build event") + .expect("template context should exist"); + + assert_eq!(found_job.block_tx_count, 1); + assert!(found_job.merkle_hashes_hex.is_empty()); + assert_eq!(found_job.op_return_payload_hex, Some(payload_hex.clone())); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("single-tx found job should satisfy the bridge/RskJ contract"); + let coinbase_tx: Transaction = + deserialize(&coinbase_bytes).expect("coinbase bytes should decode"); + assert!( + coinbase_contains_expected_op_return_payload(&coinbase_tx, &payload), + "single-tx coinbase must still contain the injected RSK payload" + ); + } + + #[tokio::test] + async fn record_found_job_exports_stripped_coinbase_for_single_tx_segwit_block() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("11223344"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let prev_hash = non_symmetric_prev_hash(0x30); + let version = 0x2000_0000; + let header_timestamp = 1_700_000_321; + let n_bits = EASY_TEST_N_BITS; + + apply_payload_to_template(&injector, 17, &payload_hex).await; + tracker + .cache_template_context(17, prev_hash, n_bits, vec![], 1) + .expect("tracker should cache template context"); + + let solved_coinbase = make_segwit_coinbase_transaction(17, Some(&payload)); + let header_nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 17, + prev_hash, + n_bits, + merkle_path: vec![], + block_tx_count: 1, + }, + version, + header_timestamp, + &solved_coinbase, + true, + ); + let solved_coinbase_bytes = serialize(&solved_coinbase); + let found_job = tracker + .record_found_job( + 17, + version, + header_timestamp, + header_nonce, + &solved_coinbase_bytes, + ) + .expect("tracker should build event") + .expect("template context should exist"); + + assert_ne!( + found_job.coinbase_tx_hex, + solved_coinbase_bytes.as_hex().to_string(), + "RskJ-facing coinbase export must not keep witness bytes", + ); + let exported_coinbase_bytes = + Vec::::from_hex(&found_job.coinbase_tx_hex).expect("coinbase hex should decode"); + let exported_coinbase: Transaction = + deserialize(&exported_coinbase_bytes).expect("coinbase should decode"); + assert!( + exported_coinbase.input[0].witness.is_empty(), + "found-job coinbase export must be witness-stripped", + ); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("segwit single-tx found job should satisfy the bridge/RskJ contract"); + } + + #[tokio::test] + async fn record_found_job_uses_latest_refreshed_prev_hash_and_bits_in_serialized_header() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("0badc0de"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let stale_prev_hash = [0xaa; 32]; + let refreshed_prev_hash = non_symmetric_prev_hash(0x00); + let refreshed_n_bits = EASY_TEST_N_BITS; + let version = 0x2000_0000; + let header_timestamp = 1_700_000_123; + + apply_payload_to_template(&injector, 99, &payload_hex).await; + tracker + .cache_template_context(99, stale_prev_hash, 0x1d00ffff, vec![], 1) + .expect("tracker should cache template context"); + assert!( + tracker + .refresh_template_chain_state(99, refreshed_prev_hash, refreshed_n_bits) + .expect("tracker should refresh template context"), + "expected the cached template context to refresh" + ); + + let coinbase_tx = make_coinbase_transaction(9, Some(&payload)); + let header_nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 99, + prev_hash: refreshed_prev_hash, + n_bits: refreshed_n_bits, + merkle_path: vec![], + block_tx_count: 1, + }, + version, + header_timestamp, + &coinbase_tx, + true, + ); + let coinbase_bytes = serialize(&coinbase_tx); + let found_job = tracker + .record_found_job(99, version, header_timestamp, header_nonce, &coinbase_bytes) + .expect("tracker should build event") + .expect("template context should exist"); + + let header = decode_found_job_header(&found_job); + let header_bytes = + Vec::::from_hex(&found_job.block_header_hex).expect("header hex should decode"); + + assert_eq!( + header.prev_blockhash.to_byte_array(), + refreshed_prev_hash, + "the serialized header must use the latest SetNewPrevHash bytes, not a stale snapshot", + ); + assert_ne!( + header.prev_blockhash.to_byte_array(), + stale_prev_hash, + "a stale prev_hash snapshot would build the wrong Bitcoin header", + ); + assert_eq!( + &header_bytes[4..36], + refreshed_prev_hash.as_slice(), + "Bitcoin headers serialize prev_hash exactly as cached internal bytes", + ); + assert_eq!(header.bits.to_consensus(), refreshed_n_bits); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(refreshed_prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(refreshed_n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("refreshed single-tx found job should satisfy the bridge/RskJ contract"); + } + + #[test] + fn record_found_job_returns_none_when_template_payload_metadata_is_missing() { + let (_injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("deadbeef"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let coinbase = serialize(&make_coinbase_transaction(1, Some(&payload))); + + tracker + .cache_template_context(404, [4; 32], 0x1d00ffff, vec![], 1) + .expect("tracker should cache template context"); + + assert_eq!( + tracker + .record_found_job(404, 2, 3, 4, &coinbase) + .expect("tracker should handle missing payload metadata"), + None + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + None + ); + } + + #[tokio::test] + async fn record_found_job_returns_none_when_coinbase_does_not_contain_expected_rsk_payload() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("cafebabe"); + + apply_payload_to_template(&injector, 77, &payload_hex).await; + tracker + .cache_template_context(77, [7; 32], 0x1d00ffff, vec![], 1) + .expect("tracker should cache template context"); + + let coinbase_without_payload = serialize(&make_coinbase_transaction(7, None)); + + assert_eq!( + tracker + .record_found_job(77, 2, 3, 4, &coinbase_without_payload) + .expect("tracker should reject mismatched coinbase"), + None + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + None + ); + } + + #[tokio::test] + async fn record_found_job_builds_rskj_compatible_partial_merkle_payload() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("00112233"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let refreshed_prev_hash = non_symmetric_prev_hash(0x20); + let refreshed_n_bits = EASY_TEST_N_BITS; + let version = 0x2000_0000; + let header_timestamp = 1_700_000_000; + + apply_payload_to_template(&injector, 77, &payload_hex).await; + + let coinbase = make_coinbase_transaction(9, Some(&payload)); + let tx1 = make_transaction(1); + let tx2 = make_transaction(2); + let tx3 = make_transaction(3); + let txids = vec![ + coinbase.compute_txid(), + tx1.compute_txid(), + tx2.compute_txid(), + tx3.compute_txid(), + ]; + let (merkle_path, expected_merkle_root) = build_merkle_path_for_coinbase(&txids); + + tracker + .cache_template_context(77, [0x42; 32], 0x1d00ffff, merkle_path.clone(), 4) + .expect("tracker should cache template context"); + assert!( + tracker + .refresh_template_chain_state(77, refreshed_prev_hash, refreshed_n_bits) + .expect("tracker should refresh template context"), + "expected the cached template context to refresh", + ); + + let header_nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 77, + prev_hash: refreshed_prev_hash, + n_bits: refreshed_n_bits, + merkle_path: merkle_path.clone(), + block_tx_count: 4, + }, + version, + header_timestamp, + &coinbase, + true, + ); + let coinbase_bytes = serialize(&coinbase); + let found_job = tracker + .record_found_job(77, version, header_timestamp, header_nonce, &coinbase_bytes) + .expect("tracker should build event") + .expect("template context should exist"); + + let header_bytes = + Vec::::from_hex(&found_job.block_header_hex).expect("header hex should decode"); + let header: Header = + deserialize(&header_bytes).expect("header hex should decode into a block header"); + + assert_eq!( + header.block_hash().to_string(), + found_job.bitcoin_block_hash_hex + ); + assert_eq!(header.prev_blockhash.to_byte_array(), refreshed_prev_hash); + assert_eq!(header.bits.to_consensus(), refreshed_n_bits); + assert_eq!( + header.merkle_root.to_raw_hash().to_byte_array(), + expected_merkle_root + ); + assert_eq!( + found_job.coinbase_tx_hex, + coinbase_bytes.as_hex().to_string() + ); + let expected_sibling_wire_hashes = + serialize_rskj_merkle_hashes_hex(&merkle_path, txids.len() as u32); + assert_eq!(found_job.merkle_hashes_hex, expected_sibling_wire_hashes); + assert_eq!( + found_job.merkle_hashes_hex.len(), + merkle_tree_height(found_job.block_tx_count), + "multi-tx found jobs must emit only the bottom-up sibling hashes expected by RSKIP92", + ); + assert_eq!(found_job.block_tx_count, txids.len() as u32); + assert_eq!(found_job.op_return_payload_hex, Some(payload_hex)); + assert_eq!( + found_job.merkle_hashes_hex, + merkle_path + .iter() + .copied() + .map(serialize_rskj_wire_hash_hex) + .collect::>(), + "RskJ partial-merkle submissions must emit only sibling hashes in bottom-up order", + ); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(refreshed_prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(refreshed_n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("multi-tx found job should satisfy the bridge/RskJ contract"); + assert_eq!( + header.merkle_root.to_raw_hash().to_byte_array(), + expected_merkle_root, + "the serialized block header must commit to the merkle root proven to RskJ", + ); + } + + #[tokio::test] + async fn record_found_job_exports_stripped_coinbase_for_multi_tx_segwit_block() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("44556677"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let prev_hash = non_symmetric_prev_hash(0x60); + let version = 0x2000_0000; + let header_timestamp = 1_700_000_654; + let n_bits = EASY_TEST_N_BITS; + + apply_payload_to_template(&injector, 88, &payload_hex).await; + + let coinbase = make_segwit_coinbase_transaction(21, Some(&payload)); + let tx1 = make_transaction(4); + let tx2 = make_transaction(5); + let txids = vec![ + coinbase.compute_txid(), + tx1.compute_txid(), + tx2.compute_txid(), + ]; + let (merkle_path, _) = build_merkle_path_for_coinbase(&txids); + + tracker + .cache_template_context(88, prev_hash, n_bits, merkle_path, 3) + .expect("tracker should cache template context"); + + let header_nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 88, + prev_hash, + n_bits, + merkle_path: build_merkle_path_for_coinbase(&txids).0, + block_tx_count: 3, + }, + version, + header_timestamp, + &coinbase, + true, + ); + let solved_coinbase_bytes = serialize(&coinbase); + let found_job = tracker + .record_found_job( + 88, + version, + header_timestamp, + header_nonce, + &solved_coinbase_bytes, + ) + .expect("tracker should build event") + .expect("template context should exist"); + + assert_ne!( + found_job.coinbase_tx_hex, + solved_coinbase_bytes.as_hex().to_string(), + "RskJ-facing partial-merkle coinbase export must not keep witness bytes", + ); + assert_eq!( + found_job.merkle_hashes_hex, + build_merkle_path_for_coinbase(&txids) + .0 + .into_iter() + .map(serialize_rskj_wire_hash_hex) + .collect::>(), + "segwit multi-tx found jobs must still emit only sibling hashes for RSKIP92", + ); + assert_eq!( + found_job.merkle_hashes_hex.len(), + merkle_tree_height(found_job.block_tx_count), + ); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("segwit multi-tx found job should satisfy the RskJ partial-merkle contract"); + } + + #[tokio::test] + async fn record_rsk_target_share_enqueues_multi_tx_found_job_when_hash_meets_live_target() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("99887766"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let template_id = 144; + let prev_hash = non_symmetric_prev_hash(0x70); + let version = 0x2000_0000; + let header_timestamp = 1_700_000_777; + let full_extranonce = vec![0xfa, 0xce, 0xb0, 0x0c]; + + let coinbase = + make_coinbase_transaction_with_extranonce(31, &full_extranonce, Some(&payload)); + let tx1 = make_transaction(7); + let tx2 = make_transaction(8); + let txids = vec![ + coinbase.compute_txid(), + tx1.compute_txid(), + tx2.compute_txid(), + ]; + let (merkle_path, _) = build_merkle_path_for_coinbase(&txids); + let template_context = MergeMiningTemplateContext { + template_id, + prev_hash, + n_bits: 0x1d00ffff, + merkle_path: merkle_path.clone(), + block_tx_count: txids.len() as u32, + }; + let header_nonce = find_nonce_for_coinbase( + &template_context, + version, + header_timestamp, + &coinbase, + false, + ); + let candidate = build_bitcoin_block_candidate( + version, + header_timestamp, + header_nonce, + &template_context, + coinbase.compute_txid(), + ); + let rsk_target_hex = rsk_target_hex_for_block_hash(candidate.header.block_hash()); + let serialized_coinbase = serialize(&coinbase); + let (coinbase_tx_prefix, coinbase_tx_suffix) = + split_serialized_coinbase_around_extranonce(&serialized_coinbase, &full_extranonce); + + apply_payload_with_rsk_target_to_template( + &injector, + template_id, + &payload_hex, + &rsk_target_hex, + ) + .await; + tracker + .cache_template_context_with_coinbase_parts( + template_id, + prev_hash, + template_context.n_bits, + merkle_path.clone(), + txids.len() as u32, + coinbase_tx_prefix, + coinbase_tx_suffix, + ) + .expect("tracker should cache template and coinbase context"); + + let found_job = tracker + .record_rsk_target_share( + template_id, + version, + header_timestamp, + header_nonce, + &full_extranonce, + ) + .expect("tracker should evaluate the RSK target share") + .expect("share meeting the live RSK target should be enqueued"); + + assert_eq!(found_job.block_tx_count, txids.len() as u32); + assert_eq!( + found_job.merkle_hashes_hex, + merkle_path + .iter() + .copied() + .map(serialize_rskj_wire_hash_hex) + .collect::>() + ); + assert_eq!(found_job.op_return_payload_hex, Some(payload_hex)); + validate_found_job_against_rskj_contract_for_tests( + &found_job, + Some(ExpectedHeaderFieldsForTests { + prev_hash: Some(prev_hash), + version: Some(version), + time: Some(header_timestamp), + n_bits: Some(template_context.n_bits), + nonce: Some(header_nonce), + }), + ) + .expect("RSK-target share should satisfy the bridge/RskJ contract"); + } + + #[tokio::test] + async fn duplicate_found_jobs_are_suppressed_before_reaching_the_queue() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("abc12345"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let coinbase_tx = make_segwit_coinbase_transaction(5, Some(&payload)); + let nonce = find_nonce_for_coinbase( + &MergeMiningTemplateContext { + template_id: 55, + prev_hash: [5; 32], + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }, + 7, + 8, + &coinbase_tx, + true, + ); + + apply_payload_to_template(&injector, 55, &payload_hex).await; + tracker + .cache_template_context(55, [5; 32], EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template context"); + + let coinbase_bytes = serialize(&coinbase_tx); + let first = tracker + .record_found_job(55, 7, 8, nonce, &coinbase_bytes) + .expect("tracker should record first event"); + let second = tracker + .record_found_job(55, 7, 8, nonce, &coinbase_bytes) + .expect("tracker should handle duplicate event"); + + assert!(first.is_some(), "the first found job should be queued"); + assert_eq!(second, None, "duplicate found jobs should be suppressed"); + assert!( + tracker + .take_found_job() + .expect("tracker should read queue") + .is_some(), + "the first found job should still be in the queue", + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + None, + "the duplicate found job must not reach the queue", + ); + } + + #[tokio::test] + async fn validate_bitcoin_block_candidate_rejects_high_hash_headers() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("deadc0de"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let prev_hash = non_symmetric_prev_hash(0x70); + let version = 0x2000_0000; + let header_timestamp = 1_700_001_000; + let coinbase_tx = make_coinbase_transaction(33, Some(&payload)); + let template_context = MergeMiningTemplateContext { + template_id: 66, + prev_hash, + n_bits: EASY_TEST_N_BITS, + merkle_path: vec![], + block_tx_count: 1, + }; + let invalid_nonce = find_nonce_for_coinbase( + &template_context, + version, + header_timestamp, + &coinbase_tx, + false, + ); + + apply_payload_to_template(&injector, 66, &payload_hex).await; + tracker + .cache_template_context(66, prev_hash, EASY_TEST_N_BITS, vec![], 1) + .expect("tracker should cache template context"); + + let validation = tracker + .validate_bitcoin_block_candidate( + 66, + version, + header_timestamp, + invalid_nonce, + &serialize(&coinbase_tx), + ) + .expect("validation should succeed"); + + assert!(matches!( + validation, + BitcoinBlockCandidateValidation::InvalidPow(_) + )); + } +} diff --git a/tests/library_init.rs b/tests/library_init.rs index 6202da4..bec8a6e 100644 --- a/tests/library_init.rs +++ b/tests/library_init.rs @@ -151,6 +151,7 @@ async fn library_init_sv2_setup_connection() { "password".to_string(), "100000000".to_string(), "api-token".to_string(), + None, ); let proxy = tokio::spawn(dmnd_client::start(config)); From 43d5ea1e233c4107b4a25eb4bdf7035adb5efd48 Mon Sep 17 00:00:00 2001 From: fi3 Date: Wed, 13 May 2026 11:07:15 +0200 Subject: [PATCH 3/3] FIX JD template refresh races so valid shares and merge-mining proofs stay bound to the right template Keep the current clean-job family separate from same-tip template refreshes so a new non-future job does not make the previous still-valid job look stale before normal channel validation runs. Capture coinbase prefix and suffix per template instead of reading them from a mutable singleton later, so a newer template cannot overwrite the coinbase parts that belong to an earlier declaration while it is still in flight. Treat the applied RSK payload as valid only when it is the last RSKBLOCK tag in the coinbase path. This keeps template injection, found-job validation, and the bridge contract aligned and avoids exporting proofs built from a payload that is already shadowed by a later tag. --- src/jd_client/job_declarator/mod.rs | 142 +++++++++++++++++++------ src/jd_client/mining_downstream/mod.rs | 139 +++++++++--------------- src/op_return_injector.rs | 73 ++++++++++++- src/valid_job_tracker.rs | 78 +++++++++++++- 4 files changed, 298 insertions(+), 134 deletions(-) diff --git a/src/jd_client/job_declarator/mod.rs b/src/jd_client/job_declarator/mod.rs index 1ff0d1e..4182f5c 100644 --- a/src/jd_client/job_declarator/mod.rs +++ b/src/jd_client/job_declarator/mod.rs @@ -54,6 +54,10 @@ pub struct LastDeclareJob { block_tx_count: u32, } +type TemplateCoinbaseParts = (B064K<'static>, B064K<'static>); +type TemplateCoinbasePartsMap = HashMap>; +const MAX_PENDING_TEMPLATE_COINBASE_PARTS: usize = 1000; + #[derive(Debug)] pub struct JobDeclarator { sender: TSender>>, @@ -78,8 +82,7 @@ pub struct JobDeclarator { BuildNoHashHasher, >, up: Arc>, - pub coinbase_tx_prefix: B064K<'static>, - pub coinbase_tx_suffix: B064K<'static>, + template_coinbase_parts: TemplateCoinbasePartsMap, valid_job_tracker: ValidJobTracker, pub task_manager: Arc>, } @@ -121,8 +124,7 @@ impl JobDeclarator { last_set_new_prev_hash: None, future_jobs: HashMap::with_hasher(BuildNoHashHasher::default()), up, - coinbase_tx_prefix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), - coinbase_tx_suffix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), + template_coinbase_parts: HashMap::with_hasher(BuildNoHashHasher::default()), valid_job_tracker, set_new_prev_hash_counter: 0, task_manager, @@ -283,13 +285,18 @@ impl JobDeclarator { } let tx_ids: Seq064K<'static, U256> = Seq064K::from(tx_ids); - let coinbase_prefix = self_mutex - .safe_lock(|s| s.coinbase_tx_prefix.clone()) - .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; - - let coinbase_suffix = self_mutex - .safe_lock(|s| s.coinbase_tx_suffix.clone()) - .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; + let (coinbase_prefix, coinbase_suffix) = self_mutex + .safe_lock(|s| { + take_template_coinbase_parts(&mut s.template_coinbase_parts, template.template_id) + }) + .map_err(|_| Error::JobDeclaratorMutexCorrupted)? + .ok_or_else(|| { + error!( + "Missing per-template coinbase prefix/suffix for template {}", + template.template_id + ); + Error::Unrecoverable + })?; let declare_job = DeclareMiningJob { request_id: id, @@ -409,16 +416,10 @@ impl JobDeclarator { pool_outs.append(&mut template_outs); match set_new_prev_hash { Some(p) => { - let (valid_job_tracker, coinbase_tx_prefix, coinbase_tx_suffix) = - match self_mutex.safe_lock(|s| { - ( - s.valid_job_tracker.clone(), - s.coinbase_tx_prefix.to_vec(), - s.coinbase_tx_suffix.to_vec(), - ) - }) + let valid_job_tracker = match self_mutex + .safe_lock(|s| s.valid_job_tracker.clone()) { - Ok(context) => context, + Ok(valid_job_tracker) => valid_job_tracker, Err(e) => { error!("{e}"); ProxyState::update_jd_state(JdState::Down); @@ -431,8 +432,8 @@ impl JobDeclarator { &p, &merkle_path, last_declare.block_tx_count, - &coinbase_tx_prefix, - &coinbase_tx_suffix, + &last_declare_mining_job_sent.coinbase_prefix.to_vec(), + &last_declare_mining_job_sent.coinbase_suffix.to_vec(), ); if let Err(e) = Upstream::set_custom_jobs( &up, @@ -560,23 +561,14 @@ impl JobDeclarator { let signed_token = job.mining_job_token.clone(); let mut template_outs = template.coinbase_tx_outputs.to_vec(); pool_outs.append(&mut template_outs); - let (coinbase_tx_prefix, coinbase_tx_suffix) = match self_mutex - .safe_lock(|s| (s.coinbase_tx_prefix.to_vec(), s.coinbase_tx_suffix.to_vec())) - { - Ok(context) => context, - Err(_) => { - error!("{}", Error::JobDeclaratorMutexCorrupted); - return; - } - }; cache_ready_template_context( &valid_job_tracker, template.template_id, &set_new_prev_hash, &merkle_path, block_tx_count, - &coinbase_tx_prefix, - &coinbase_tx_suffix, + &job.coinbase_prefix.to_vec(), + &job.coinbase_suffix.to_vec(), ); if let Err(e) = Upstream::set_custom_jobs( &up, @@ -663,6 +655,24 @@ impl JobDeclarator { Error::Unrecoverable }) } + + pub fn remember_template_coinbase_parts( + self_mutex: &Arc>, + template_id: u64, + coinbase_tx_prefix: B064K<'static>, + coinbase_tx_suffix: B064K<'static>, + ) -> Result<(), Error> { + self_mutex + .safe_lock(|s| { + remember_template_coinbase_parts( + &mut s.template_coinbase_parts, + template_id, + coinbase_tx_prefix, + coinbase_tx_suffix, + ); + }) + .map_err(|_| Error::JobDeclaratorMutexCorrupted) + } } fn missing_prioritized_txids( @@ -727,6 +737,30 @@ fn block_tx_count_from_request_transaction_list_len(non_coinbase_tx_count: usize .expect("block transaction count overflowed") } +fn remember_template_coinbase_parts( + template_coinbase_parts: &mut TemplateCoinbasePartsMap, + template_id: u64, + coinbase_tx_prefix: B064K<'static>, + coinbase_tx_suffix: B064K<'static>, +) { + if template_coinbase_parts.len() >= MAX_PENDING_TEMPLATE_COINBASE_PARTS + && !template_coinbase_parts.contains_key(&template_id) + { + if let Some(oldest_template_id) = template_coinbase_parts.keys().min().copied() { + template_coinbase_parts.remove(&oldest_template_id); + } + } + + template_coinbase_parts.insert(template_id, (coinbase_tx_prefix, coinbase_tx_suffix)); +} + +fn take_template_coinbase_parts( + template_coinbase_parts: &mut TemplateCoinbasePartsMap, + template_id: u64, +) -> Option { + template_coinbase_parts.remove(&template_id) +} + fn cache_ready_template_context( valid_job_tracker: &ValidJobTracker, template_id: u64, @@ -772,8 +806,13 @@ fn cache_ready_template_context( #[cfg(test)] mod tests { - use super::{block_tx_count_from_request_transaction_list_len, missing_prioritized_txids}; + use super::{ + block_tx_count_from_request_transaction_list_len, missing_prioritized_txids, + remember_template_coinbase_parts, take_template_coinbase_parts, TemplateCoinbasePartsMap, + }; + use binary_sv2::B064K; use std::collections::HashSet; + use std::convert::TryInto; #[test] fn no_missing_prioritized_txids_when_all_are_in_template() { @@ -799,4 +838,39 @@ mod tests { assert_eq!(block_tx_count_from_request_transaction_list_len(0), 1); assert_eq!(block_tx_count_from_request_transaction_list_len(3), 4); } + + #[test] + fn template_coinbase_parts_are_tracked_per_template_until_consumed() { + let mut template_coinbase_parts = TemplateCoinbasePartsMap::default(); + let prefix_a: B064K<'static> = vec![0xaa].try_into().expect("prefix should fit"); + let suffix_a: B064K<'static> = vec![0xab].try_into().expect("suffix should fit"); + let prefix_b: B064K<'static> = vec![0xba].try_into().expect("prefix should fit"); + let suffix_b: B064K<'static> = vec![0xbb].try_into().expect("suffix should fit"); + + remember_template_coinbase_parts( + &mut template_coinbase_parts, + 11, + prefix_a.clone(), + suffix_a.clone(), + ); + remember_template_coinbase_parts( + &mut template_coinbase_parts, + 22, + prefix_b.clone(), + suffix_b.clone(), + ); + + assert_eq!( + take_template_coinbase_parts(&mut template_coinbase_parts, 11), + Some((prefix_a, suffix_a)) + ); + assert_eq!( + take_template_coinbase_parts(&mut template_coinbase_parts, 22), + Some((prefix_b, suffix_b)) + ); + assert!( + take_template_coinbase_parts(&mut template_coinbase_parts, 11).is_none(), + "consuming template A should not disturb template B, but it should remove A" + ); + } } diff --git a/src/jd_client/mining_downstream/mod.rs b/src/jd_client/mining_downstream/mod.rs index 37b6286..a41d139 100644 --- a/src/jd_client/mining_downstream/mod.rs +++ b/src/jd_client/mining_downstream/mod.rs @@ -356,7 +356,6 @@ impl DownstreamMiningNode { pool_output: &[u8], ) -> Result<(), JdClientError> { let template_id = new_template.template_id; - let template_is_future = new_template.future_template; // Make sure to set the template handled to true since we do not have a channel opened yet // and template can not be handled without it we will lock template handling forever. if !self_mutex @@ -398,13 +397,7 @@ impl DownstreamMiningNode { let message = if let Mining::NewExtendedMiningJob(job) = message { self_mutex .safe_lock(|s| { - remember_template_job( - &mut s.job_template_ids, - &mut s.prev_job_id, - template_is_future, - job.job_id, - template_id, - ); + remember_template_job(&mut s.job_template_ids, job.job_id, template_id); }) .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; let jd = self_mutex @@ -414,10 +407,13 @@ impl DownstreamMiningNode { // Propagate error. The caller will restart proxy JdClientError::JdMissing })?; - jd.safe_lock(|jd| jd.coinbase_tx_prefix = job.coinbase_tx_prefix.clone()) - .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)?; - jd.safe_lock(|jd| jd.coinbase_tx_suffix = job.coinbase_tx_suffix.clone()) - .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)?; + JobDeclarator::remember_template_coinbase_parts( + &jd, + template_id, + job.coinbase_tx_prefix.clone(), + job.coinbase_tx_suffix.clone(), + ) + .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)?; Mining::NewExtendedMiningJob(job) } else { @@ -640,50 +636,17 @@ impl m: SubmitSharesExtended, ) -> Result, Error> { let is_solo_miner = self.status.is_solo_miner(); - if share_uses_stale_job(self.prev_job_id, is_solo_miner, m.job_id) { - if !is_solo_miner && share_is_newer_than_current_job(self.prev_job_id, m.job_id) { - let full_extranonce = match self.status.get_channel() { - Ok(channel) => channel - .extranonce_from_downstream_extranonce(m.extranonce.clone().into()) - .map(|extranonce| extranonce.to_vec()), - Err(_) => None, - }; - - if let Some(full_extranonce) = full_extranonce.as_deref() { - if let Some(template_id) = self.job_template_ids.get(&m.job_id).copied() { - match self.valid_job_tracker.record_rsk_target_share( - template_id, - m.version, - m.ntime, - m.nonce, - full_extranonce, - ) { - Ok(Some(_)) => {} - Ok(None) => {} - Err(error) => { - error!( - "Failed to record stale-response RSK-target valid job for polling API: {error}" - ); - } - } - } else { - debug!( - submitted_job_id = m.job_id, - "Skipping stale-response RSK-target share because no current-prevhash template_id was cached for the JD job" - ); - } - } else { - debug!( - submitted_job_id = m.job_id, - "Skipping stale-response RSK-target share because the full extranonce could not be reconstructed" - ); - } - } - + if share_uses_stale_job( + self.prev_job_id, + &self.job_template_ids, + is_solo_miner, + m.job_id, + ) { warn!( submitted_job_id = m.job_id, current_job_id = self.prev_job_id, - "Rejecting JD share because it references a non-current clean job" + tracked_job_count = self.job_template_ids.len(), + "Rejecting JD share because it references a job outside the current clean-job family" ); let error = SubmitSharesError { channel_id: m.channel_id, @@ -881,81 +844,77 @@ impl } impl IsMiningDownstream for DownstreamMiningNode {} -fn remember_template_job( - job_template_ids: &mut HashMap, - current_job_id: &mut Option, - template_is_future: bool, - job_id: u32, - template_id: u64, -) { +fn remember_template_job(job_template_ids: &mut HashMap, job_id: u32, template_id: u64) { job_template_ids.insert(job_id, template_id); - if !template_is_future { - *current_job_id = Some(job_id); - } } fn share_uses_stale_job( current_job_id: Option, + job_template_ids: &HashMap, is_solo_miner: bool, submitted_job_id: u32, ) -> bool { - !is_solo_miner && current_job_id.is_some_and(|current| current != submitted_job_id) -} - -fn share_is_newer_than_current_job(current_job_id: Option, submitted_job_id: u32) -> bool { - current_job_id.is_some_and(|current| submitted_job_id > current) + !is_solo_miner && current_job_id.is_some() && !job_template_ids.contains_key(&submitted_job_id) } #[cfg(test)] mod tests { - use super::{remember_template_job, share_is_newer_than_current_job, share_uses_stale_job}; + use super::{remember_template_job, share_uses_stale_job}; use std::collections::HashMap; #[test] - fn remember_template_job_advances_current_job_for_non_future_templates() { + fn remember_template_job_tracks_job_to_template_without_advancing_current_job() { let mut job_template_ids = HashMap::new(); - let mut current_job_id = Some(7); + let current_job_id = Some(7); - remember_template_job(&mut job_template_ids, &mut current_job_id, false, 8, 42); + remember_template_job(&mut job_template_ids, 8, 42); - assert_eq!(current_job_id, Some(8)); + assert_eq!(current_job_id, Some(7)); assert_eq!(job_template_ids.get(&8), Some(&42)); } #[test] - fn remember_template_job_keeps_current_job_for_future_templates() { + fn remember_template_job_tracks_multiple_jobs_for_same_clean_family() { let mut job_template_ids = HashMap::new(); - let mut current_job_id = Some(7); + remember_template_job(&mut job_template_ids, 7, 41); + remember_template_job(&mut job_template_ids, 8, 42); - remember_template_job(&mut job_template_ids, &mut current_job_id, true, 8, 42); - - assert_eq!(current_job_id, Some(7)); + assert_eq!(job_template_ids.get(&7), Some(&41)); assert_eq!(job_template_ids.get(&8), Some(&42)); } #[test] - fn newer_than_current_job_detects_false_stale_case() { - assert!(share_is_newer_than_current_job(Some(1), 7)); - } + fn stale_job_guard_allows_any_tracked_job_within_current_clean_family() { + let mut job_template_ids = HashMap::new(); + remember_template_job(&mut job_template_ids, 7, 41); + remember_template_job(&mut job_template_ids, 8, 42); - #[test] - fn newer_than_current_job_ignores_older_or_equal_jobs() { - assert!(!share_is_newer_than_current_job(Some(7), 7)); - assert!(!share_is_newer_than_current_job(Some(7), 6)); + assert!(!share_uses_stale_job(Some(7), &job_template_ids, false, 7)); + assert!(!share_uses_stale_job(Some(7), &job_template_ids, false, 8)); } #[test] - fn stale_job_guard_rejects_older_non_solo_job_ids() { - assert!(share_uses_stale_job(Some(7), false, 6)); + fn stale_job_guard_rejects_unknown_job_once_current_clean_family_is_known() { + let mut job_template_ids = HashMap::new(); + remember_template_job(&mut job_template_ids, 7, 41); + + assert!(share_uses_stale_job(Some(7), &job_template_ids, false, 6)); + assert!(share_uses_stale_job(Some(7), &job_template_ids, false, 8)); } #[test] - fn stale_job_guard_allows_current_job_id() { - assert!(!share_uses_stale_job(Some(7), false, 7)); + fn stale_job_guard_is_disabled_until_a_clean_job_family_exists() { + let mut job_template_ids = HashMap::new(); + remember_template_job(&mut job_template_ids, 8, 42); + + assert!(!share_uses_stale_job(None, &job_template_ids, false, 7)); } #[test] fn stale_job_guard_is_disabled_for_solo_mode() { - assert!(!share_uses_stale_job(Some(7), true, 6)); + let mut job_template_ids = HashMap::new(); + remember_template_job(&mut job_template_ids, 7, 41); + + assert!(!share_uses_stale_job(Some(7), &job_template_ids, true, 6)); } } diff --git a/src/op_return_injector.rs b/src/op_return_injector.rs index 38c30cb..99dcb39 100644 --- a/src/op_return_injector.rs +++ b/src/op_return_injector.rs @@ -328,18 +328,30 @@ fn template_already_contains_payload( ) -> Result { let bytes = template.coinbase_tx_outputs.to_vec(); let mut cursor = bytes.as_slice(); + let expect_last_rsk_payload = desired.payload_bytes.starts_with(RSK_MERGED_MINING_TAG); + let mut matching_payload_seen = false; + let mut last_rsk_payload = None; for _ in 0..template.coinbase_tx_outputs_count { let tx_out = TxOut::consensus_decode(&mut cursor) .map_err(|error| OpReturnInjectorError::Encoding(error.to_string()))?; - if extract_single_op_return_pushdata(&tx_out.script_pubkey) - .is_some_and(|payload| payload == desired.payload_bytes) - { - return Ok(true); + if let Some(payload) = extract_single_op_return_pushdata(&tx_out.script_pubkey) { + if payload == desired.payload_bytes { + matching_payload_seen = true; + } + if expect_last_rsk_payload && payload.starts_with(RSK_MERGED_MINING_TAG) { + last_rsk_payload = Some(payload); + } } } - Ok(false) + if expect_last_rsk_payload { + Ok(last_rsk_payload + .as_deref() + .is_some_and(|payload| payload == desired.payload_bytes.as_slice())) + } else { + Ok(matching_payload_seen) + } } fn extract_single_op_return_pushdata(script_pubkey: &ScriptBuf) -> Option> { @@ -564,6 +576,57 @@ mod tests { assert_eq!(template.coinbase_tx_outputs_count, 1); } + #[tokio::test] + async fn appends_desired_rsk_payload_when_an_older_matching_tag_is_not_last() { + let injector = OpReturnInjector::default(); + let desired_payload_hex = "52534b424c4f434b3adeadbeef"; + let desired_payload = + Vec::from_hex(desired_payload_hex).expect("desired payload should decode"); + let later_payload = + Vec::from_hex("52534b424c4f434b3acafebabe").expect("later payload should decode"); + let mut template = make_new_template(51); + let mut outputs = Vec::new(); + + for payload in [&desired_payload, &later_payload] { + let tx_out = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload.clone()).expect("payload should fit"), + ), + }; + tx_out + .consensus_encode(&mut outputs) + .expect("Vec should encode"); + } + template.coinbase_tx_outputs_count = 2; + template.coinbase_tx_outputs = outputs + .try_into() + .expect("serialized outputs should fit in B064K"); + + injector + .queue_from_hex(desired_payload_hex) + .await + .expect("payload should queue"); + injector + .apply_pending_to_template(&mut template) + .await + .expect("template apply should succeed") + .expect("payload should still be reported as applied"); + + assert_eq!( + template.coinbase_tx_outputs_count, 3, + "the desired payload must be appended again so it becomes the last RSKBLOCK tag", + ); + + let mut cursor = template.coinbase_tx_outputs.as_ref(); + let mut last_payload = None; + for _ in 0..template.coinbase_tx_outputs_count { + let tx_out = TxOut::consensus_decode(&mut cursor).expect("valid tx out"); + last_payload = extract_single_op_return_pushdata(&tx_out.script_pubkey); + } + assert_eq!(last_payload, Some(desired_payload)); + } + #[tokio::test] async fn rejects_invalid_hex_payload() { let injector = OpReturnInjector::default(); diff --git a/src/valid_job_tracker.rs b/src/valid_job_tracker.rs index 6a786a0..1af543c 100644 --- a/src/valid_job_tracker.rs +++ b/src/valid_job_tracker.rs @@ -673,11 +673,16 @@ pub(crate) fn coinbase_contains_expected_op_return_payload( coinbase_transaction: &Transaction, expected_payload: &[u8], ) -> bool { - coinbase_transaction - .output - .iter() - .filter_map(|output| extract_single_op_return_pushdata(&output.script_pubkey)) - .any(|payload| payload == expected_payload) + if expected_payload.starts_with(RSK_MERGED_MINING_TAG) { + return last_rskblock_payload(coinbase_transaction) + .as_deref() + .is_some_and(|payload| payload == expected_payload); + } + + coinbase_transaction.output.iter().any(|output| { + extract_single_op_return_pushdata(&output.script_pubkey) + .is_some_and(|payload| payload == expected_payload) + }) } fn extract_single_op_return_pushdata(script_pubkey: &bitcoin::ScriptBuf) -> Option> { @@ -703,6 +708,15 @@ fn extract_single_op_return_pushdata(script_pubkey: &bitcoin::ScriptBuf) -> Opti Some(payload) } +fn last_rskblock_payload(coinbase_transaction: &Transaction) -> Option> { + coinbase_transaction + .output + .iter() + .filter_map(|output| extract_single_op_return_pushdata(&output.script_pubkey)) + .filter(|payload| payload.starts_with(RSK_MERGED_MINING_TAG)) + .last() +} + fn unix_timestamp_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1633,6 +1647,60 @@ mod tests { ); } + #[tokio::test] + async fn record_found_job_requires_expected_payload_to_be_last_rskblock_tag() { + let (injector, tracker) = make_tracker(); + let payload_hex = rsk_payload_hex("cafebabe"); + let payload = Vec::from_hex(&payload_hex).expect("payload should decode"); + let later_payload = + Vec::from_hex(&rsk_payload_hex("deadbeef")).expect("later payload should decode"); + + apply_payload_to_template(&injector, 78, &payload_hex).await; + tracker + .cache_template_context(78, [7; 32], 0x1d00ffff, vec![], 1) + .expect("tracker should cache template context"); + + let coinbase_with_later_rsk_tag = Transaction { + version: bitcoin::transaction::Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: vec![0x03, 0x07, 0x51].into(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![ + TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload).expect("payload should fit"), + ), + }, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(later_payload).expect("payload should fit"), + ), + }, + ], + }; + + assert_eq!( + tracker + .record_found_job(78, 2, 3, 4, &serialize(&coinbase_with_later_rsk_tag)) + .expect("tracker should reject mismatched last RSKBLOCK tag"), + None + ); + assert_eq!( + tracker.take_found_job().expect("tracker should read queue"), + None + ); + } + #[tokio::test] async fn record_found_job_builds_rskj_compatible_partial_merkle_payload() { let (injector, tracker) = make_tracker();