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..4182f5c 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,8 +51,13 @@ pub struct LastDeclareJob { template: NewTemplate<'static>, coinbase_pool_output: Vec, tx_list: Seq064K<'static, B016M<'static>>, + 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>>, @@ -71,12 +77,13 @@ 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>, + template_coinbase_parts: TemplateCoinbasePartsMap, + valid_job_tracker: ValidJobTracker, pub task_manager: Arc>, } @@ -86,6 +93,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)?; @@ -116,8 +124,8 @@ 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, })); @@ -246,6 +254,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(); @@ -275,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, @@ -297,6 +312,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 +393,7 @@ impl JobDeclarator { merkle_path, template, last_declare.coinbase_pool_output, + last_declare.block_tx_count, ), ); }) { @@ -398,20 +415,47 @@ 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 = match self_mutex + .safe_lock(|s| s.valid_job_tracker.clone()) + { + Ok(valid_job_tracker) => valid_job_tracker, + 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, + &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, + 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 +515,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 +524,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 +561,15 @@ 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); + cache_ready_template_context( + &valid_job_tracker, + template.template_id, + &set_new_prev_hash, + &merkle_path, + block_tx_count, + &job.coinbase_prefix.to_vec(), + &job.coinbase_suffix.to_vec(), + ); if let Err(e) = Upstream::set_custom_jobs( &up, job, @@ -594,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( @@ -647,10 +726,93 @@ 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 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, + 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, + 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() { @@ -670,4 +832,45 @@ 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); + } + + #[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 4b27852..a41d139 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,7 @@ impl DownstreamMiningNode { mut new_template: NewTemplate<'static>, pool_output: &[u8], ) -> Result<(), JdClientError> { + let template_id = new_template.template_id; // 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 +395,11 @@ 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, job.job_id, template_id); + }) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; let jd = self_mutex .safe_lock(|s| s.jd.clone()) .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)? @@ -394,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 { @@ -429,6 +445,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 +635,30 @@ 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, + &self.job_template_ids, + is_solo_miner, + m.job_id, + ) { + warn!( + submitted_job_id = m.job_id, + current_job_id = self.prev_job_id, + 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, + 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 +673,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 +727,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 +843,78 @@ impl } } impl IsMiningDownstream for DownstreamMiningNode {} + +fn remember_template_job(job_template_ids: &mut HashMap, job_id: u32, template_id: u64) { + job_template_ids.insert(job_id, template_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() && !job_template_ids.contains_key(&submitted_job_id) +} + +#[cfg(test)] +mod tests { + use super::{remember_template_job, share_uses_stale_job}; + use std::collections::HashMap; + + #[test] + fn remember_template_job_tracks_job_to_template_without_advancing_current_job() { + let mut job_template_ids = HashMap::new(); + let current_job_id = Some(7); + + remember_template_job(&mut job_template_ids, 8, 42); + + assert_eq!(current_job_id, Some(7)); + assert_eq!(job_template_ids.get(&8), Some(&42)); + } + + #[test] + fn remember_template_job_tracks_multiple_jobs_for_same_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); + + assert_eq!(job_template_ids.get(&7), Some(&41)); + assert_eq!(job_template_ids.get(&8), Some(&42)); + } + + #[test] + 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); + + 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_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_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() { + 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/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 new file mode 100644 index 0000000..99dcb39 --- /dev/null +++ b/src/op_return_injector.rs @@ -0,0 +1,682 @@ +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}, +}; +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(Debug, Clone, Default)] +pub struct OpReturnInjector { + desired: Arc>>, + applied_to_templates: Arc>>, +} + +#[derive(Clone, Debug)] +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)] +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), + InvalidRskTarget(String), + PayloadTooLarge(usize), + TxOutTooLarge(usize), + TooManyCoinbaseOutputs, + CoinbaseOutputsTooLarge, + Encoding(String), + AppliedPayloadCacheCorrupted, +} + +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::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" + ), + 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}") + } + OpReturnInjectorError::AppliedPayloadCacheCorrupted => { + f.write_str("Applied OP_RETURN template cache corrupted") + } + } + } +} + +impl OpReturnInjector { + pub async fn queue_from_hex( + &self, + data_hex: &str, + ) -> Result { + 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: desired.payload_hex.clone(), + payload_len_bytes: desired.payload_len_bytes, + tx_out_len_bytes: desired.tx_out_len_bytes, + replaced_pending, + }) + } + + pub async fn apply_pending_to_template<'a>( + &self, + template: &mut NewTemplate<'a>, + ) -> Result, OpReturnInjectorError> { + let desired = { + let guard = self.desired.lock().await; + guard.as_ref().cloned() + }; + let Some(desired) = desired else { + return Ok(None); + }; + + append_to_template(template, &desired)?; + self.cache_applied_payload(template.template_id, &desired)?; + + Ok(Some(AppliedOpReturn { + 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> { + 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); + } +} + +#[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_bytes = Vec::from_hex(&payload_hex) + .map_err(|error| OpReturnInjectorError::InvalidHex(error.to_string()))?; + if payload_bytes.is_empty() { + return Err(OpReturnInjectorError::EmptyPayload); + } + 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_bytes.clone()) + .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())); + } + + 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>, + 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(&desired.tx_out_bytes); + template.coinbase_tx_outputs = outputs + .try_into() + .map_err(|_| OpReturnInjectorError::CoinbaseOutputsTooLarge)?; + template.coinbase_tx_outputs_count = next_outputs_count; + 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(); + 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 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); + } + } + } + + 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> { + 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(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, + } + } + + 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 queued_payload_remains_applied_across_successive_templates_until_replaced() { + let injector = OpReturnInjector::default(); + injector + .queue_from_hex("deadbeef") + .await + .expect("queue should succeed"); + + let mut first_template = make_new_template(11); + let mut second_template = make_new_template(12); + + 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"); + + 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!( + 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" + ); + } + + #[tokio::test] + async fn replacing_payload_updates_later_templates_without_mutating_older_templates() { + let injector = OpReturnInjector::default(); + let mut template_a = make_new_template(21); + let mut template_b = make_new_template(22); + + injector + .queue_from_hex("aa") + .await + .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("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"); + + 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("payload should queue"); + + 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"); + + assert_eq!(apply_error, OpReturnInjectorError::CoinbaseOutputsTooLarge); + assert_eq!(incompatible_template.coinbase_tx_outputs_count, 0); + assert_eq!( + 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] + 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(); + let error = injector + .queue_from_hex("zz") + .await + .expect_err("invalid hex should be rejected"); + + 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(); + 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) + ); + } +} 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..1af543c --- /dev/null +++ b/src/valid_job_tracker.rs @@ -0,0 +1,2088 @@ +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 { + 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> { + 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 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) + .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_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(); + 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));