From 4817d56d2edb79637ecda4c55a27de05f1dd4404 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:40:14 +0900 Subject: [PATCH 01/61] Add native mining pools with durable PPLNS Share mining jobs and share validation across opt-in SV1 and authenticated SV2 listeners. Keep durable PPLNS accounting and external-signer reconciliation behind one ledger owner. Add the explicitly approved PoolLedger storage family without resetting existing records. Verify with 490 behavioral tests, strict feature-enabled workspace lint, and 14 architecture tests. No operator database was opened or migrated. --- Cargo.lock | 398 ++- Cargo.toml | 20 + bin/bitcoin-rs/Cargo.toml | 2 + bin/bitcoin-rs/src/config.rs | 2 +- .../tests/support/dependency_graph.rs | 12 +- crates/node/Cargo.toml | 6 + crates/node/src/config.rs | 259 ++ crates/node/src/lib.rs | 3 + crates/node/src/lifecycle.rs | 52 +- crates/node/src/options.rs | 105 + crates/node/src/pool.rs | 285 ++ crates/node/src/pool/authority.rs | 105 + crates/node/src/pool/ledger.rs | 653 ++++ crates/node/src/pool/sv2.rs | 467 +++ crates/node/src/pool/tests.rs | 388 +++ crates/node/src/state.rs | 8 + crates/node/tests/unit/config/tests.rs | 191 ++ crates/pool-ledger/Cargo.toml | 32 + crates/pool-ledger/src/kv.rs | 410 +++ crates/pool-ledger/src/lib.rs | 1859 +++++++++++ crates/pool-ledger/src/store.rs | 2598 +++++++++++++++ crates/pool-ledger/tests/ledger.rs | 9 + .../tests/support/kv_transactions.rs | 71 + .../pool-ledger/tests/support/ledger_cases.rs | 2827 +++++++++++++++++ crates/rpc/Cargo.toml | 3 + crates/rpc/src/context.rs | 24 + crates/rpc/src/handlers.rs | 24 + crates/rpc/src/handlers/pool.rs | 584 ++++ crates/rpc/src/registry.rs | 23 + crates/storage/src/column_families.rs | 6 + crates/storage/src/redb_impl.rs | 1 + crates/storage/tests/backend_equivalence.rs | 84 + crates/stratum-v2/Cargo.toml | 51 + crates/stratum-v2/src/lib.rs | 809 +++++ crates/stratum-v2/src/session.rs | 1978 ++++++++++++ crates/stratum-v2/tests/protocol.rs | 2370 ++++++++++++++ crates/stratum/Cargo.toml | 32 + crates/stratum/src/job.rs | 1555 +++++++++ crates/stratum/src/lib.rs | 34 + crates/stratum/src/sv1.rs | 1199 +++++++ crates/stratum/src/sv1/session.rs | 1967 ++++++++++++ crates/stratum/tests/jobs.rs | 1246 ++++++++ crates/stratum/tests/protocol.rs | 785 +++++ deny.toml | 30 +- docs/README.md | 1 + docs/contracts/README.md | 1 + docs/contracts/pool.md | 117 + docs/pool.md | 102 + docs/rpc-reference.md | 11 +- scripts/feature-matrix.tsv | 2 + 50 files changed, 23761 insertions(+), 40 deletions(-) create mode 100644 crates/node/src/pool.rs create mode 100644 crates/node/src/pool/authority.rs create mode 100644 crates/node/src/pool/ledger.rs create mode 100644 crates/node/src/pool/sv2.rs create mode 100644 crates/node/src/pool/tests.rs create mode 100644 crates/pool-ledger/Cargo.toml create mode 100644 crates/pool-ledger/src/kv.rs create mode 100644 crates/pool-ledger/src/lib.rs create mode 100644 crates/pool-ledger/src/store.rs create mode 100644 crates/pool-ledger/tests/ledger.rs create mode 100644 crates/pool-ledger/tests/support/kv_transactions.rs create mode 100644 crates/pool-ledger/tests/support/ledger_cases.rs create mode 100644 crates/rpc/src/handlers/pool.rs create mode 100644 crates/stratum-v2/Cargo.toml create mode 100644 crates/stratum-v2/src/lib.rs create mode 100644 crates/stratum-v2/src/session.rs create mode 100644 crates/stratum-v2/tests/protocol.rs create mode 100644 crates/stratum/Cargo.toml create mode 100644 crates/stratum/src/job.rs create mode 100644 crates/stratum/src/lib.rs create mode 100644 crates/stratum/src/sv1.rs create mode 100644 crates/stratum/src/sv1/session.rs create mode 100644 crates/stratum/tests/jobs.rs create mode 100644 crates/stratum/tests/protocol.rs create mode 100644 docs/contracts/pool.md create mode 100644 docs/pool.md diff --git a/Cargo.lock b/Cargo.lock index 75d53c74cb..2ab0fcdbfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,14 +2,49 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.2.2", + "inout 0.2.5", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", ] [[package]] @@ -144,7 +179,7 @@ version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", ] [[package]] @@ -165,6 +200,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "binary_sv2" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec138d56d7501e4a365ce858465f827197835da7fda8b56f9a1ddc0f4aa012e" +dependencies = [ + "derive_codec_sv2", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -194,10 +238,10 @@ dependencies = [ "bech32", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "hex-conservative 0.2.3", "hex_lit", - "secp256k1", + "secp256k1 0.29.1", "serde", ] @@ -307,7 +351,7 @@ dependencies = [ "hashbrown 0.17.1", "parking_lot", "rayon", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "sha2", @@ -339,7 +383,7 @@ dependencies = [ "bitcoin-rs-primitives", "bitcoin-rs-storage", "bitcoin-rs-utxo", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "compact_str", "crossbeam-channel", "hashbrown 0.17.1", @@ -347,7 +391,7 @@ dependencies = [ "parking_lot", "proptest", "rayon", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "sha2", @@ -415,10 +459,13 @@ dependencies = [ "bitcoin-rs-mining", "bitcoin-rs-node", "bitcoin-rs-p2p", + "bitcoin-rs-pool-ledger", "bitcoin-rs-primitives", "bitcoin-rs-rpc", "bitcoin-rs-script", "bitcoin-rs-storage", + "bitcoin-rs-stratum", + "bitcoin-rs-stratum-v2", "bitcoin-rs-utxo", "bytes", "cap-fs-ext", @@ -443,6 +490,7 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", "tracing", "tracing-subscriber", + "zeroize", ] [[package]] @@ -459,8 +507,8 @@ dependencies = [ "bitcoin-rs-primitives", "bitcoin-rs-storage", "bytes", - "chacha20", - "chacha20poly1305", + "chacha20 0.10.2", + "chacha20poly1305 0.11.0", "criterion", "crossbeam-channel", "hashbrown 0.17.1", @@ -477,6 +525,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "bitcoin-rs-pool-ledger" +version = "0.14.0" +dependencies = [ + "bitcoin-rs-storage", + "sha2", + "tempfile", +] + [[package]] name = "bitcoin-rs-primitives" version = "0.14.0" @@ -507,9 +564,11 @@ dependencies = [ "bitcoin-rs-mining", "bitcoin-rs-node", "bitcoin-rs-p2p", + "bitcoin-rs-pool-ledger", "bitcoin-rs-primitives", "bitcoin-rs-rpc", "bitcoin-rs-script", + "bitcoin-rs-storage", "bitcoin-rs-utxo", "bytes", "cap-fs-ext", @@ -540,10 +599,10 @@ dependencies = [ "bitcoin", "bitcoin-rs-consensus", "bitcoin-rs-primitives", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "criterion", "proptest", - "secp256k1", + "secp256k1 0.29.1", "serde_json", "sha2", "smallvec", @@ -602,6 +661,50 @@ dependencies = [ "tempfile", ] +[[package]] +name = "bitcoin-rs-stratum" +version = "0.14.0" +dependencies = [ + "bitcoin-rs-chain", + "bitcoin-rs-consensus", + "bitcoin-rs-mempool", + "bitcoin-rs-mining", + "bitcoin-rs-primitives", + "bitcoin-rs-script", + "compact_str", + "parking_lot", + "ruint", + "sha2", + "sonic-rs", + "thiserror", +] + +[[package]] +name = "bitcoin-rs-stratum-v2" +version = "0.14.0" +dependencies = [ + "binary_sv2", + "bitcoin-rs-chain", + "bitcoin-rs-consensus", + "bitcoin-rs-mempool", + "bitcoin-rs-mining", + "bitcoin-rs-primitives", + "bitcoin-rs-script", + "bitcoin-rs-stratum", + "codec_sv2", + "common_messages_sv2", + "framing_sv2", + "mining_sv2", + "noise_sv2", + "parking_lot", + "rand 0.8.8", + "ruint", + "secp256k1 0.28.2", + "sha2", + "thiserror", + "zeroize", +] + [[package]] name = "bitcoin-rs-utxo" version = "0.14.0" @@ -618,7 +721,7 @@ dependencies = [ "proptest", "rayon", "ruint", - "secp256k1", + "secp256k1 0.29.1", "sha2", "smallvec", "tempfile", @@ -637,6 +740,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446819536d8121575eeb7e89efdbadb3f055e87e4bb66c6679a6d5cc2f4b64fd" +dependencies = [ + "hex-conservative 0.1.2", +] + [[package]] name = "bitcoin_hashes" version = "0.14.101" @@ -678,6 +790,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "buffer_sv2" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c1cac04b754ae9750cd8b2d9948bd8db2b2b00f8192024a500e7ba8a3fb2eb" +dependencies = [ + "aes-gcm", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -832,6 +953,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -839,8 +971,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.5.2", + "cpufeatures 0.3.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", ] [[package]] @@ -849,10 +994,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.6.1", + "chacha20 0.10.2", + "cipher 0.5.2", + "poly1305 0.9.1", ] [[package]] @@ -882,6 +1027,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + [[package]] name = "cipher" version = "0.5.2" @@ -889,8 +1045,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer", - "crypto-common", - "inout", + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] @@ -950,12 +1106,34 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "codec_sv2" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27fa7e278b547a7793e352ec2d9346147758a45d7f6e0354762c632a5ef1022b" +dependencies = [ + "binary_sv2", + "buffer_sv2", + "framing_sv2", + "noise_sv2", + "rand 0.8.8", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "common_messages_sv2" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fc74eef0c73f31e57bbe5ecbda80629bc98b2dd5ca8ba7fc9a145ec22166c6" +dependencies = [ + "binary_sv2", +] + [[package]] name = "compact_str" version = "0.10.0" @@ -986,6 +1164,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1080,6 +1267,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -1089,6 +1286,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.5" @@ -1112,6 +1318,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "derive_codec_sv2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05768350591ccb368bd0ec96dc9ca27ca6a04a4cc298718f139e323c27a89835" + [[package]] name = "digest" version = "0.11.3" @@ -1119,7 +1331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -1259,6 +1471,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "framing_sv2" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cedad16f0ac769b23506b19b0b1004a40d822d30b35106ffb51845e3b8da405" +dependencies = [ + "binary_sv2", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -1309,6 +1530,16 @@ dependencies = [ "windows-result", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1343,6 +1574,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.4" @@ -1408,6 +1649,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + [[package]] name = "hex-conservative" version = "0.2.3" @@ -1469,6 +1716,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inout" version = "0.2.5" @@ -1777,6 +2033,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mining_sv2" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab45eca584d0e0478ea87919f7c7f93d579bb453ecb1e772e46ffe9b20a89bdb" +dependencies = [ + "binary_sv2", +] + [[package]] name = "miniscript" version = "13.2.0" @@ -1814,6 +2079,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "noise_sv2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d54d5ba485f6a44a4195336cdbedca81e9afa875e656d7b28661ca9974c475" +dependencies = [ + "chacha20poly1305 0.10.1", + "rand 0.8.8", + "secp256k1 0.28.2", + "zeroize", +] + [[package]] name = "nom" version = "7.1.3" @@ -1861,6 +2138,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "page_size" version = "0.6.0" @@ -1984,14 +2267,37 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "poly1305" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", ] [[package]] @@ -2487,18 +2793,38 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes 0.13.1", + "rand 0.8.8", + "secp256k1-sys 0.9.2", +] + [[package]] name = "secp256k1" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "rand 0.8.8", - "secp256k1-sys", + "secp256k1-sys 0.10.1", "serde", ] +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -2605,7 +2931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "digest", ] @@ -2750,6 +3076,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -3045,13 +3377,23 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] diff --git a/Cargo.toml b/Cargo.toml index baaa3ee977..4517307bec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ members = [ "crates/mempool", "crates/p2p", "crates/mining", + "crates/stratum", + "crates/pool-ledger", + "crates/stratum-v2", "crates/rpc", "crates/node", "bin/bitcoin-rs", @@ -51,6 +54,9 @@ bitcoin-rs-index = { path = "crates/index", version = "0.14.0" } bitcoin-rs-mempool = { path = "crates/mempool", version = "0.14.0" } bitcoin-rs-p2p = { path = "crates/p2p", version = "0.14.0" } bitcoin-rs-mining = { path = "crates/mining", version = "0.14.0" } +bitcoin-rs-stratum = { path = "crates/stratum", version = "0.14.0" } +bitcoin-rs-pool-ledger = { path = "crates/pool-ledger", version = "0.14.0" } +bitcoin-rs-stratum-v2 = { path = "crates/stratum-v2", version = "0.14.0" } bitcoin-rs-rpc = { path = "crates/rpc", version = "0.14.0" } bitcoin-rs-node = { path = "crates/node", version = "0.14.0", default-features = false } bitcoin-rs-e2e = { path = "e2e", version = "0.14.0" } @@ -58,6 +64,20 @@ bitcoin-rs-e2e = { path = "e2e", version = "0.14.0" } # Allocator. Defaults pull `secure` and `extended` overhead — disable for perf. mimalloc = { version = ">=0.1.50, <0.2", default-features = false } +# --- Stratum V2 transport ------------------------------------------------------ +# Pinned SRI release family (v1.12.0): these crates are semver-major-coupled +# and their wire layouts MUST move together. noise_sv2 2.0.0 is admitted with +# the narrow duplicate-crypto exception documented in deny.toml. +binary_sv2 = "=7.0.0" +framing_sv2 = "=8.0.0" +codec_sv2 = { version = "=7.0.0" } +noise_sv2 = "=2.0.0" +mining_sv2 = "=12.0.0" +common_messages_sv2 = "=9.0.0" + +# Authority-key zeroization used by the SV2 Noise boundary. +zeroize = { version = ">=1.8.2, <2", default-features = false, features = ["alloc"] } + # --- Bitcoin ecosystem -------------------------------------------------------- # bitcoin 0.32 line; 0.33 is still beta. `default = [std, secp-recovery]`. # Keep `bitcoinconsensus-std` out of the workspace-wide dependency: the diff --git a/bin/bitcoin-rs/Cargo.toml b/bin/bitcoin-rs/Cargo.toml index 73046a020c..13c08926c6 100644 --- a/bin/bitcoin-rs/Cargo.toml +++ b/bin/bitcoin-rs/Cargo.toml @@ -48,6 +48,8 @@ redb = ["bitcoin-rs-node/redb"] kernel = ["bitcoin-rs-node/kernel"] zmq = ["bitcoin-rs-node/zmq"] silent-payments = ["bitcoin-rs-node/silent-payments"] +pool = ["bitcoin-rs-node/pool"] +pool-sv2 = ["bitcoin-rs-node/pool-sv2"] [[test]] name = "g17_dependency_direction" diff --git a/bin/bitcoin-rs/src/config.rs b/bin/bitcoin-rs/src/config.rs index 7df86af40f..766148978e 100644 --- a/bin/bitcoin-rs/src/config.rs +++ b/bin/bitcoin-rs/src/config.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; use bitcoin_rs_chainstate::ValidationMode; use bitcoin_rs_node::options::{ - parse_connect_endpoint, parse_network, parse_p2p_magic, parse_script_index, + PoolOverrides, parse_connect_endpoint, parse_network, parse_p2p_magic, parse_script_index, parse_storage_backend, parse_validation_engine, parse_validation_mode, }; use bitcoin_rs_node::{ diff --git a/bin/bitcoin-rs/tests/support/dependency_graph.rs b/bin/bitcoin-rs/tests/support/dependency_graph.rs index b8f809500b..385717c2fd 100644 --- a/bin/bitcoin-rs/tests/support/dependency_graph.rs +++ b/bin/bitcoin-rs/tests/support/dependency_graph.rs @@ -64,8 +64,16 @@ pub(crate) fn approved_layer(crate_name: &str) -> u8 { match crate_name { "bitcoin-rs-primitives" | "bitcoin-rs-script" | "bitcoin-rs-consensus" => 0, STORAGE_CRATE => 1, - "bitcoin-rs-chain" | CHAINSTATE_CRATE | "bitcoin-rs-utxo" | "bitcoin-rs-p2p" - | "bitcoin-rs-mempool" | "bitcoin-rs-index" | "bitcoin-rs-mining" => 2, + "bitcoin-rs-chain" + | CHAINSTATE_CRATE + | "bitcoin-rs-utxo" + | "bitcoin-rs-p2p" + | "bitcoin-rs-mempool" + | "bitcoin-rs-index" + | "bitcoin-rs-mining" + | "bitcoin-rs-stratum" + | "bitcoin-rs-stratum-v2" + | "bitcoin-rs-pool-ledger" => 2, RPC_CRATE => 3, NODE_CRATE | BIN_CRATE diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 75acd145e0..c19d0fd383 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -59,6 +59,8 @@ redb = [ kernel = ["bitcoin-rs-chainstate/kernel"] zmq = ["bitcoin-rs-rpc/zmq"] silent-payments = ["bitcoin-rs-rpc/silent-payments"] +pool = ["bitcoin-rs-rpc/pool", "dep:bitcoin-rs-stratum", "dep:bitcoin-rs-pool-ledger"] +pool-sv2 = ["pool", "dep:bitcoin-rs-stratum-v2", "dep:zeroize"] [dependencies] hashbrown.workspace = true @@ -74,6 +76,10 @@ bitcoin-rs-mempool.workspace = true bitcoin-rs-p2p.workspace = true bitcoin-rs-mining.workspace = true bitcoin-rs-rpc.workspace = true +bitcoin-rs-stratum = { workspace = true, optional = true } +bitcoin-rs-stratum-v2 = { workspace = true, optional = true } +bitcoin-rs-pool-ledger = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } bitcoin.workspace = true bytes.workspace = true diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index df02496957..5a5d90557a 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -124,6 +124,250 @@ const DEFAULT_STORAGE_BACKEND: StorageBackend = StorageBackend::Fjall; const DEFAULT_LOG_LEVEL: &str = "info"; const DEFAULT_DBCACHE_MB: u64 = 450; +/// Resolved noncustodial pool policy. No path is opened during resolution. +#[derive(Clone, Debug)] +pub struct PoolConfig { + /// Dedicated ledger directory. + pub datadir: PathBuf, + /// Explicit SV1 listener, if enabled. + pub v1_bind: Option, + /// Explicit SV2 listener, if enabled. + pub sv2_bind: Option, + /// Authority key path, validated only by the service. + pub sv2_authority_key: Option, + /// Operator consent to non-loopback listeners. + pub allow_non_loopback: bool, + /// Shared connection limit. + pub max_connections: u32, + /// Shared per-IP connection limit. + pub max_connections_per_ip: u32, + /// Fixed exact decimal share difficulty. + pub share_difficulty: String, + /// Target resolved by the authoritative share arithmetic owner. + #[cfg(feature = "pool")] + pub share_target: bitcoin_rs_stratum::ShareTarget, + /// Immutable PPLNS work window. + pub window_work: u64, + /// Immutable fee basis points. + pub fee_bps: u16, + /// Externally controlled reward scriptPubKey. + pub reward_script: Vec, + /// Coinbase confirmation requirement. + pub maturity: u32, + /// Payout confirmation requirement. + pub payout_confirmations: u32, + /// Depth of reorg monitoring. + pub reorg_watch_depth: u32, + /// Minimum payout in satoshis. + pub min_payout_sat: u64, + /// Maximum outputs per payout batch. + pub max_payout_outputs: u32, +} + +impl PoolConfig { + /// Immutable ledger policy, shared by ledger creation and reopen validation. + /// Abandon depth stays fixed at the ledger's six-confirmation default; + /// dust stays at 546 satoshis and no signer change scripts are authorized. + #[cfg(feature = "pool")] + #[must_use] + pub fn ledger_config(&self) -> pool_ledger::PoolConfig { + let mut config = + pool_ledger::PoolConfig::new(u128::from(self.window_work), self.reward_script.clone()); + config.pool_fee_bps = self.fee_bps; + config.coinbase_maturity = self.maturity; + config.payout_confirmations = self.payout_confirmations; + config.reorg_watch_depth = self.reorg_watch_depth; + config + } + + fn validate(&self) -> Result<()> { + anyhow::ensure!( + cfg!(feature = "pool"), + "--pool requires the pool build feature" + ); + anyhow::ensure!( + !self.datadir.as_os_str().is_empty(), + "--pool-datadir must not be empty" + ); + anyhow::ensure!( + self.v1_bind.is_some() || self.sv2_bind.is_some(), + "--pool requires an explicit --pool-v1-bind or --pool-sv2-bind" + ); + anyhow::ensure!( + self.v1_bind.is_none() || self.v1_bind != self.sv2_bind, + "pool listener addresses must differ" + ); + for bind in [self.v1_bind, self.sv2_bind].into_iter().flatten() { + anyhow::ensure!( + bind.port() != 0, + "pool listener ports must be explicit and nonzero" + ); + anyhow::ensure!( + bind.ip().is_loopback() || self.allow_non_loopback, + "non-loopback pool bind requires --pool-allow-non-loopback" + ); + } + anyhow::ensure!( + self.sv2_bind.is_none() || cfg!(feature = "pool-sv2"), + "SV2 requires the pool-sv2 build feature" + ); + anyhow::ensure!( + self.sv2_bind.is_some() == self.sv2_authority_key.is_some(), + "--pool-sv2-bind and --pool-sv2-authority-key must be configured together" + ); + anyhow::ensure!( + self.sv2_authority_key + .as_ref() + .is_none_or(|path| !path.as_os_str().is_empty()), + "pool SV2 authority key path must not be empty" + ); + anyhow::ensure!( + self.max_connections > 0 + && self.max_connections_per_ip > 0 + && self.max_connections_per_ip <= self.max_connections, + "pool connection limits must be positive and per-IP must not exceed total" + ); + anyhow::ensure!( + usize::try_from(self.max_connections).is_ok() + && usize::try_from(self.max_connections_per_ip).is_ok(), + "pool connection limits exceed platform bounds" + ); + anyhow::ensure!(self.window_work > 0, "--pool-window-work must be positive"); + anyhow::ensure!( + self.fee_bps <= 10_000, + "--pool-fee-bps must be at most 10000" + ); + anyhow::ensure!( + self.maturity >= 100 && self.payout_confirmations > 0, + "pool maturity must be at least 100 and payout confirmations must be positive" + ); + anyhow::ensure!( + self.reorg_watch_depth >= self.maturity.max(self.payout_confirmations).max(6), + "pool reorg watch depth must cover maturity, payout and abandon confirmations" + ); + anyhow::ensure!( + self.min_payout_sat >= 546 && self.min_payout_sat <= 2_100_000_000_000_000, + "pool minimum payout must be between 546 and MAX_MONEY satoshis" + ); + anyhow::ensure!( + (1..=2500).contains(&self.max_payout_outputs), + "pool maximum payout outputs must be in 1..=2500" + ); + anyhow::ensure!( + !self.reward_script.is_empty() + && self.reward_script.len() <= 10_000 + && self.reward_script[0] != 0x6a, + "pool reward script must be spendable and bounded" + ); + anyhow::ensure!( + bitcoin::Script::from_bytes(&self.reward_script) + .instructions_minimal() + .all(|instruction| instruction.is_ok()), + "pool reward script must have canonical script encoding" + ); + #[cfg(feature = "pool")] + { + self.ledger_config().validate()?; + let target = bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal( + &self.share_difficulty, + ) + .map_err(|error| anyhow::anyhow!("invalid pool share difficulty: {error}"))?; + anyhow::ensure!( + target == self.share_target, + "pool share target must match configured difficulty" + ); + } + Ok(()) + } +} + +fn resolve_pool(settings: &UserConfig) -> Result> { + let pool = &settings.pool; + if !pool.enabled.unwrap_or(false) { + anyhow::ensure!( + pool.datadir.is_none() + && pool.v1_bind.is_none() + && pool.sv2_bind.is_none() + && pool.sv2_authority_key.is_none() + && pool.allow_non_loopback.is_none() + && pool.max_connections.is_none() + && pool.max_connections_per_ip.is_none() + && pool.share_difficulty.is_none() + && pool.window_work.is_none() + && pool.fee_bps.is_none() + && pool.reward_script.is_none() + && pool.maturity.is_none() + && pool.payout_confirmations.is_none() + && pool.reorg_watch_depth.is_none() + && pool.min_payout_sat.is_none() + && pool.max_payout_outputs.is_none(), + "pool sub-options require --pool" + ); + return Ok(None); + } + let reward = pool + .reward_script + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-reward-script"))?; + anyhow::ensure!( + reward.len() <= 20_000 && reward.len().is_multiple_of(2), + "pool reward script must be bounded even-length hex" + ); + let (pairs, remainder) = reward.as_bytes().as_chunks::<2>(); + anyhow::ensure!( + remainder.is_empty(), + "pool reward script must be even-length hex" + ); + let reward_script = pairs + .iter() + .map(|pair| { + let digit = |byte: u8| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + }; + match (digit(pair[0]), digit(pair[1])) { + (Some(high), Some(low)) => Ok(high * 16 + low), + _ => Err(anyhow::anyhow!("pool reward script must be hex")), + } + }) + .collect::>>()?; + Ok(Some(PoolConfig { + datadir: pool + .datadir + .clone() + .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-datadir"))?, + v1_bind: pool.v1_bind, + sv2_bind: pool.sv2_bind, + sv2_authority_key: pool.sv2_authority_key.clone(), + allow_non_loopback: pool.allow_non_loopback.unwrap_or(false), + max_connections: pool.max_connections.unwrap_or(128), + max_connections_per_ip: pool.max_connections_per_ip.unwrap_or(8), + share_difficulty: pool + .share_difficulty + .clone() + .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-share-difficulty"))?, + #[cfg(feature = "pool")] + share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal( + pool.share_difficulty + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-share-difficulty"))?, + ) + .map_err(|error| anyhow::anyhow!("invalid pool share difficulty: {error}"))?, + window_work: pool + .window_work + .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-window-work"))?, + fee_bps: pool.fee_bps.unwrap_or(0), + reward_script, + maturity: pool.maturity.unwrap_or(100), + payout_confirmations: pool.payout_confirmations.unwrap_or(6), + reorg_watch_depth: pool.reorg_watch_depth.unwrap_or(1000), + min_payout_sat: pool.min_payout_sat.unwrap_or(546), + max_payout_outputs: pool.max_payout_outputs.unwrap_or(2500), + })) +} + /// Resolved storage configuration. #[derive(Clone, Debug)] pub struct StorageConfig { @@ -246,6 +490,8 @@ pub struct NodeConfig { pub validation: ValidationConfig, /// Mining settings. pub mining: MiningConfig, + /// Native keyless pool, disabled unless explicitly configured. + pub pool: Option, } impl NodeConfig { @@ -342,6 +588,7 @@ impl NodeConfig { .unwrap_or(ValidationEngine::Native), }, mining: MiningConfig::default(), + pool: None, } } @@ -351,6 +598,13 @@ impl NodeConfig { /// cannot execute is rejected here — before the data directory opens, /// chainstate exists, or any worker starts. pub fn validate(&self) -> Result<()> { + if let Some(pool) = &self.pool { + pool.validate()?; + anyhow::ensure!( + !self.mining.payout_script.is_empty(), + "--pool requires --mining-payout-address" + ); + } anyhow::ensure!( self.mempool_snapshot_max_bytes >= 1024 * 1024 && self.mempool_snapshot_max_bytes < u64::try_from(usize::MAX).unwrap_or(u64::MAX), @@ -508,6 +762,11 @@ pub fn resolve(layers: &[&UserConfig]) -> Result { } let selection = settings.network.unwrap_or(NetworkSelection::Mainnet); let mut config = NodeConfig::materialize(&settings, selection); + config.pool = resolve_pool(&settings)?; + anyhow::ensure!( + config.pool.is_none() || settings.mining.payout_address.is_some(), + "--pool requires --mining-payout-address" + ); if let Some(address) = settings.mining.payout_address { config.mining.payout_script = decode_payout_script(config.network, &address)?; } diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index f45ab56bb2..2dd03f1c5a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -27,6 +27,9 @@ mod lifecycle; pub mod metrics; /// Node-owned mining candidate lifecycle coordinator. pub mod mining; +/// Native Stratum pool service and external-signer accounting boundary. +#[cfg(feature = "pool")] +mod pool; /// Node-owned effects around authoritative chainstate reorgs. pub mod reorg; /// Top-level node runner. diff --git a/crates/node/src/lifecycle.rs b/crates/node/src/lifecycle.rs index 6612adda4e..79f5722276 100644 --- a/crates/node/src/lifecycle.rs +++ b/crates/node/src/lifecycle.rs @@ -165,10 +165,25 @@ struct RpcBindings { /// Binds every RPC/HTTP listener before spawning a worker. Startup records each /// worker immediately after spawning, so a later failure cannot detach it. +#[cfg(test)] fn bind_rpc( state: &NodeState, mining_control: &Arc, block_body_source: Arc, +) -> Result { + bind_rpc_with_pool( + state, + mining_control, + block_body_source, + bitcoin_rs_rpc::context::PoolHandles::default(), + ) +} + +fn bind_rpc_with_pool( + state: &NodeState, + mining_control: &Arc, + block_body_source: Arc, + pool: bitcoin_rs_rpc::context::PoolHandles, ) -> Result { let config = state.config(); let rpc_auth = Arc::new(config.rpc.auth.to_rpc_auth(&config.data_dir)?); @@ -212,6 +227,7 @@ fn bind_rpc( mining: MiningHandles { mining_control: Some(Arc::clone(mining_control)), }, + pool, zmq_publisher: state.zmq_publisher(), debug_log_path: Some(state.data_dir().join("debug.log")), }) @@ -310,6 +326,8 @@ pub(crate) enum TeardownMode { /// shutdown followed by Drop safe, and prevents repeated lifecycle work. #[derive(Default)] pub(crate) struct NodeServices { + #[cfg(feature = "pool")] + pool: Option, event_loop: Option>, event_loop_signal: Option>, /// Stops and joins its listener in Drop. @@ -350,6 +368,12 @@ impl NodeServices { &*state }); shutdown::mark_shutdown_stage(); + #[cfg(feature = "pool")] + if let Some(mut pool) = self.pool.take() { + if let Err(error) = pool.shutdown(std::time::Instant::now() + DRAIN_DEADLINE) { + set_first_error(&mut first_error, error); + } + } if let Some(state) = state { state.request_shutdown(); state.p2p().shutdown(); @@ -711,7 +735,7 @@ pub(crate) fn start_node( Arc::clone(&chainstate), state.stable_read(), state.chain_followers(), - state.config().mining.payout_script.clone(), + pool_reward_script(state.config()), )); let sequence_wake: Arc = coordinator.clone(); let mining_control: Arc = coordinator; @@ -800,7 +824,20 @@ pub(crate) fn start_node( // state. crate::chain_effects::bootstrap_genesis(&chainstate, &state.chain_followers()) .map_err(|error| anyhow::Error::new(error).context("failed to bootstrap genesis"))?; - let RpcBindings { context, listeners } = bind_rpc(state, &mining_control, block_body_source)?; + #[cfg(feature = "pool")] + { + guard.services.pool = + crate::pool::PoolService::prepare(state, Arc::clone(&mining_control))?; + } + #[cfg(feature = "pool")] + let pool_handles = guard.services.pool.as_ref().map_or_else( + bitcoin_rs_rpc::context::PoolHandles::default, + crate::pool::PoolService::handles, + ); + #[cfg(not(feature = "pool"))] + let pool_handles = bitcoin_rs_rpc::context::PoolHandles::default(); + let RpcBindings { context, listeners } = + bind_rpc_with_pool(state, &mining_control, block_body_source, pool_handles)?; for (rpc_server, surface) in listeners { let rpc_local_addr = rpc_server.local_addr()?; tracing::info!(addr = %rpc_local_addr, ?surface, "rpc listener bound"); @@ -816,6 +853,10 @@ pub(crate) fn start_node( })?; guard.services.rpc_threads.push(worker); } + #[cfg(feature = "pool")] + if let Some(pool) = guard.services.pool.as_mut() { + pool.start()?; + } let peer_ready: Arc = Arc::new(move |source: bitcoin_rs_p2p::PeerSource| { peer_ready_sync.on_peer_ready(source); @@ -842,6 +883,13 @@ pub(crate) fn start_node( Ok(guard.finish(context)) } +fn pool_reward_script(config: &NodeConfig) -> Vec { + #[cfg(feature = "pool")] + if let Some(pool) = config.pool.as_ref() { + return pool.reward_script.clone(); + } + config.mining.payout_script.clone() +} /// Threads for the process-wide rayon pool. /// /// This pool runs coarse hashing and shard-commit jobs. Script verification diff --git a/crates/node/src/options.rs b/crates/node/src/options.rs index 643ce17cdb..a2ad6221db 100644 --- a/crates/node/src/options.rs +++ b/crates/node/src/options.rs @@ -769,6 +769,111 @@ macro_rules! option_rows { toml native("mining_payout_address") } } + /// Native pool overrides. Listener addresses, data and difficulty are explicit. + group pool: PoolOverrides { + /// Enable the noncustodial native pool service with external payout signing. + enabled as pool: Option { + cli[#[arg(long = "pool", num_args = 0..=1, default_missing_value = "true")]] + env["BITCOIN_RS_POOL", parse_bool] + toml native("pool") + } + /// Dedicated pool ledger directory. + datadir as pool_datadir: Option { + cli[#[arg(long = "pool-datadir")]] + env["BITCOIN_RS_POOL_DATADIR", parse_path] + toml native("pool_datadir") + } + /// Explicit SV1 listener address. + v1_bind as pool_v1_bind: Option { + cli[#[arg(long = "pool-v1-bind")]] + env["BITCOIN_RS_POOL_V1_BIND", str::parse] + toml native("pool_v1_bind") + } + /// Explicit SV2 listener address. + sv2_bind as pool_sv2_bind: Option { + cli[#[arg(long = "pool-sv2-bind")]] + env["BITCOIN_RS_POOL_SV2_BIND", str::parse] + toml native("pool_sv2_bind") + } + /// Authority key file; the service validates permissions and contents. + sv2_authority_key as pool_sv2_authority_key: Option { + cli[#[arg(long = "pool-sv2-authority-key")]] + env["BITCOIN_RS_POOL_SV2_AUTHORITY_KEY", parse_path] + toml native("pool_sv2_authority_key") + } + /// Explicitly permit non-loopback mining listeners. + allow_non_loopback as pool_allow_non_loopback: Option { + cli[#[arg(long = "pool-allow-non-loopback", num_args = 0..=1, default_missing_value = "true")]] + env["BITCOIN_RS_POOL_ALLOW_NON_LOOPBACK", parse_bool] + toml native("pool_allow_non_loopback") + } + /// Total pool connection limit. + max_connections as pool_max_connections: Option { + cli[#[arg(long = "pool-max-connections")]] + env["BITCOIN_RS_POOL_MAX_CONNECTIONS", str::parse] + toml native("pool_max_connections") + } + /// Per-IP pool connection limit. + max_connections_per_ip as pool_max_connections_per_ip: Option { + cli[#[arg(long = "pool-max-connections-per-ip")]] + env["BITCOIN_RS_POOL_MAX_CONNECTIONS_PER_IP", str::parse] + toml native("pool_max_connections_per_ip") + } + /// Fixed share difficulty as an exact plain decimal. + share_difficulty as pool_share_difficulty: Option { + cli[#[arg(long = "pool-share-difficulty")]] + env["BITCOIN_RS_POOL_SHARE_DIFFICULTY", parse_text] + toml native("pool_share_difficulty") + } + /// Immutable PPLNS window in exact work units. + window_work as pool_window_work: Option { + cli[#[arg(long = "pool-window-work")]] + env["BITCOIN_RS_POOL_WINDOW_WORK", str::parse] + toml native("pool_window_work") + } + /// Immutable pool fee in basis points (0..=10000). + fee_bps as pool_fee_bps: Option { + cli[#[arg(long = "pool-fee-bps")]] + env["BITCOIN_RS_POOL_FEE_BPS", str::parse] + toml native("pool_fee_bps") + } + /// Hex scriptPubKey paying the externally controlled pool reward. + reward_script as pool_reward_script: Option { + cli[#[arg(long = "pool-reward-script")]] + env["BITCOIN_RS_POOL_REWARD_SCRIPT", parse_text] + toml native("pool_reward_script") + } + /// Coinbase maturity, at least the consensus minimum of 100. + maturity as pool_maturity: Option { + cli[#[arg(long = "pool-maturity")]] + env["BITCOIN_RS_POOL_MATURITY", str::parse] + toml native("pool_maturity") + } + /// Required payout transaction confirmation depth. + payout_confirmations as pool_payout_confirmations: Option { + cli[#[arg(long = "pool-payout-confirmations")]] + env["BITCOIN_RS_POOL_PAYOUT_CONFIRMATIONS", str::parse] + toml native("pool_payout_confirmations") + } + /// Reorg monitoring depth, covering all confirmation depths. + reorg_watch_depth as pool_reorg_watch_depth: Option { + cli[#[arg(long = "pool-reorg-watch-depth")]] + env["BITCOIN_RS_POOL_REORG_WATCH_DEPTH", str::parse] + toml native("pool_reorg_watch_depth") + } + /// Minimum external payout amount in satoshis. + min_payout_sat as pool_min_payout_sat: Option { + cli[#[arg(long = "pool-min-payout-sat")]] + env["BITCOIN_RS_POOL_MIN_PAYOUT_SAT", str::parse] + toml native("pool_min_payout_sat") + } + /// Maximum external payout outputs (1..=2500). + max_payout_outputs as pool_max_payout_outputs: Option { + cli[#[arg(long = "pool-max-payout-outputs")]] + env["BITCOIN_RS_POOL_MAX_PAYOUT_OUTPUTS", str::parse] + toml native("pool_max_payout_outputs") + } + } /// User-supplied chainstate journal overrides. group chainstate_journal: ChainstateJournalOverrides table("chainstate_journal") diff --git a/crates/node/src/pool.rs b/crates/node/src/pool.rs new file mode 100644 index 0000000000..54cb4c6ac9 --- /dev/null +++ b/crates/node/src/pool.rs @@ -0,0 +1,285 @@ +//! Node-owned native pool lifecycle. No wallet or signing capability enters this module. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result}; +use bitcoin_rs_mining::{CoinbaseBudget, MiningControl}; +use bitcoin_rs_storage::KvStore; +use bitcoin_rs_stratum::{ + ConnectionLimiter, DifficultyPolicy, ExtranonceLayout, JobBookConfig, PoolCore, Server, + ServerConfig, ServerDeps, +}; + +#[cfg(feature = "pool-sv2")] +mod authority; +mod ledger; +#[cfg(feature = "pool-sv2")] +mod sv2; +#[cfg(test)] +mod tests; + +/// Constructed before RPC; listeners are started only after RPC binds successfully. +pub(crate) struct PoolService { + config: crate::config::PoolConfig, + host: Arc, + core: Arc, + #[cfg(feature = "pool-sv2")] + control: Arc, + chain: Arc, + cancel: Arc, + worker: Option>, + v1: Option, + #[cfg(feature = "pool-sv2")] + v2: Option, +} + +struct LedgerComposer { + config: pool_ledger::PoolConfig, +} +impl crate::storage_backend::StoreConsumer for LedgerComposer { + type Output = (pool_ledger::Ledger, u32); + type Error = anyhow::Error; + fn consume(self, store: Arc) -> Result { + let store: Arc = store; + let ledger = match pool_ledger::Ledger::create(Arc::clone(&store), self.config.clone()) { + Ok(ledger) => ledger, + Err(pool_ledger::LedgerError::PathExists) => { + pool_ledger::Ledger::open(Arc::clone(&store), self.config)? + } + Err(error) => return Err(error.into()), + }; + let epoch = ledger.allocate_run_epoch()?; + Ok((ledger, epoch)) + } +} + +impl PoolService { + pub(crate) fn prepare( + state: &crate::state::NodeState, + control: Arc, + ) -> Result> { + let Some(config) = state.config().pool.as_ref() else { + return Ok(None); + }; + std::fs::create_dir_all(&config.datadir).context("creating pool data directory")?; + let guard = crate::state::lock_pool_directory(&config.datadir)?; + let accounting = config.ledger_config(); + let (ledger, epoch) = crate::storage_backend::open_generic( + "pool", + state.config().storage.backend, + &config.datadir.join("ledger"), + None, + &guard, + LedgerComposer { config: accounting }, + )?; + let host = Arc::new(ledger::LedgerHost::new( + Arc::new(ledger), + epoch, + pool_ledger::PayoutPolicy { + min_payout_sat: config.min_payout_sat, + max_outputs: usize::try_from(config.max_payout_outputs)?, + }, + )); + let sessions = usize::try_from(config.max_connections)? + .checked_mul(2) + .context("pool session bound overflow")?; + let core = Arc::new( + PoolCore::new( + JobBookConfig { + budget: CoinbaseBudget { + additional_size: 0, + additional_sigops: 0, + }, + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/btcrs/".to_vec(), + version_mask: bitcoin_rs_stratum::BIP320_VERSION_MASK, + ntime_forward_secs: bitcoin_rs_stratum::MAX_NTIME_FORWARD_SECS, + max_jobs: 64, + max_sessions: sessions, + max_shares_per_job: 65_536, + }, + Arc::clone(&control), + host.clone(), + )? + .with_ledger_retry(1, Duration::ZERO) + .with_outbox(1024, 8 * 1024 * 1024), + ); + let chain = Arc::new(ledger::NodeChainView::new( + state.chainstate(), + state.stable_read(), + state.mempool_reader(), + state.derived_index_query(), + state.script_index_query(), + )?); + Ok(Some(Self { + config: config.clone(), + host, + core, + #[cfg(feature = "pool-sv2")] + control, + chain, + cancel: Arc::new(AtomicBool::new(false)), + worker: None, + v1: None, + #[cfg(feature = "pool-sv2")] + v2: None, + })) + } + + pub(crate) fn handles(&self) -> bitcoin_rs_rpc::context::PoolHandles { + bitcoin_rs_rpc::context::PoolHandles { + operator: Some(self.host.clone()), + } + } + + pub(crate) fn start(&mut self) -> Result<()> { + // Never acknowledge work before there is a durably opened ledger and a template. + self.core + .refresh(None, Vec::new()) + .context("initial pool template")?; + let limiter = Arc::new(ConnectionLimiter::new( + usize::try_from(self.config.max_connections)?, + usize::try_from(self.config.max_connections_per_ip)?, + )?); + if let Some(bind) = self.config.v1_bind { + let text = &self.config.share_difficulty; + let mut config = ServerConfig::new( + DifficultyPolicy::new(text, text, text) + .map_err(|error| anyhow::anyhow!("invalid pool difficulty: {error:?}"))?, + ); + config.max_connections = usize::try_from(self.config.max_connections)?; + config.max_connections_per_ip = usize::try_from(self.config.max_connections_per_ip)?; + self.v1 = Some(Server::bind_with_limiter( + bind, + config, + ServerDeps { + pool: Arc::clone(&self.core), + accounts: self.host.clone(), + observer: None, + }, + Arc::clone(&limiter), + )?); + } + #[cfg(feature = "pool-sv2")] + if self.config.sv2_bind.is_some() { + self.v2 = Some(sv2::start( + &self.config, + Arc::clone(&self.core), + Arc::clone(&self.host), + Arc::clone(&self.control), + Arc::clone(&self.cancel), + Arc::clone(&limiter), + )?); + } + let core = Arc::clone(&self.core); + let host = Arc::clone(&self.host); + let chain = Arc::clone(&self.chain); + let cancel = Arc::clone(&self.cancel); + self.worker = Some( + std::thread::Builder::new() + .name("pool-reconcile".into()) + .spawn(move || { + while !cancel.load(Ordering::Acquire) { + // No long-poll: shutdown never waits for a future mining generation. + if let Err(error) = core.refresh(None, Vec::new()) { + core.invalidate_active(); + tracing::warn!(%error, "pool template refresh unavailable"); + } + if let Err(error) = host.reconcile(&chain) { + tracing::error!(%error, "pool chain reconciliation failed"); + } + core.drain_outbox(); + std::thread::park_timeout(Duration::from_millis(250)); + } + core.drain_outbox(); + while matches!(host.drain_one_block_record(), Ok(true)) {} + })?, + ); + Ok(()) + } + + pub(crate) fn shutdown(&mut self, deadline: Instant) -> Result<()> { + self.cancel.store(true, Ordering::Release); + let mut failure = None; + if let Some(server) = self.v1.as_mut() { + match server.shutdown_until(deadline) { + Ok(report) => { + if report.sessions_panicked != 0 { + failure = Some(anyhow::anyhow!("pool SV1 session panicked")); + } + self.v1.take(); + } + Err(error) => failure = Some(error.into()), + } + } + #[cfg(feature = "pool-sv2")] + if let Some(server) = self.v2.as_mut() { + match server.stop_join(deadline) { + Ok(()) => { + self.v2.take(); + } + Err(error) => failure = Some(error), + } + } + if let Some(worker) = self.worker.as_ref() { + worker.thread().unpark(); + while !worker.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + if !worker.is_finished() { + failure = Some(anyhow::anyhow!("pool shutdown deadline exceeded")); + } else if let Some(worker) = self.worker.take() { + if worker.join().is_err() { + failure = Some(anyhow::anyhow!("pool reconciliation worker panicked")); + } + } + } + if Instant::now() < deadline { + self.core.drain_outbox(); + } + while Instant::now() < deadline { + match self.host.drain_one_block_record() { + Ok(true) => {} + Ok(false) => break, + Err(error) => { + failure = Some(error.into()); + break; + } + } + } + if self.host.pending_block_records() != 0 { + failure = Some(anyhow::anyhow!( + "pool shutdown left unrecorded found blocks" + )); + } + if self.core.pending_ledger_writes() != 0 { + failure = Some(anyhow::anyhow!( + "pool shutdown left unrecorded SV1 accounting work" + )); + } + if let Some(error) = failure { + return Err(error); + } + Ok(()) + } +} + +impl Drop for PoolService { + fn drop(&mut self) { + if let Err(error) = self.shutdown(Instant::now() + Duration::from_secs(10)) { + tracing::error!(%error, "pool shutdown failed"); + } + // A failed deadline is not permission to detach a durable writer. + if let Some(worker) = self.worker.take() { + worker.thread().unpark(); + if worker.join().is_err() { + tracing::error!("pool reconciliation worker panicked during final join"); + } + } + } +} diff --git a/crates/node/src/pool/authority.rs b/crates/node/src/pool/authority.rs new file mode 100644 index 0000000000..7f6c273ca4 --- /dev/null +++ b/crates/node/src/pool/authority.rs @@ -0,0 +1,105 @@ +use std::io::{self, Read as _}; +use std::path::Path; + +use bitcoin_rs_stratum_v2::AuthorityKeypair; +use thiserror::Error; +use zeroize::Zeroizing; + +const KEY_BYTES: usize = 32; +const KEY_HEX_LEN: usize = KEY_BYTES * 2; +const MAX_KEY_FILE_BYTES: usize = 256; +const MAX_KEY_FILE_READ: u64 = 257; + +/// Failure to load the SV2 Noise authority key. +/// +/// Parse errors deliberately carry no input text so key material cannot reach +/// logs through either `Display` or `Debug`. +#[derive(Debug, Error)] +pub(crate) enum AuthorityKeyError { + #[error("could not open or read the SV2 authority key: {0}")] + Io(#[from] io::Error), + #[error( + "the SV2 authority key must contain a 32-byte hex public key followed by a 32-byte hex secret key" + )] + InvalidFormat, + #[cfg(unix)] + #[error("the SV2 authority key must be a regular file with mode 0600")] + InsecureFile, + #[cfg(not(unix))] + #[error("the SV2 authority key must be a regular file")] + InsecureFile, +} + +pub(crate) fn load(path: &Path) -> Result { + let file = open_private_file(path)?; + let mut contents = Zeroizing::new(Vec::with_capacity(MAX_KEY_FILE_BYTES + 1)); + file.take(MAX_KEY_FILE_READ).read_to_end(&mut contents)?; + if contents.len() > MAX_KEY_FILE_BYTES { + return Err(AuthorityKeyError::InvalidFormat); + } + + let text = std::str::from_utf8(&contents).map_err(|_| AuthorityKeyError::InvalidFormat)?; + let mut fields = text.split_ascii_whitespace(); + let public_text = fields.next().ok_or(AuthorityKeyError::InvalidFormat)?; + let secret_text = fields.next().ok_or(AuthorityKeyError::InvalidFormat)?; + if fields.next().is_some() { + return Err(AuthorityKeyError::InvalidFormat); + } + + let mut public = [0; KEY_BYTES]; + decode_key_into(public_text, &mut public)?; + let mut secret = Zeroizing::new([0; KEY_BYTES]); + decode_key_into(secret_text, &mut secret)?; + Ok(AuthorityKeypair::from_bytes(public, secret)) +} + +fn decode_key_into(text: &str, key: &mut [u8; KEY_BYTES]) -> Result<(), AuthorityKeyError> { + if text.len() != KEY_HEX_LEN { + return Err(AuthorityKeyError::InvalidFormat); + } + let digits = text.as_bytes(); + for (index, destination) in key.iter_mut().enumerate() { + let high = hex_digit(digits[index * 2]).ok_or(AuthorityKeyError::InvalidFormat)?; + let low = hex_digit(digits[index * 2 + 1]).ok_or(AuthorityKeyError::InvalidFormat)?; + *destination = (high << 4) | low; + } + Ok(()) +} + +const fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(unix)] +fn open_private_file(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt as _; + + use rustix::fs::{Mode, OFlags}; + + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(io::Error::from)?; + let file = std::fs::File::from(descriptor); + let metadata = file.metadata()?; + if !metadata.file_type().is_file() || metadata.permissions().mode() & 0o7777 != 0o600 { + return Err(AuthorityKeyError::InsecureFile); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_private_file(_path: &Path) -> Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure SV2 authority-key loading is unavailable on this platform", + ) + .into()) +} diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs new file mode 100644 index 0000000000..c9d8cb7862 --- /dev/null +++ b/crates/node/src/pool/ledger.rs @@ -0,0 +1,653 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use bitcoin_rs_chainstate::Chainstate; +use bitcoin_rs_index::{DerivedIndexQuery, ScriptIndexQuery}; +use bitcoin_rs_mempool::MempoolReader; +use bitcoin_rs_primitives::encode::deserialize; +use bitcoin_rs_primitives::{Block, Hash256, OutPoint as NodeOutPoint, Txid}; +use bitcoin_rs_storage::block_body::BlockBodyStore; +use bitcoin_rs_stratum::{ + AcceptedShare, AccountResolver, AuthDenied, BlockReceipt, FoundBlock, LedgerUnavailable, + Password, RecordOutcome, ShareLedger, WorkerIdentity, +}; +use pool_ledger::{ + BlockFound as LedgerBlockFound, BlockLocation, ChainView, ChainViewError, ConfirmedSpend, + Ledger, MinerId, ObservedCoinbase, ShareCredit, ShareReceipt, TxOut, TxStatus, + work_from_le_bytes, +}; + +/// Node-backed observation of the fully applied chain. +/// +/// Header-chain state is deliberately not used: headers may lead block validation. +pub(crate) struct NodeChainView { + chainstate: Arc, + bodies: Arc, + stable: bitcoin_rs_chain::StableRead, + mempool: MempoolReader, + transactions: Option>, + scripts: Option>, +} + +impl NodeChainView { + pub(crate) fn new( + chainstate: Arc, + stable: bitcoin_rs_chain::StableRead, + mempool: MempoolReader, + transactions: Option>, + scripts: Option>, + ) -> Result { + let bodies = chainstate + .block_body_store_handle() + .ok_or_else(|| ChainViewError("running node has no block body store".to_owned()))?; + Ok(Self { + chainstate, + bodies, + stable, + mempool, + transactions, + scripts, + }) + } + + fn with_observation(&self, observe: impl FnOnce() -> T) -> T { + let _stable = self.stable.lock(); + observe() + } + + fn load_block(&self, height: u32, hash: Hash256) -> Result { + let bytes = self + .bodies + .load_block_body(height, hash) + .map_err(|error| ChainViewError(error.to_string()))? + .ok_or_else(|| ChainViewError("canonical block body is unavailable".to_owned()))?; + let block = deserialize::(&bytes) + .map_err(|error| ChainViewError(format!("stored block body is corrupt: {error}")))?; + if Hash256::from(block.block_hash()) != hash { + return Err(ChainViewError( + "stored block body has the wrong hash".to_owned(), + )); + } + Ok(block) + } +} + +struct RawChainView<'a>(&'a NodeChainView); + +impl core::ops::Deref for RawChainView<'_> { + type Target = NodeChainView; + + fn deref(&self) -> &Self::Target { + self.0 + } +} + +impl ChainView for RawChainView<'_> { + fn tip_height(&self) -> Result { + self.chainstate + .applied_tip_snapshot() + .map(|tip| tip.height) + .ok_or_else(|| ChainViewError("applied chain tip is unavailable".to_owned())) + } + + fn block_location( + &self, + block_hash: &[u8; 32], + _claimed_height: u32, + ) -> Result { + let hash = Hash256::from_le_bytes(block_hash); + let Some(tip) = self.chainstate.applied_tip_snapshot() else { + return Ok(BlockLocation::Unknown); + }; + let tree = self.chainstate.block_tree_reader(); + let tree = tree.read(); + let Some(candidate) = tree.node_by_hash(hash) else { + return Ok(BlockLocation::Unknown); + }; + let canonical = tree + .node_at_height_from(tip.tip_id, candidate.height) + .and_then(|id| tree.node(id).ok()) + .is_some_and(|node| node.hash == hash); + if canonical { + Ok(BlockLocation::Canonical { + height: candidate.height, + }) + } else { + Ok(BlockLocation::Side) + } + } + + fn coinbase_of( + &self, + block_hash: &[u8; 32], + ) -> Result, ChainViewError> { + let hash = Hash256::from_le_bytes(block_hash); + let height = { + let Some(tip) = self.chainstate.applied_tip_snapshot() else { + return Ok(None); + }; + let tree = self.chainstate.block_tree_reader(); + let tree = tree.read(); + let Some(node) = tree.node_by_hash(hash) else { + return Ok(None); + }; + let height = node.height; + let canonical = tree + .node_at_height_from(tip.tip_id, height) + .and_then(|id| tree.node(id).ok()) + .is_some_and(|active| active.hash == hash); + if !canonical { + return Ok(None); + } + height + }; + let Some(bytes) = self + .bodies + .load_block_body(height, hash) + .map_err(|error| ChainViewError(error.to_string()))? + else { + return Ok(None); + }; + let block = deserialize::(&bytes) + .map_err(|error| ChainViewError(format!("stored block body is corrupt: {error}")))?; + if Hash256::from(block.block_hash()) != hash { + return Err(ChainViewError( + "stored block body has the wrong hash".to_owned(), + )); + } + let coinbase = block + .txs + .first() + .filter(|transaction| transaction.is_coinbase()) + .ok_or_else(|| ChainViewError("stored block has no valid coinbase".to_owned()))?; + Ok(Some(ObservedCoinbase { + txid: Hash256::from(coinbase.txid()).to_le_bytes(), + outputs: coinbase + .outputs + .iter() + .map(|output| TxOut { + value_sat: output.value.to_sat(), + script_pubkey: output.script_pubkey.as_bytes().to_vec(), + }) + .collect(), + })) + } + + fn tx_status(&self, txid: &[u8; 32]) -> Result { + let txid = Txid::from(Hash256::from_le_bytes(txid)); + let Some(transactions) = self.transactions.as_ref() else { + return if self.mempool.read().contains_txid(&txid) { + Ok(TxStatus::InMempool) + } else { + Err(ChainViewError( + "payout transaction observation requires txindex".to_owned(), + )) + }; + }; + if let Some(height) = transactions + .transaction_height(&txid) + .map_err(|error| ChainViewError(error.to_string()))? + { + let Some(tip) = self.chainstate.applied_tip_snapshot() else { + return Err(ChainViewError( + "applied chain tip is unavailable".to_owned(), + )); + }; + let tree = self.chainstate.block_tree_reader(); + let tree = tree.read(); + let block_hash = tree + .node_at_height_from(tip.tip_id, height) + .and_then(|id| tree.node(id).ok()) + .map(|node| node.hash) + .ok_or_else(|| { + ChainViewError( + "transaction index height is not on the applied chain".to_owned(), + ) + })?; + drop(tree); + let block = self.load_block(height, block_hash)?; + if !block + .txs + .iter() + .any(|transaction| transaction.txid() == txid) + { + return Err(ChainViewError( + "transaction index entry is absent from the canonical block".to_owned(), + )); + } + return Ok(TxStatus::Confirmed { + block_hash: block_hash.to_le_bytes(), + height, + }); + } + if self.mempool.read().contains_txid(&txid) { + Ok(TxStatus::InMempool) + } else { + Ok(TxStatus::Unknown) + } + } + + fn confirmed_spender( + &self, + outpoint: &pool_ledger::OutPoint, + ) -> Result, ChainViewError> { + let node_outpoint = NodeOutPoint::new( + Txid::from(Hash256::from_le_bytes(&outpoint.txid)), + outpoint.vout, + ); + let scripts = self.scripts.as_ref().ok_or_else(|| { + ChainViewError("payout spend observation requires script index".to_owned()) + })?; + let Some(spend) = scripts + .spender(node_outpoint) + .map_err(|error| ChainViewError(error.to_string()))? + else { + return Ok(None); + }; + let Some(tip) = self.chainstate.applied_tip_snapshot() else { + return Err(ChainViewError( + "applied chain tip is unavailable".to_owned(), + )); + }; + let tree = self.chainstate.block_tree_reader(); + let tree = tree.read(); + let block_hash = tree + .node_at_height_from(tip.tip_id, spend.height) + .and_then(|id| tree.node(id).ok()) + .map(|node| node.hash) + .ok_or_else(|| { + ChainViewError("spend index height is not on the applied chain".to_owned()) + })?; + drop(tree); + let block = self.load_block(spend.height, block_hash)?; + let spends_exact_outpoint = block.txs.iter().any(|transaction| { + transaction.txid() == spend.txid + && transaction + .inputs + .iter() + .any(|input| input.previous_output == node_outpoint) + }); + if !spends_exact_outpoint { + return Err(ChainViewError( + "spend index entry is absent from the canonical block".to_owned(), + )); + } + Ok(Some(ConfirmedSpend { + spender_txid: Hash256::from(spend.txid).to_le_bytes(), + block_hash: block_hash.to_le_bytes(), + height: spend.height, + })) + } +} + +const MAX_PENDING_BLOCK_RECORDS: usize = 8; +const MAX_BLOCK_RECORD_BYTES: usize = 4_000_000; +const MAX_PENDING_BLOCK_BYTES: usize = MAX_PENDING_BLOCK_RECORDS * MAX_BLOCK_RECORD_BYTES; + +struct PendingBlockRecord { + hash: bitcoin_rs_primitives::BlockHash, + block: LedgerBlockFound, + bytes: usize, +} + +#[derive(Default)] +struct PendingBlockRecords { + reserved: usize, + bytes: usize, + queue: VecDeque, +} + +/// Result of consuming a pre-submission durable-record reservation. +pub(crate) enum BlockRecordOutcome { + /// The found-block round is durably visible and protocol success may be sent. + Durable, + /// The host retained the block for retry; the socket must not send success. + Queued, + /// The block cannot be represented by the ledger; close without success. + Unrecordable, +} + +/// Capacity reserved before node submission so a failed ledger write survives socket exit. +pub(crate) struct BlockRecordPermit { + host: Arc, + bytes: usize, + armed: bool, +} + +impl BlockRecordPermit { + pub(crate) fn record_or_enqueue( + mut self, + account: MinerId, + block: &FoundBlock, + submission: &Result, + ) -> BlockRecordOutcome { + let host = Arc::clone(&self.host); + self.armed = false; + let recorded_directly = host + .record_submitted_block(account, block, submission) + .is_ok(); + if recorded_directly || matches!(host.found_block_recorded(block.share.hash), Ok(true)) { + host.release_block_reservation(self.bytes); + return BlockRecordOutcome::Durable; + } + let hash = block.share.hash; + let Some(ledger_block) = ledger_block(host.run_epoch, account, block).ok() else { + host.release_block_reservation(self.bytes); + return BlockRecordOutcome::Unrecordable; + }; + let mut pending = host + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(pending.reserved <= MAX_PENDING_BLOCK_RECORDS); + pending.queue.push_back(PendingBlockRecord { + hash, + block: ledger_block, + bytes: self.bytes, + }); + BlockRecordOutcome::Queued + } +} + +impl Drop for BlockRecordPermit { + fn drop(&mut self) { + if self.armed { + self.host.release_block_reservation(self.bytes); + } + } +} + +impl ChainView for NodeChainView { + fn tip_height(&self) -> Result { + self.with_observation(|| RawChainView(self).tip_height()) + } + + fn block_location( + &self, + block_hash: &[u8; 32], + claimed_height: u32, + ) -> Result { + self.with_observation(|| RawChainView(self).block_location(block_hash, claimed_height)) + } + + fn coinbase_of( + &self, + block_hash: &[u8; 32], + ) -> Result, ChainViewError> { + self.with_observation(|| RawChainView(self).coinbase_of(block_hash)) + } + + fn tx_status(&self, txid: &[u8; 32]) -> Result { + self.with_observation(|| RawChainView(self).tx_status(txid)) + } + + fn confirmed_spender( + &self, + outpoint: &pool_ledger::OutPoint, + ) -> Result, ChainViewError> { + self.with_observation(|| RawChainView(self).confirmed_spender(outpoint)) + } +} + +/// Shared durable accounting adapter for SV1, SV2, and operator RPC. +pub(crate) struct LedgerHost { + ledger: Arc, + run_epoch: u32, + payout_policy: pool_ledger::PayoutPolicy, + pending_blocks: Mutex, +} + +impl LedgerHost { + pub(crate) fn new( + ledger: Arc, + run_epoch: u32, + payout_policy: pool_ledger::PayoutPolicy, + ) -> Self { + Self { + ledger, + run_epoch, + payout_policy, + pending_blocks: Mutex::new(PendingBlockRecords::default()), + } + } + + /// Reserves one fixed-capacity found-record slot before protocol feed/validation. + /// + /// The permit must be dropped for non-block events. Failed writes retain only the + /// compact [`LedgerBlockFound`] projection, not block transactions or serialized bytes. + pub(crate) fn try_reserve_block_slot(self: &Arc) -> Option { + let block_bytes = MAX_BLOCK_RECORD_BYTES; + let mut pending = self + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if pending.reserved == MAX_PENDING_BLOCK_RECORDS + || block_bytes > MAX_PENDING_BLOCK_BYTES.saturating_sub(pending.bytes) + { + return None; + } + pending.reserved += 1; + pending.bytes += block_bytes; + Some(BlockRecordPermit { + host: Arc::clone(self), + bytes: block_bytes, + armed: true, + }) + } + + fn release_block_reservation(&self, bytes: usize) { + let mut pending = self + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.reserved = pending.reserved.saturating_sub(1); + pending.bytes = pending.bytes.saturating_sub(bytes); + } + + pub(crate) fn pending_block_records(&self) -> usize { + self.pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .queue + .len() + } + + pub(crate) fn drain_one_block_record(&self) -> Result { + let pending_record = { + let mut pending = self + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.queue.pop_front() + }; + let Some(record) = pending_record else { + return Ok(false); + }; + let result = match self.found_block_recorded(record.hash) { + Ok(true) => Ok(()), + Ok(false) => self.ledger.record_found_block(&record.block).map(|_| ()), + Err(error) => Err(error), + }; + let mut pending = self + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match result { + Ok(()) => { + pending.bytes = pending.bytes.saturating_sub(record.bytes); + pending.reserved = pending.reserved.saturating_sub(1); + Ok(true) + } + Err(error) => { + pending.queue.push_front(record); + Err(error) + } + } + } + + /// Resolves a decimal account id only when its payout script was explicitly registered. + pub(crate) fn account(&self, login: &str) -> Option { + let (account, worker) = login + .split_once('.') + .map_or((login, None), |(account, worker)| (account, Some(worker))); + if worker.is_some_and(|worker| worker.is_empty() || worker.contains('.')) { + return None; + } + let miner = MinerId(account.parse().ok()?); + self.ledger + .payout_script(miner) + .ok() + .flatten() + .map(|_| miner) + } + + pub(crate) fn reconcile(&self, chain: &NodeChainView) -> Result<(), pool_ledger::LedgerError> { + let queue_error = loop { + match self.drain_one_block_record() { + Ok(true) => {} + Ok(false) => break None, + Err(error) => break Some(error), + } + }; + chain.with_observation(|| { + let observation = RawChainView(chain); + self.ledger.reconcile_rounds(&observation)?; + self.ledger.reconcile_payouts(&observation) + })?; + match queue_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + pub(crate) fn record_accepted_share( + &self, + account: MinerId, + share: &AcceptedShare, + ) -> Result { + let credit = share_credit(self.run_epoch, account, share)?; + self.ledger + .record_share(&credit) + .map(|receipt| match receipt { + ShareReceipt::Credited { .. } => RecordOutcome::Recorded, + ShareReceipt::Duplicate { .. } => RecordOutcome::AlreadyRecorded, + }) + .map_err(|_| LedgerUnavailable) + } + + /// Records a found block after any submission result. + /// + /// Submission status is not accounting evidence: the round remains Pending until + /// authoritative chain reconciliation observes the exact block hash and coinbase. + pub(crate) fn record_submitted_block( + &self, + account: MinerId, + block: &FoundBlock, + _receipt: &Result, + ) -> Result<(), LedgerUnavailable> { + self.record_found_block(account, block) + } + + pub(crate) fn found_block_recorded( + &self, + hash: bitcoin_rs_primitives::BlockHash, + ) -> Result { + self.ledger + .round_for_block(&Hash256::from(hash).to_le_bytes()) + .map(|round| round.is_some()) + } + + pub(crate) fn record_found_block( + &self, + account: MinerId, + block: &FoundBlock, + ) -> Result<(), LedgerUnavailable> { + let found = ledger_block(self.run_epoch, account, block)?; + self.ledger + .record_found_block(&found) + .map(|_| ()) + .map_err(|_| LedgerUnavailable) + } +} + +fn ledger_block( + run_epoch: u32, + account: MinerId, + block: &FoundBlock, +) -> Result { + Ok(LedgerBlockFound { + share: share_credit(run_epoch, account, &block.share)?, + height: block.share.height, + coinbase_txid: Hash256::from(block.coinbase_txid).to_le_bytes(), + claimed_coinbase_value_sat: block.coinbase_value, + }) +} + +fn share_credit( + run_epoch: u32, + account: MinerId, + share: &AcceptedShare, +) -> Result { + let work = work_from_le_bytes(share.work.to_le_bytes()).map_err(|_| LedgerUnavailable)?; + Ok(ShareCredit { + header_hash: Hash256::from(share.hash).to_le_bytes(), + job: (u64::from(run_epoch) << 32) | u64::from(share.job.get()), + account, + extranonce: share.extranonce.clone(), + nonce: share.header.nonce, + work, + }) +} + +/// Public mining identity routing, not a withdrawal-authentication boundary. +/// +/// A login is a decimal [`MinerId`] optionally followed by `.rig`, and resolves only +/// after the account's payout script was explicitly registered through authenticated +/// management RPC. The SV1 password is intentionally ignored: miners cannot create or +/// mutate accounts, payout scripts, payout requests, or signed receipts through this +/// adapter. There is consequently no unreachable in-memory password registration API. +impl AccountResolver for LedgerHost { + fn resolve(&self, login: &str, _password: &Password) -> Result { + let (account, worker) = login + .split_once('.') + .map_or((login, "default"), |(account, worker)| (account, worker)); + if worker.is_empty() || self.account(login).is_none() { + return Err(AuthDenied); + } + Ok(WorkerIdentity { + account: account.to_owned(), + worker: worker.to_owned(), + }) + } +} + +impl ShareLedger for LedgerHost { + fn record_share( + &self, + worker: &WorkerIdentity, + share: &AcceptedShare, + ) -> Result { + let account = self.account(&worker.account).ok_or(LedgerUnavailable)?; + self.record_accepted_share(account, share) + } + + fn record_block( + &self, + worker: &WorkerIdentity, + block: &FoundBlock, + submission: &Result, + ) -> Result<(), LedgerUnavailable> { + let account = self.account(&worker.account).ok_or(LedgerUnavailable)?; + self.record_submitted_block(account, block, submission) + } +} + +impl bitcoin_rs_rpc::context::PoolOperator for LedgerHost { + fn ledger(&self) -> &Ledger { + &self.ledger + } + + fn payout_policy(&self) -> pool_ledger::PayoutPolicy { + self.payout_policy + } +} diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs new file mode 100644 index 0000000000..8d9b660ec7 --- /dev/null +++ b/crates/node/src/pool/sv2.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; +use std::io::{self, Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result, anyhow}; +use bitcoin_rs_mining::MiningControl; +use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore}; +use bitcoin_rs_stratum_v2::{ + AdmitError, AuthorityKeypair, Connection, ERROR_CODE_SHARE_NOT_RECORDED, Event, Limits, + MAX_FEED_LEN, ServerConfig, +}; +use pool_ledger::MinerId; + +use super::authority; +use super::ledger::{BlockRecordOutcome, BlockRecordPermit, LedgerHost}; +use crate::config::PoolConfig; + +const CERTIFICATE_VALIDITY: Duration = Duration::new(86_400, 0); +const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(100); +const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Owned SV2 listener and connection threads. +pub(crate) struct Sv2Service { + cancel: Arc, + listener: Option>, + connections: Arc>>>, +} + +impl Sv2Service { + /// Stops admission and asks every connection to drain and release its owner sessions. + pub(crate) fn stop(&self) { + self.cancel.store(true, Ordering::Release); + } + + /// Stops admission and joins every runtime thread before `deadline`. + pub(crate) fn stop_join(&mut self, deadline: Instant) -> Result<()> { + self.stop(); + wait_until_finished(self.listener.as_ref(), deadline, "SV2 listener")?; + if let Some(listener) = self.listener.take() { + listener + .join() + .map_err(|_| anyhow!("SV2 listener thread panicked"))?; + } + + loop { + let all_finished = self + .connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .all(JoinHandle::is_finished); + if all_finished { + break; + } + if Instant::now() >= deadline { + return Err(anyhow!("timed out joining SV2 connection threads")); + } + thread::sleep( + ACCEPT_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + for connection in self + .connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + { + connection + .join() + .map_err(|_| anyhow!("SV2 connection thread panicked"))?; + } + Ok(()) + } +} + +impl Drop for Sv2Service { + fn drop(&mut self) { + self.stop(); + if let Some(listener) = self.listener.take() { + let _ = listener.join(); + } + for connection in self + .connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + { + let _ = connection.join(); + } + } +} + +pub(crate) fn start( + config: &PoolConfig, + core: Arc, + ledger: Arc, + control: Arc, + cancel: Arc, + limiter: Arc, +) -> Result { + let bind = config + .sv2_bind + .ok_or_else(|| anyhow!("SV2 start requires a listener address"))?; + let key_path = config + .sv2_authority_key + .as_deref() + .ok_or_else(|| anyhow!("SV2 start requires an authority key"))?; + let authority = Arc::new(authority::load(key_path).context("loading SV2 authority key")?); + let listener = + TcpListener::bind(bind).with_context(|| format!("binding SV2 listener {bind}"))?; + listener + .set_nonblocking(true) + .context("setting SV2 listener nonblocking")?; + + let connections = Arc::new(Mutex::new(Vec::new())); + let thread_connections = Arc::clone(&connections); + let thread_cancel = Arc::clone(&cancel); + let initial_target = config.share_target; + + let listener_thread = thread::Builder::new() + .name("pool-sv2-listener".to_owned()) + .spawn(move || { + while !thread_cancel.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, peer)) => { + reap_finished(&thread_connections); + let Ok(permit) = limiter.try_admit(peer.ip()) else { + drop(stream); + continue; + }; + let core = Arc::clone(&core); + let ledger = Arc::clone(&ledger); + let control = Arc::clone(&control); + let authority = Arc::clone(&authority); + let cancel = Arc::clone(&thread_cancel); + let connection_ledger = Arc::clone(&ledger); + let admit_user = Arc::new(move |identity: &[u8]| { + let login = std::str::from_utf8(identity) + .map_err(|_| AdmitError::InvalidIdentity)?; + connection_ledger + .account(login) + .map(|_| ()) + .ok_or(AdmitError::UnknownUser) + }); + let mut server_config = ServerConfig::new(initial_target); + server_config.limits = Limits { + max_pending_acks: 1, + max_pending_events: 1, + ..Limits::DEFAULT + }; + server_config.admit_user = admit_user; + match thread::Builder::new() + .name(format!("pool-sv2-{}", peer.ip())) + .spawn(move || { + let _permit = permit; + if let Err(error) = serve_connection( + stream, + &authority, + server_config, + &core, + &ledger, + &*control, + &cancel, + ) { + tracing::debug!(peer = %peer, %error, "SV2 connection closed"); + } + }) + { + Ok(handle) => thread_connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(handle), + Err(error) => tracing::warn!(peer = %peer, %error, "could not spawn SV2 connection worker"), + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(ACCEPT_POLL_INTERVAL); + } + Err(error) => { + tracing::error!(%error, "SV2 listener failed"); + break; + } + } + } + }) + .context("spawning SV2 listener")?; + + Ok(Sv2Service { + cancel, + listener: Some(listener_thread), + connections, + }) +} + +fn serve_connection( + mut stream: TcpStream, + authority: &AuthorityKeypair, + config: ServerConfig, + core: &PoolCore, + ledger: &Arc, + control: &dyn MiningControl, + cancel: &AtomicBool, +) -> Result<()> { + stream.set_nonblocking(true)?; + stream.set_nodelay(true)?; + let mut connection = core + .with_job_book(|book| Connection::accept(authority, CERTIFICATE_VALIDITY, config, book)) + .context("creating authenticated SV2 connection")?; + let mut accounts = HashMap::new(); + let mut input = vec![0; MAX_FEED_LEN]; + let mut buffered_input = None; + let mut transmit = PendingTransmit::default(); + let result = (|| -> Result<()> { + while !cancel.load(Ordering::Acquire) && !connection.is_closing() { + announce_current_job(&mut connection, core)?; + transmit.fill(&mut connection); + transmit.flush(&mut stream)?; + + if buffered_input.is_none() { + let read_len = connection.read_hint().clamp(1, MAX_FEED_LEN); + match stream.read(&mut input[..read_len]) { + Ok(0) => break, + Ok(length) => buffered_input = Some(length), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock + | io::ErrorKind::TimedOut + | io::ErrorKind::Interrupted + ) => + { + thread::sleep(SOCKET_POLL_INTERVAL); + continue; + } + Err(error) => return Err(error.into()), + } + } + + let Some(block_permit) = ledger.try_reserve_block_slot() else { + thread::sleep(SOCKET_POLL_INTERVAL); + continue; + }; + let mut block_permit = Some(block_permit); + let length = buffered_input + .take() + .ok_or_else(|| anyhow!("SV2 buffered input length is missing"))?; + core.with_job_book(|book| connection.feed(&input[..length], book))?; + handle_events( + &mut connection, + ledger, + control, + &mut accounts, + &mut block_permit, + )?; + transmit.fill(&mut connection); + transmit.flush(&mut stream)?; + } + Ok(()) + })(); + let _ = transmit.drain( + &mut connection, + &mut stream, + Instant::now() + SOCKET_POLL_INTERVAL, + ); + core.with_job_book(|book| connection.release(book)); + result +} + +fn announce_current_job(connection: &mut Connection, core: &PoolCore) -> Result<()> { + if let Some(mut installed) = core.current_installed() { + // `fresh` describes the owner's latest refresh, not whether this + // connection has received the job. Distribution itself is idempotent. + installed.fresh = true; + connection.announce(&installed)?; + } + Ok(()) +} + +fn handle_events( + connection: &mut Connection, + ledger: &LedgerHost, + control: &dyn MiningControl, + accounts: &mut HashMap, + block_permit: &mut Option, +) -> Result<()> { + while let Some(event) = connection.poll_event() { + match event { + Event::ChannelOpened { + channel_id, + user_identity, + .. + } => { + if let Ok(login) = std::str::from_utf8(&user_identity) + && let Some(account) = ledger.account(login) + { + accounts.insert(channel_id, account); + } + } + Event::ChannelClosed { channel_id, .. } => { + accounts.remove(&channel_id); + } + Event::ShareAccepted(event) => { + let recorded = accounts + .get(&event.channel_id) + .copied() + .is_some_and(|account| { + ledger + .record_accepted_share(account, &event.outcome) + .is_ok() + }); + if recorded { + connection.confirm_share(event.ticket)?; + } else { + connection.reject_share(event.ticket, ERROR_CODE_SHARE_NOT_RECORDED)?; + } + } + Event::BlockFound(pending) => { + let Some(account) = accounts.get(&pending.channel_id()).copied() else { + return Err(anyhow!("SV2 block channel has no admitted ledger account")); + }; + let permit = block_permit + .take() + .ok_or_else(|| anyhow!("SV2 block event has no host record reservation"))?; + let found = pending.found_block(); + let submission = pending.submit(control); + let receipt = submission + .as_ref() + .map(|submitted| submitted.receipt().clone()) + .map_err(Clone::clone); + match permit.record_or_enqueue(account, found, &receipt) { + BlockRecordOutcome::Durable => {} + BlockRecordOutcome::Queued => { + return Err(anyhow!( + "SV2 block record queued for durable reconciliation" + )); + } + BlockRecordOutcome::Unrecordable => { + return Err(anyhow!( + "SV2 block cannot be represented by the durable ledger" + )); + } + } + let submission = submission.context("SV2 block submission failed")?; + match submission.receipt().status() { + BlockStatus::Applied | BlockStatus::Undetermined => { + connection.confirm_block(&submission)?; + } + BlockStatus::Rejected => { + // A rejected node verdict remains a pending accounting + // round until chain reconciliation. Do not send a + // success acknowledgement for the rejected submission. + return Err(anyhow!("node rejected submitted SV2 block")); + } + } + } + Event::SetupRejected { code } => tracing::debug!(code, "SV2 setup rejected"), + Event::ChannelOpenRejected { code, .. } => { + tracing::debug!(code, "SV2 channel open rejected"); + } + Event::ShareRejected { code, .. } => tracing::debug!(code, "SV2 share rejected"), + Event::SetupCompleted { .. } => {} + } + } + // A feed can validate at most one block under the configured adapter + // limits. Release an unconsumed reservation after this feed/event drain. + drop(block_permit.take()); + Ok(()) +} + +fn reap_finished(connections: &Mutex>>) { + let mut connections = connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut index = 0; + while index < connections.len() { + if connections[index].is_finished() { + let handle = connections.swap_remove(index); + if handle.join().is_err() { + tracing::warn!("SV2 connection thread panicked"); + } + } else { + index += 1; + } + } +} + +#[derive(Default)] +struct PendingTransmit { + bytes: Vec, + offset: usize, +} + +impl PendingTransmit { + fn fill(&mut self, connection: &mut Connection) { + if self.offset == self.bytes.len() { + self.bytes = connection.take_transmit(); + self.offset = 0; + } + } + + fn drain( + &mut self, + connection: &mut Connection, + stream: &mut TcpStream, + deadline: Instant, + ) -> Result<()> { + loop { + self.fill(connection); + self.flush(stream)?; + self.fill(connection); + if self.bytes.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(anyhow!("timed out draining SV2 connection output")); + } + thread::sleep(ACCEPT_POLL_INTERVAL); + } + } + + fn flush(&mut self, stream: &mut TcpStream) -> Result<()> { + while self.offset < self.bytes.len() { + match stream.write(&self.bytes[self.offset..]) { + Ok(0) => { + return Err( + io::Error::new(io::ErrorKind::WriteZero, "SV2 socket closed").into(), + ); + } + Ok(written) => self.offset += written, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock + | io::ErrorKind::TimedOut + | io::ErrorKind::Interrupted + ) => + { + break; + } + Err(error) => return Err(error.into()), + } + } + if self.offset == self.bytes.len() { + self.bytes.clear(); + self.offset = 0; + } + Ok(()) + } +} + +fn wait_until_finished( + handle: Option<&JoinHandle<()>>, + deadline: Instant, + name: &str, +) -> Result<()> { + while handle.is_some_and(|handle| !handle.is_finished()) { + if Instant::now() >= deadline { + return Err(anyhow!("timed out joining {name}")); + } + thread::sleep(ACCEPT_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()))); + } + Ok(()) +} diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs new file mode 100644 index 0000000000..25c6c53776 --- /dev/null +++ b/crates/node/src/pool/tests.rs @@ -0,0 +1,388 @@ +#![cfg(any(feature = "redb", feature = "fjall"))] + +use std::{path::Path, sync::Arc}; + +use bitcoin_rs_mining::{BlockValidationResult, MiningControlError}; +use bitcoin_rs_primitives::{Block, BlockHash, Hash256, Header, Txid}; +use bitcoin_rs_rpc::context::PoolOperator; +use bitcoin_rs_storage::KvStore; +use bitcoin_rs_stratum::{ + AcceptedShare, AccountResolver, BlockReceipt, FoundBlock, JobId, Password, RecordOutcome, + SessionId, ShareClass, ShareLedger, ShareTarget, WorkerIdentity, +}; +use pool_ledger::{ + Balance, Ledger, MinerId, PayoutPolicy, PoolConfig, RoundState, ShareCredit, ShareReceipt, +}; + +use super::ledger::LedgerHost; + +type OpenStore = fn(&Path) -> Result, Box>; +type TestResult = Result<(), Box>; +const EPOCH: u32 = 17; + +fn config() -> PoolConfig { + PoolConfig::new(1_000, vec![0x51]) +} + +fn payout_policy() -> PayoutPolicy { + PayoutPolicy { + min_payout_sat: 1_000, + max_outputs: 10, + } +} + +fn registered_host( + open: OpenStore, + path: &Path, + epoch: u32, +) -> Result> { + let ledger = Arc::new(Ledger::create(open(path)?, config())?); + ledger.set_payout_script(MinerId(42), &[0x51])?; + Ok(LedgerHost::new(ledger, epoch, payout_policy())) +} + +fn worker() -> WorkerIdentity { + WorkerIdentity { + account: "42".to_owned(), + worker: "rig".to_owned(), + } +} + +fn share() -> Result> { + let header = Header { + version: 4, + nonce: 0x1234_5678, + time: 1_700_000_000, + ..Header::default() + }; + let target = ShareTarget::from_le_bytes([0xff; 32])?; + Ok(AcceptedShare { + session: SessionId::new(9), + job: JobId::new(0x0102_0304), + hash: header.compute_hash(), + header, + height: 101, + previous_block_hash: BlockHash::from(Hash256::from_le_bytes(&[3; 32])), + extranonce: vec![0, 1, 0x80, 0xff, 7], + target, + work: target.work(), + class: ShareClass::Share, + }) +} + +fn expected_credit(share: &AcceptedShare, epoch: u32) -> ShareCredit { + ShareCredit { + header_hash: Hash256::from(share.hash).to_le_bytes(), + job: (u64::from(epoch) << 32) | u64::from(share.job.get()), + account: MinerId(42), + extranonce: share.extranonce.clone(), + nonce: share.header.nonce, + work: 1, + } +} + +fn found_block() -> Result> { + let mut share = share()?; + share.class = ShareClass::Block; + Ok(FoundBlock { + block: Block { + header: share.header, + txs: Vec::new(), + }, + raw: Vec::new(), + coinbase_txid: Txid::from(Hash256::from_le_bytes(&[5; 32])), + coinbase_value: 5_000_000_000, + share, + }) +} + +fn registered_account_gating(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let host = registered_host(open, directory.path(), EPOCH)?; + let password = Password::new("not-an-account-registration-secret"); + for login in ["43", "43.rig", "address.rig", "", "42."] { + assert!(host.resolve(login, &password).is_err(), "{login:?}"); + } + assert_eq!( + host.resolve("42.rig", &password) + .unwrap_or_else(|_| panic!("registered login 42.rig refused")), + worker() + ); + assert_eq!( + host.resolve("42", &password) + .unwrap_or_else(|_| panic!("registered bare login refused")) + .worker, + "default" + ); + let unregistered = WorkerIdentity { + account: "43".to_owned(), + worker: "rig".to_owned(), + }; + assert!(host.record_share(&unregistered, &share()?).is_err()); + assert_eq!(host.ledger().share_count()?, 0); + host.ledger().set_payout_script(MinerId(43), &[0x51])?; + assert!(host.resolve("43.rig", &password).is_ok()); + Ok(()) +} + +fn exact_share_survives_reopen(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let share = share()?; + { + let host = registered_host(open, directory.path(), EPOCH)?; + assert_eq!( + host.record_share(&worker(), &share) + .unwrap_or_else(|_| panic!("first share record unavailable")), + RecordOutcome::Recorded + ); + assert_eq!( + host.record_share(&worker(), &share) + .unwrap_or_else(|_| panic!("replay share record unavailable")), + RecordOutcome::AlreadyRecorded + ); + } + let ledger = Ledger::open(open(directory.path())?, config())?; + let credit = expected_credit(&share, EPOCH); + assert_eq!(ledger.share_count()?, 1); + assert_eq!(ledger.current_window()?, vec![(MinerId(42), 1)]); + let (seq, work) = ledger + .share_by_hash(&credit.header_hash)? + .ok_or("recorded share missing after reopen")?; + assert_eq!(work, credit.work); + // Duplicate acceptance checks every persisted identity field, not merely the hash. + assert_eq!( + ledger.record_share(&credit)?, + ShareReceipt::Duplicate { seq } + ); + assert_eq!(ledger.payout_script(MinerId(42))?, Some(vec![0x51])); + Ok(()) +} + +fn restart_epoch_separates_reused_wire_job(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let first = share()?; + let first_epoch; + { + let ledger = Arc::new(Ledger::create(open(directory.path())?, config())?); + ledger.set_payout_script(MinerId(42), &[0x51])?; + first_epoch = ledger.allocate_run_epoch()?; + let host = LedgerHost::new(ledger, first_epoch, payout_policy()); + host.record_share(&worker(), &first) + .map_err(|_| "first-epoch share record unavailable")?; + } + let ledger = Arc::new(Ledger::open(open(directory.path())?, config())?); + let second_epoch = ledger.allocate_run_epoch()?; + assert!(second_epoch > first_epoch); + let host = LedgerHost::new(Arc::clone(&ledger), second_epoch, payout_policy()); + let mut second = first.clone(); + // The miner may reuse job/extranonce/nonce after restart; the new template changes its hash. + second.header.time += 1; + second.hash = second.header.compute_hash(); + assert_eq!( + host.record_share(&worker(), &second) + .unwrap_or_else(|_| panic!("second-epoch share record unavailable")), + RecordOutcome::Recorded + ); + assert_eq!(ledger.share_count()?, 2); + assert_eq!(ledger.current_window()?, vec![(MinerId(42), 2)]); + for (share, epoch) in [(&first, first_epoch), (&second, second_epoch)] { + assert!(matches!( + ledger.record_share(&expected_credit(share, epoch))?, + ShareReceipt::Duplicate { .. } + )); + } + Ok(()) +} + +fn failed_block_receipt_stays_pending(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let found = found_block()?; + { + let host = registered_host(open, directory.path(), EPOCH)?; + let receipt = Err(MiningControlError::Rejected("invalid candidate".into())); + host.record_block(&worker(), &found, &receipt) + .map_err(|_| "failed-submission block record unavailable")?; + assert_eq!(host.ledger().share_count()?, 1); + } + let ledger = Ledger::open(open(directory.path())?, config())?; + let hash = Hash256::from(found.share.hash).to_le_bytes(); + assert_eq!( + ledger + .round_for_block(&hash)? + .ok_or("failed submission round missing")? + .state, + RoundState::Pending + ); + assert!(ledger.share_by_hash(&hash)?.is_some()); + assert_eq!(ledger.balance(MinerId(42))?, Balance::default()); + Ok(()) +} + +fn rejected_block_receipt_stays_pending(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let found = found_block()?; + { + let host = registered_host(open, directory.path(), EPOCH)?; + let receipt = BlockReceipt { + hash: found.share.hash, + height: found.share.height, + previous_block_hash: found.share.previous_block_hash, + coinbase_txid: found.coinbase_txid, + coinbase_value: found.coinbase_value, + session: found.share.session, + job: found.share.job, + result: BlockValidationResult::DuplicateInvalid, + }; + host.record_block(&worker(), &found, &Ok(receipt)) + .map_err(|_| "rejected-receipt block record unavailable")?; + } + let ledger = Ledger::open(open(directory.path())?, config())?; + let credit = expected_credit(&found.share, EPOCH); + assert_eq!( + ledger + .round_for_block(&credit.header_hash)? + .ok_or("rejected receipt round missing")? + .state, + RoundState::Pending + ); + assert_eq!(ledger.balance(MinerId(42))?, Balance::default()); + assert_eq!(ledger.share_count()?, 1); + assert!(matches!( + ledger.record_share(&credit)?, + ShareReceipt::Duplicate { .. } + )); + Ok(()) +} + +fn block_record_reservations_are_bounded_and_released(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let host = Arc::new(registered_host(open, directory.path(), EPOCH)?); + let mut permits = Vec::new(); + for _ in 0..8 { + permits.push( + host.try_reserve_block_slot() + .ok_or("slot refused below capacity")?, + ); + } + assert!(host.try_reserve_block_slot().is_none()); + drop( + permits + .pop() + .ok_or("permit vector exhausted below capacity")?, + ); + permits.push( + host.try_reserve_block_slot() + .ok_or("released slot not re-reservable")?, + ); + assert!(host.try_reserve_block_slot().is_none()); + drop(permits); + let permits: Vec<_> = (0..8) + .map(|_| { + host.try_reserve_block_slot() + .ok_or_else(|| std::io::Error::other("full release did not restore capacity")) + }) + .collect::, _>>()?; + assert!(host.try_reserve_block_slot().is_none()); + drop(permits); + Ok(()) +} + +#[test] +fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResult { + let shared = Arc::new(bitcoin_rs_stratum::ConnectionLimiter::new(1, 1)?); + let sv1 = Arc::clone(&shared); + let sv2 = Arc::clone(&shared); + let first = sv1 + .try_admit("192.0.2.1".parse()?) + .map_err(|reason| std::io::Error::other(format!("sv1 admission refused: {reason:?}")))?; + assert!(matches!( + sv2.try_admit("192.0.2.2".parse()?), + Err(bitcoin_rs_stratum::RefusalReason::ConnectionLimit) + )); + assert_eq!(shared.active(), 1); + drop(first); + let second = sv2.try_admit("192.0.2.2".parse()?).map_err(|reason| { + std::io::Error::other(format!("sv2 admission after release refused: {reason:?}")) + })?; + assert_eq!(shared.active(), 1); + drop(second); + assert_eq!(shared.active(), 0); + Ok(()) +} + +#[test] +fn protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots() -> TestResult { + let shared = Arc::new(bitcoin_rs_stratum::ConnectionLimiter::new(3, 1)?); + let sv1 = Arc::clone(&shared); + let sv2 = Arc::clone(&shared); + let same_ip = "192.0.2.1".parse()?; + let first = sv1.try_admit(same_ip).map_err(|reason| { + std::io::Error::other(format!("per-IP sv1 admission refused: {reason:?}")) + })?; + assert!(matches!( + sv2.try_admit(same_ip), + Err(bitcoin_rs_stratum::RefusalReason::PerIpLimit) + )); + let other = sv2.try_admit("192.0.2.2".parse()?).map_err(|reason| { + std::io::Error::other(format!("second-IP admission refused: {reason:?}")) + })?; + assert_eq!(shared.active(), 2); + drop(first); + let replacement = sv2.try_admit(same_ip).map_err(|reason| { + std::io::Error::other(format!( + "released per-IP slot not re-admittable: {reason:?}" + )) + })?; + assert_eq!(shared.active(), 2); + drop((replacement, other)); + assert_eq!(shared.active(), 0); + Ok(()) +} + +macro_rules! backend_tests { + ($module:ident, $store:path) => { + mod $module { + use super::*; + + fn open(path: &Path) -> Result, Box> { + Ok(Arc::new(<$store>::open(path)?)) + } + + #[test] + fn block_record_capacity_is_bounded_and_permit_drop_releases_it() -> TestResult { + block_record_reservations_are_bounded_and_released(open) + } + + #[test] + fn only_explicitly_registered_accounts_can_authenticate_or_credit_shares() -> TestResult + { + registered_account_gating(open) + } + + #[test] + fn accepted_share_has_exact_durable_identity_and_idempotent_replay() -> TestResult { + exact_share_survives_reopen(open) + } + + #[test] + fn restart_epoch_prevents_reused_wire_job_identity_collision() -> TestResult { + restart_epoch_separates_reused_wire_job(open) + } + + #[test] + fn rejected_block_receipt_persists_pending_round_without_funds() -> TestResult { + rejected_block_receipt_stays_pending(open) + } + + #[test] + fn failed_block_submission_persists_pending_round_without_funds() -> TestResult { + failed_block_receipt_stays_pending(open) + } + } + }; +} + +#[cfg(feature = "redb")] +backend_tests!(redb, bitcoin_rs_storage::RedbStore); +#[cfg(feature = "fjall")] +backend_tests!(fjall, bitcoin_rs_storage::FjallStore); diff --git a/crates/node/src/state.rs b/crates/node/src/state.rs index 3f21c34f70..7add633890 100644 --- a/crates/node/src/state.rs +++ b/crates/node/src/state.rs @@ -39,6 +39,14 @@ use storage::NodeStorage; use storage::StoredBlockBodySource; mod data_dir_lock; + +/// Locks an explicitly configured pool directory for the lifetime of its store. +#[cfg(feature = "pool")] +pub(crate) fn lock_pool_directory( + path: &std::path::Path, +) -> Result { + Ok(Arc::new(data_dir_lock::DataDirLock::acquire(path)?)) +} mod index; mod open; mod storage; diff --git a/crates/node/tests/unit/config/tests.rs b/crates/node/tests/unit/config/tests.rs index a3b5fb5177..1638cd2a7f 100644 --- a/crates/node/tests/unit/config/tests.rs +++ b/crates/node/tests/unit/config/tests.rs @@ -7,6 +7,197 @@ use crate::options::{ ValidationOverrides, }; +fn pool_layer() -> UserConfig { + UserConfig { + pool: crate::options::PoolOverrides { + enabled: Some(true), + datadir: Some(PathBuf::from("/nonexistent/operator-pool-ledger")), + v1_bind: Some(SocketAddr::from(([127, 0, 0, 1], 3333))), + share_difficulty: Some("1".to_owned()), + window_work: Some(1000), + reward_script: Some("51".to_owned()), + ..Default::default() + }, + mining: crate::options::MiningOverrides { + payout_address: Some("1BoatSLRHtKNngkdXEeobR76b53LETtpyT".to_owned()), + }, + ..Default::default() + } +} + +#[test] +fn pool_defaults_off_and_suboptions_require_enablement() { + assert!(resolved(&[]).pool.is_none()); + let mut layer = pool_layer(); + layer.pool.enabled = Some(false); + assert!(super::resolve(&[&layer]).is_err()); + layer.pool = crate::options::PoolOverrides::default(); + assert!(super::resolve(&[&layer]).is_ok()); +} + +#[cfg(not(feature = "pool"))] +#[test] +fn pool_requires_compiled_feature() { + match super::resolve(&[&pool_layer()]) { + Ok(config) => { + panic!("pool configuration must be refused without the pool build feature: {config:?}") + } + Err(error) => assert!(error.to_string().contains("pool build feature"), "{error}"), + } +} + +#[cfg(feature = "pool")] +#[test] +fn pool_requires_explicit_policy_and_listener() { + for missing in 0..7 { + let mut layer = pool_layer(); + match missing { + 0 => layer.pool.datadir = None, + 1 => layer.pool.v1_bind = None, + 2 => layer.pool.share_difficulty = None, + 3 => layer.pool.window_work = None, + 4 => layer.pool.reward_script = None, + 5 => layer.mining.payout_address = None, + 6 => layer.pool.datadir = Some(PathBuf::new()), + _ => unreachable!(), + } + assert!( + super::resolve(&[&layer]).is_err(), + "missing explicit input {missing}" + ); + } +} + +#[cfg(feature = "pool")] +#[test] +fn pool_layers_preserve_explicit_fields_and_override_policy() -> anyhow::Result<()> { + let base = pool_layer(); + let later = UserConfig { + pool: crate::options::PoolOverrides { + fee_bps: Some(125), + share_difficulty: Some("2.5".to_owned()), + ..Default::default() + }, + ..Default::default() + }; + let config = super::resolve(&[&base, &later])?; + let pool = config + .pool + .ok_or_else(|| anyhow::anyhow!("enabled pool must resolve"))?; + let expected_datadir = base + .pool + .datadir + .ok_or_else(|| anyhow::anyhow!("fixture must specify a pool directory"))?; + assert_eq!(pool.datadir, expected_datadir); + assert_eq!(pool.v1_bind, base.pool.v1_bind); + assert!(pool.sv2_bind.is_none()); + assert_eq!(pool.fee_bps, 125); + assert_eq!(pool.share_difficulty, "2.5"); + assert_eq!( + pool.share_target, + bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("2.5")? + ); + assert_eq!(pool.ledger_config().window_work, 1000); + assert_eq!(pool.ledger_config().pool_fee_bps, 125); + Ok(()) +} + +#[cfg(feature = "pool")] +#[test] +fn pool_non_loopback_requires_explicit_opt_in() { + let mut layer = pool_layer(); + layer.pool.v1_bind = Some(SocketAddr::from(([0, 0, 0, 0], 3333))); + assert!(super::resolve(&[&layer]).is_err()); + layer.pool.allow_non_loopback = Some(true); + assert!(super::resolve(&[&layer]).is_ok()); + layer.pool.v1_bind = Some(SocketAddr::from(([127, 0, 0, 1], 0))); + assert!(super::resolve(&[&layer]).is_err()); +} + +#[cfg(feature = "pool")] +#[test] +fn pool_money_depth_and_capacity_bounds_are_validated() { + for invalid in 0..15 { + let mut layer = pool_layer(); + match invalid { + 0 => layer.pool.window_work = Some(0), + 1 => layer.pool.fee_bps = Some(10_001), + 2 => layer.pool.maturity = Some(99), + 3 => layer.pool.payout_confirmations = Some(0), + 4 => layer.pool.reorg_watch_depth = Some(99), + 5 => layer.pool.payout_confirmations = Some(1001), + 6 => layer.pool.min_payout_sat = Some(545), + 7 => layer.pool.min_payout_sat = Some(u64::MAX), + 8 => layer.pool.max_payout_outputs = Some(0), + 9 => layer.pool.max_payout_outputs = Some(2501), + 10 => layer.pool.max_connections = Some(0), + 11 => layer.pool.max_connections_per_ip = Some(0), + 12 => layer.pool.max_connections_per_ip = Some(129), + 13 => layer.pool.reward_script = Some("6a".to_owned()), + 14 => layer.pool.reward_script = Some("4c0151".to_owned()), + _ => unreachable!(), + } + assert!( + super::resolve(&[&layer]).is_err(), + "invalid policy {invalid}" + ); + } + let mut boundary = pool_layer(); + boundary.pool.fee_bps = Some(10_000); + boundary.pool.max_payout_outputs = Some(2500); + assert!(super::resolve(&[&boundary]).is_ok()); +} + +#[cfg(feature = "pool")] +#[test] +fn pool_exact_difficulty_and_script_parsing_reject_invalid_inputs() { + for value in [ + "", + "0", + "-1", + "NaN", + "1e3", + "18446744073709551616", + "1.00000000000000000000", + ] { + let mut layer = pool_layer(); + layer.pool.share_difficulty = Some(value.to_owned()); + assert!(super::resolve(&[&layer]).is_err(), "difficulty {value}"); + } + for script in ["", "0", "zz", "4c"] { + let mut layer = pool_layer(); + layer.pool.reward_script = Some(script.to_owned()); + assert!(super::resolve(&[&layer]).is_err(), "script {script}"); + } +} + +#[cfg(feature = "pool")] +#[test] +fn pool_sv2_requires_feature_and_paired_authority_path_without_opening_it() { + let mut layer = pool_layer(); + layer.pool.sv2_bind = Some(SocketAddr::from(([127, 0, 0, 1], 3334))); + assert!(super::resolve(&[&layer]).is_err()); + layer.pool.sv2_authority_key = Some(PathBuf::from("/nonexistent/pool-authority-key")); + #[cfg(feature = "pool-sv2")] + { + assert!( + super::resolve(&[&layer]).is_ok(), + "key IO belongs to service startup" + ); + layer.pool.sv2_bind = layer.pool.v1_bind; + assert!(super::resolve(&[&layer]).is_err()); + } + #[cfg(not(feature = "pool-sv2"))] + assert!( + super::resolve(&[&layer]) + .unwrap_err() + .to_string() + .contains("pool-sv2") + ); + layer.pool.sv2_bind = None; + assert!(super::resolve(&[&layer]).is_err()); +} + #[test] fn auth_debug_redacts_secrets() { let auth = Auth::basic("operator", "s3cret"); diff --git a/crates/pool-ledger/Cargo.toml b/crates/pool-ledger/Cargo.toml new file mode 100644 index 0000000000..6bc5a53f72 --- /dev/null +++ b/crates/pool-ledger/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "bitcoin-rs-pool-ledger" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +version.workspace = true +authors.workspace = true +repository.workspace = true +description = "bitcoin-rs :: durable external-signer PPLNS pool ledger" + +[lib] +name = "pool_ledger" + +[features] +default = [] +# Explicitly test-only crash/fault hooks, never enabled by node production features. +test-seam = [] + +[[test]] +name = "ledger" +required-features = ["test-seam"] + +[lints] +workspace = true + +[dependencies] +bitcoin-rs-storage.workspace = true +sha2.workspace = true + +[dev-dependencies] +bitcoin-rs-storage = { workspace = true, features = ["test-seam", "redb", "fjall"] } +tempfile.workspace = true diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs new file mode 100644 index 0000000000..2119313309 --- /dev/null +++ b/crates/pool-ledger/src/kv.rs @@ -0,0 +1,410 @@ +//! Snapshot-backed logical tables and a single revision-guarded durable commit. +use crate::LedgerError; +use bitcoin_rs_storage::{ColumnFamily, KvSnapshot, KvStore, WriteCondition}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::ops::{Bound, RangeBounds}; +use std::sync::{Arc, Mutex, MutexGuard}; + +#[cfg(test)] +#[path = "../tests/support/kv_transactions.rs"] +mod tests; + +const PREFIX: &[u8] = b"pool-ledger/v1/"; +const REVISION: &[u8] = b"pool-ledger/v1/\0revision"; +const CF: ColumnFamily = ColumnFamily::PoolLedger; + +pub(crate) struct Database { + store: Arc, + writer: Mutex<()>, +} +impl Database { + pub(crate) fn new(store: Arc) -> Self { + Self { + store, + writer: Mutex::new(()), + } + } + pub(crate) fn begin_write(&self) -> Result, LedgerError> { + let guard = self + .writer + .lock() + .map_err(|_| LedgerError::Invariant("ledger writer poisoned".into()))?; + self.transaction(Some(guard)) + } + pub(crate) fn begin_read(&self) -> Result, LedgerError> { + self.transaction(None) + } + fn transaction<'a>( + &'a self, + guard: Option>, + ) -> Result, LedgerError> { + let snapshot = self.store.snapshot()?; + let revision = snapshot.get(CF, REVISION)?; + Ok(WriteTransaction { + db: self, + snapshot, + revision, + changes: RefCell::new(BTreeMap::new()), + guard, + }) + } +} +pub(crate) struct WriteTransaction<'a> { + db: &'a Database, + snapshot: Box, + revision: Option>, + changes: RefCell, Option>>>, + guard: Option>, +} +impl WriteTransaction<'_> { + pub(crate) fn open_table( + &self, + definition: TableDefinition<'_, K, V>, + ) -> Result, LedgerError> { + Ok(Table { + snapshot: self.snapshot.as_ref(), + changes: &self.changes, + prefix: definition.prefix()?, + marker: PhantomData, + }) + } + pub(crate) fn has_revision(&self) -> bool { + self.revision.as_ref().is_some_and(|bytes| bytes.len() == 8) + } + pub(crate) fn namespace_is_empty(&self) -> Result { + Ok(self + .snapshot + .iter_prefix(CF, PREFIX)? + .next() + .transpose()? + .is_none()) + } + pub(crate) fn commit(self) -> Result<(), LedgerError> { + if self.guard.is_none() { + return Err(LedgerError::Invariant( + "read transaction cannot commit".into(), + )); + } + let revision = match self.revision.as_deref() { + Some(bytes) => u64::from_be_bytes( + bytes + .try_into() + .map_err(|_| LedgerError::Invariant("invalid ledger revision".into()))?, + ), + None => 0, + }; + let next = revision + .checked_add(1) + .ok_or(LedgerError::Overflow("ledger revision"))?; + let mut batch = self.db.store.new_batch(); + for (key, value) in self.changes.into_inner() { + match value { + Some(value) => batch.put(CF, &key, &value), + None => batch.delete(CF, &key), + } + } + batch.put(CF, REVISION, &next.to_be_bytes()); + let condition = match self.revision.as_deref() { + Some(expected) => WriteCondition::Equals { + cf: CF, + key: REVISION, + expected, + }, + None => WriteCondition::Absent { + cf: CF, + key: REVISION, + }, + }; + if !self.db.store.write_durable_if(&[condition], batch)? { + return Err(LedgerError::Conflict); + } + Ok(()) + } +} + +pub(crate) trait Value: 'static { + type Owned: 'static; + type View<'a> + where + Self: 'a; + fn decode(bytes: Vec) -> Result; + fn view(value: &Self::Owned) -> Self::View<'_>; +} +pub(crate) trait Encode { + fn encode(self) -> Vec; +} +macro_rules! integer { + ($t:ty) => { + impl Value for $t { + type Owned = $t; + type View<'a> = $t; + fn decode(bytes: Vec) -> Result<$t, LedgerError> { + Ok(<$t>::from_be_bytes(bytes.as_slice().try_into().map_err( + |_| LedgerError::Invariant("invalid integer table value".into()), + )?)) + } + fn view(value: &$t) -> $t { + *value + } + } + impl Encode<$t> for $t { + fn encode(self) -> Vec { + self.to_be_bytes().to_vec() + } + } + }; +} +integer!(u64); +integer!(u128); +impl Value for &'static [u8] { + type Owned = Vec; + type View<'a> = &'a [u8]; + fn decode(bytes: Vec) -> Result, LedgerError> { + Ok(bytes) + } + fn view(value: &Vec) -> &[u8] { + value + } +} +impl Encode<&'static [u8]> for &[u8] { + fn encode(self) -> Vec { + self.to_vec() + } +} +impl Value for &'static str { + type Owned = String; + type View<'a> = &'a str; + fn decode(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| LedgerError::Invariant("invalid metadata key".into())) + } + fn view(value: &String) -> &str { + value + } +} +impl Encode<&'static str> for &str { + fn encode(self) -> Vec { + self.as_bytes().to_vec() + } +} +impl Value for () { + type Owned = (); + type View<'a> = (); + fn decode(bytes: Vec) -> Result<(), LedgerError> { + if bytes.is_empty() { + Ok(()) + } else { + Err(LedgerError::Invariant("invalid unit table value".into())) + } + } + fn view((): &()) {} +} +impl Encode<()> for () { + fn encode(self) -> Vec { + Vec::new() + } +} +pub(crate) struct Guard(V::Owned); +impl Guard { + pub(crate) fn value(&self) -> V::View<'_> { + V::view(&self.0) + } +} +pub(crate) struct TableDefinition<'a, K, V> { + name: &'a str, + marker: PhantomData<(K, V)>, +} +impl Copy for TableDefinition<'_, K, V> {} +impl Clone for TableDefinition<'_, K, V> { + fn clone(&self) -> Self { + *self + } +} +impl<'a, K, V> TableDefinition<'a, K, V> { + pub(crate) const fn new(name: &'a str) -> Self { + Self { + name, + marker: PhantomData, + } + } + fn prefix(&self) -> Result, LedgerError> { + let tag = match self.name { + "pplns_meta_u64_v1" => 1, + "pplns_meta_bytes_v1" => 2, + "pplns_shares_v1" => 3, + "pplns_share_hashes_v1" => 4, + "pplns_share_tuples_v1" => 5, + "pplns_retired_jobs_v1" => 6, + "pplns_rounds_v1" => 7, + "pplns_round_by_block_v1" => 8, + "pplns_round_weights_v1" => 9, + "pplns_round_allocs_v1" => 10, + "pplns_open_rounds_v1" => 11, + "pplns_balances_v1" => 12, + "pplns_scripts_v1" => 13, + "pplns_batches_v1" => 14, + "pplns_active_batches_v1" => 15, + "pplns_batch_idem_v1" => 16, + "pplns_batch_txids_v1" => 17, + "pplns_audit_v1" => 18, + _ => { + return Err(LedgerError::Invariant( + "unknown logical ledger table".into(), + )); + } + }; + Ok([PREFIX, &[tag]].concat()) + } +} +type GuardPair = (Guard, Guard); +type Row = Result, LedgerError>; +type Rows = std::vec::IntoIter>; +type EncodedBound = Option<(Vec, bool)>; +pub(crate) struct Table<'a, K: Value, V: Value> { + snapshot: &'a dyn KvSnapshot, + changes: &'a RefCell, Option>>>, + prefix: Vec, + marker: PhantomData<(K, V)>, +} +impl Table<'_, K, V> { + fn key(&self, key: impl Encode) -> Result, LedgerError> { + let encoded = key.encode(); + self.prefix + .len() + .checked_add(encoded.len()) + .ok_or(LedgerError::Overflow("ledger key length"))?; + let mut full = self.prefix.clone(); + full.extend(encoded); + Ok(full) + } + pub(crate) fn get(&self, key: impl Encode) -> Result>, LedgerError> { + let key = self.key(key)?; + let bytes = match self.changes.borrow().get(&key) { + Some(value) => value.clone(), + None => self.snapshot.get(CF, &key)?, + }; + bytes.map(|bytes| V::decode(bytes).map(Guard)).transpose() + } + pub(crate) fn insert( + &self, + key: impl Encode, + value: impl Encode, + ) -> Result<(), LedgerError> { + let key = self.key(key)?; + self.changes.borrow_mut().insert(key, Some(value.encode())); + Ok(()) + } + pub(crate) fn remove(&self, key: impl Encode) -> Result<(), LedgerError> { + let key = self.key(key)?; + self.changes.borrow_mut().insert(key, None); + Ok(()) + } + pub(crate) fn rows_iter(&self) -> Result, LedgerError> { + self.rows(&None, &None) + } + pub(crate) fn range + Clone, R: RangeBounds>( + &self, + range: R, + ) -> Result, LedgerError> { + let bound = |b: Bound<&I>| match b { + Bound::Included(v) => Some((v.clone().encode(), true)), + Bound::Excluded(v) => Some((v.clone().encode(), false)), + Bound::Unbounded => None, + }; + let start = bound(range.start_bound()); + let end = bound(range.end_bound()); + self.rows(&start, &end) + } + fn rows(&self, start: &EncodedBound, end: &EncodedBound) -> Result, LedgerError> { + let within = |key: &[u8]| { + !start.as_ref().is_some_and(|(b, inclusive)| { + key < b.as_slice() || (!inclusive && key == b.as_slice()) + }) && !end.as_ref().is_some_and(|(b, inclusive)| { + key > b.as_slice() || (!inclusive && key == b.as_slice()) + }) + }; + let mut rows = BTreeMap::new(); + let mut seek = self.prefix.clone(); + if let Some((key, _)) = &start { + seek.extend_from_slice(key); + } + loop { + let page = self.snapshot.scan_prefix_from_bounded( + CF, + &self.prefix, + &seek, + bitcoin_rs_storage::PrefixScanLimit { + max_rows: 256, + max_bytes: 1024 * 1024, + }, + )?; + let mut beyond_end = false; + let mut next = None; + for (key, value) in page.rows { + let logical = &key[self.prefix.len()..]; + if end.as_ref().is_some_and(|(b, inclusive)| { + logical > b.as_slice() || (!inclusive && logical == b.as_slice()) + }) { + beyond_end = true; + break; + } + let mut cursor = key.clone(); + cursor.push(0); + next = Some(cursor); + if within(logical) { + rows.insert(key, value); + } + } + if page.complete || beyond_end { + break; + } + seek = next.ok_or_else(|| { + LedgerError::Invariant("incomplete storage scan made no progress".into()) + })?; + } + let mut overlay_start = self.prefix.clone(); + if let Some((key, _)) = &start { + overlay_start.extend_from_slice(key); + } + for (key, value) in self.changes.borrow().range(overlay_start..) { + if !key.starts_with(&self.prefix) { + break; + } + let logical = &key[self.prefix.len()..]; + if !within(logical) { + continue; + } + match value { + Some(value) => { + rows.insert(key.clone(), value.clone()); + } + None => { + rows.remove(key); + } + } + } + let mut result = Vec::new(); + for (key, value) in rows { + let key = &key[self.prefix.len()..]; + result.push(Ok(( + Guard(K::decode(key.to_vec())?), + Guard(V::decode(value)?), + ))); + } + Ok(result.into_iter()) + } + pub(crate) fn last(&self) -> Result>, LedgerError> { + self.rows_iter()?.next_back().transpose() + } + pub(crate) fn len(&self) -> Result { + let mut count = 0u64; + for row in self.rows_iter()? { + row?; + count = count + .checked_add(1) + .ok_or(LedgerError::Overflow("table length"))?; + } + Ok(count) + } +} diff --git a/crates/pool-ledger/src/lib.rs b/crates/pool-ledger/src/lib.rs new file mode 100644 index 0000000000..da9e67ee28 --- /dev/null +++ b/crates/pool-ledger/src/lib.rs @@ -0,0 +1,1859 @@ +//! Durable PPLNS accounting with an external wallet signer boundary. +//! +//! The node supplies an `Arc`. The ledger never opens an engine, +//! holds private keys, calls a wallet RPC, or broadcasts transactions. All mutations use one +//! revision-guarded durable batch; a revision conflict applies nothing, while a backend durability +//! error is ambiguous and must be reconciled through the operation's idempotent identity. +//! Creation requires an empty ledger namespace; opening verifies the immutable configuration. +//! There is no implicit migration from the standalone experimental ledger format. +//! +//! Layout: +//! - this file: identifiers, money/work arithmetic (checked integers only), PPLNS window and +//! reward split, transaction parsing and payout verification, canonical request/receipt codecs, +//! the chain-observation and signer seams, and the error type; +//! - [`store`]: durable accounting transitions ([`Ledger`]); +//! - `kv`: coherent logical-table views and the conditional durable commit boundary. +//! +//! Conventions: every `Hash32` is in internal byte order (the order used by transaction +//! serialization), money is `u64` satoshis, work is `u128`, and no floating point is used. + +#![forbid(unsafe_code)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use sha2::{Digest, Sha256}; + +mod kv; +mod store; + +pub use store::*; + +/// A 32-byte hash in internal (serialization) byte order, never display-reversed. +pub type Hash32 = [u8; 32]; + +/// Maximum amount of money that can exist, in satoshis. +pub const MAX_MONEY_SAT: u64 = 2_100_000_000_000_000; +/// Longest accepted extranonce (`session || rolled`), in bytes. +pub const MAX_EXTRANONCE_LEN: usize = 64; +/// Longest accepted script, in bytes (the consensus script size limit). +pub const MAX_SCRIPT_LEN: usize = 10_000; +/// Largest raw transaction accepted from a signer, in bytes. +pub const MAX_RAW_TX_BYTES: usize = 4_000_000; +/// Largest number of outputs in one payout batch. +pub const MAX_BATCH_OUTPUTS: usize = 2_500; +/// Number of non-batch (change) outputs a signed payout transaction may carry. +pub const MAX_CHANGE_OUTPUTS: usize = 1; +/// Largest number of allowed change scripts in a pool configuration. +pub const MAX_CHANGE_SCRIPTS: usize = 16; + +const OP_RETURN: u8 = 0x6a; +const BATCH_ID_DOMAIN: &[u8] = b"btcrs.pool.payout-batch.v1"; +const REQUEST_MAGIC: &[u8; 4] = b"BRPR"; +const RECEIPT_MAGIC: &[u8; 4] = b"BRPT"; +const WIRE_VERSION: u8 = 1; + +// --------------------------------------------------------------------------------------------- +// Hashing helpers +// --------------------------------------------------------------------------------------------- + +/// SHA-256 of `data`. +#[must_use] +pub fn sha256(data: &[u8]) -> Hash32 { + Sha256::digest(data).into() +} + +/// Double SHA-256 of `data` (Bitcoin transaction id hash). +#[must_use] +pub fn sha256d(data: &[u8]) -> Hash32 { + let first = Sha256::digest(data); + Sha256::digest(first).into() +} + +/// Lowercase hex of `bytes` (diagnostics only; not a display-order conversion). +#[must_use] +pub fn to_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(DIGITS[usize::from(byte >> 4)])); + out.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + out +} + +// --------------------------------------------------------------------------------------------- +// Identifiers and job-owner inputs +// --------------------------------------------------------------------------------------------- + +/// Pool account identifier. The protocol adapter maps an authorized worker/session to one. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MinerId(pub u64); + +/// One accepted share as reported by the common job owner (mirror of the job owner's +/// `AcceptedShare`, class `Share` or `Block`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShareCredit { + /// Header hash of the share. Primary idempotency key, unique-indexed by the ledger. + pub header_hash: Hash32, + /// Numeric job identifier. + pub job: u64, + /// Pool account credited with the work. + pub account: MinerId, + /// Session prefix followed by the rolled extranonce bytes. + pub extranonce: Vec, + /// Header nonce. + pub nonce: u32, + /// Exact work of the assigned share target, at least 1. + pub work: u128, +} + +impl ShareCredit { + /// Check static bounds. + /// + /// # Errors + /// Returns [`LedgerError::InvalidInput`] for zero work or an oversized extranonce. + pub fn validate(&self) -> Result<(), LedgerError> { + if self.work == 0 { + return Err(LedgerError::InvalidInput("share work must be at least 1")); + } + if self.extranonce.len() > MAX_EXTRANONCE_LEN { + return Err(LedgerError::InvalidInput("extranonce too long")); + } + Ok(()) + } + + /// Canonical secondary identity: `job || account || nonce || extranonce` (big-endian). + /// Fixed-width fields come first, so the encoding is injective and prefix-scannable by job. + #[must_use] + pub fn tuple_key(&self) -> Vec { + let mut key = Vec::with_capacity(20 + self.extranonce.len()); + key.extend_from_slice(&self.job.to_be_bytes()); + key.extend_from_slice(&self.account.0.to_be_bytes()); + key.extend_from_slice(&self.nonce.to_be_bytes()); + key.extend_from_slice(&self.extranonce); + key + } +} + +/// Convert the exact little-endian 256-bit `ShareWork` bytes to `u128`. +/// +/// # Errors +/// Returns [`LedgerError::Overflow`] if the value does not fit in 128 bits. +pub fn work_from_le_bytes(le: [u8; 32]) -> Result { + let (low, high) = le.split_at(16); + if high.iter().any(|byte| *byte != 0) { + return Err(LedgerError::Overflow("share work exceeds 2^128")); + } + let mut buf = [0u8; 16]; + buf.copy_from_slice(low); + Ok(u128::from_le_bytes(buf)) +} + +/// A found block (mirror of the job owner's `FoundBlock`). Submission status is deliberately not +/// an input: a submission receipt moves no money. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlockFound { + /// The block-class share. `share.header_hash` is the block hash. + pub share: ShareCredit, + /// Height of the block. + pub height: u32, + /// Coinbase transaction id claimed by the job owner (verified against the chain). + pub coinbase_txid: Hash32, + /// Total coinbase output value claimed by the job owner (verified against the chain). + pub claimed_coinbase_value_sat: u64, +} + +/// Outcome of crediting a share. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShareReceipt { + /// The share was credited with sequence number `seq`. + Credited { + /// Ledger sequence number. + seq: u64, + }, + /// The same header hash was already credited at `seq`; nothing changed. + Duplicate { + /// Ledger sequence number of the original credit. + seq: u64, + }, +} + +impl ShareReceipt { + /// Sequence number of the credit. + #[must_use] + pub fn seq(self) -> u64 { + match self { + Self::Credited { seq } | Self::Duplicate { seq } => seq, + } + } +} + +// --------------------------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------------------------- + +/// Immutable per-ledger accounting parameters. Stored at creation; a ledger refuses to open with +/// different values because they change money semantics. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PoolConfig { + /// PPLNS window size in accepted-share work units, at least 1. + pub window_work: u128, + /// Pool fee in basis points of the observed coinbase reward (0..=10000), rounded down. + pub pool_fee_bps: u16, + /// Confirmations (`tip - height + 1`) a block needs before its reward is credited. + pub coinbase_maturity: u32, + /// Confirmations a payout transaction needs before the batch is Confirmed. + pub payout_confirmations: u32, + /// Depth at which a conflicting confirmed spend makes a signed transaction dead. + pub abandon_confirmations: u32, + /// Depth after which rounds and confirmed batches leave reorg monitoring. + pub reorg_watch_depth: u32, + /// Smallest output the pool will ever pay, in satoshis. + pub dust_limit_sat: u64, + /// Script that the coinbase pays the pool reward to; reward basis is the sum of those outputs. + pub pool_coinbase_script: Vec, + /// Scripts a signed payout transaction may use for its (at most one) change output. + pub change_scripts: Vec>, +} + +impl PoolConfig { + /// Mainnet-shaped defaults: no fee, maturity 100, 6 payout confirmations, 6 abandon depth, + /// 1000-block reorg watch, 546 sat dust, no change scripts. + #[must_use] + pub fn new(window_work: u128, pool_coinbase_script: Vec) -> Self { + Self { + window_work, + pool_fee_bps: 0, + coinbase_maturity: 100, + payout_confirmations: 6, + abandon_confirmations: 6, + reorg_watch_depth: 1_000, + dust_limit_sat: 546, + pool_coinbase_script, + change_scripts: Vec::new(), + } + } + + /// Validate every field. + /// + /// # Errors + /// Returns [`LedgerError::InvalidConfig`] describing the first violated bound. + pub fn validate(&self) -> Result<(), LedgerError> { + if self.window_work == 0 { + return Err(LedgerError::InvalidConfig("window_work must be at least 1")); + } + if self.pool_fee_bps > 10_000 { + return Err(LedgerError::InvalidConfig("pool_fee_bps exceeds 10000")); + } + if self.coinbase_maturity == 0 + || self.payout_confirmations == 0 + || self.abandon_confirmations == 0 + { + return Err(LedgerError::InvalidConfig( + "confirmation counts must be at least 1", + )); + } + let deepest = self + .coinbase_maturity + .max(self.payout_confirmations) + .max(self.abandon_confirmations); + if self.reorg_watch_depth < deepest { + return Err(LedgerError::InvalidConfig( + "reorg_watch_depth must cover maturity, payout and abandon depths", + )); + } + if self.dust_limit_sat == 0 || self.dust_limit_sat > MAX_MONEY_SAT { + return Err(LedgerError::InvalidConfig("dust_limit_sat out of range")); + } + validate_script(&self.pool_coinbase_script) + .map_err(|_| LedgerError::InvalidConfig("pool_coinbase_script invalid"))?; + if self.change_scripts.len() > MAX_CHANGE_SCRIPTS { + return Err(LedgerError::InvalidConfig("too many change scripts")); + } + for script in &self.change_scripts { + validate_script(script) + .map_err(|_| LedgerError::InvalidConfig("change script invalid"))?; + } + Ok(()) + } + + /// Canonical encoding stored in the ledger and compared at open. + /// + /// # Errors + /// Returns [`LedgerError::Codec`] if a length does not fit the wire format. + pub fn encode(&self) -> Result, LedgerError> { + let mut enc = codec::Enc::new(); + enc.u8(WIRE_VERSION); + enc.u128(self.window_work); + enc.u32(u32::from(self.pool_fee_bps)); + enc.u32(self.coinbase_maturity); + enc.u32(self.payout_confirmations); + enc.u32(self.abandon_confirmations); + enc.u32(self.reorg_watch_depth); + enc.u64(self.dust_limit_sat); + enc.bytes(&self.pool_coinbase_script); + enc.len32(self.change_scripts.len()); + for script in &self.change_scripts { + enc.bytes(script); + } + enc.finish() + } +} + +/// A payout or reward script must be non-empty, bounded and not provably unspendable. +fn validate_script(script: &[u8]) -> Result<(), LedgerError> { + if script.is_empty() || script.len() > MAX_SCRIPT_LEN { + return Err(LedgerError::InvalidInput("script length out of range")); + } + if script[0] == OP_RETURN { + return Err(LedgerError::InvalidInput( + "script is unspendable (OP_RETURN)", + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Rounds and balances +// --------------------------------------------------------------------------------------------- + +/// Lifecycle of a reward round (one found block). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RoundState { + /// Recorded from a found block; the active chain has not shown the block yet. + Pending, + /// Block is on the active chain and the reward is fixed, but not yet mature. + Immature, + /// Block is mature on the active chain and allocations are credited to balances. + Matured, + /// Block was displaced from the active chain (credits, if any, were reversed). + Orphaned, +} + +impl RoundState { + pub(crate) fn code(self) -> u8 { + match self { + Self::Pending => 1, + Self::Immature => 2, + Self::Matured => 3, + Self::Orphaned => 4, + } + } + + pub(crate) fn from_code(code: u8) -> Option { + match code { + 1 => Some(Self::Pending), + 2 => Some(Self::Immature), + 3 => Some(Self::Matured), + 4 => Some(Self::Orphaned), + _ => None, + } + } +} + +/// One reward round. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Round { + /// Ledger round id (1-based, monotonic). + pub id: u64, + /// Block hash (internal byte order). + pub block_hash: Hash32, + /// Block height. + pub height: u32, + /// Sequence number of the solving share; the PPLNS window ends there. + pub solving_seq: u64, + /// Coinbase txid claimed by the job owner. + pub coinbase_txid: Hash32, + /// Coinbase total claimed by the job owner. + pub claimed_coinbase_value_sat: u64, + /// Sum of the window weights (at most `window_work`). + pub window_work_total: u128, + /// True once the reward was read from the active chain and allocations were stored. + pub reward_fixed: bool, + /// Observed coinbase value paying the pool script (0 until fixed). + pub gross_sat: u64, + /// Pool fee withheld from `gross_sat`. + pub pool_fee_sat: u64, + /// Amount distributed to accounts: `gross_sat - pool_fee_sat`. + pub net_sat: u64, + /// Current state. + pub state: RoundState, +} + +/// Result of recording a found block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlockRecorded { + /// The round for the block (existing or new). + pub round: Round, + /// Outcome of crediting the solving share in the same transaction. + pub share: ShareReceipt, + /// True if this call created the round. + pub newly_recorded: bool, +} + +/// Account balance. Invariant: `available + reserved + paid - deficit == credited allocations`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Balance { + /// Credited and not reserved: eligible for the next payout batch. + pub available_sat: u64, + /// Reserved by an open payout batch. + pub reserved_sat: u64, + /// Paid by Confirmed payout batches. + pub paid_sat: u64, + /// Credits reversed after they were reserved or paid; netted against future credits. + pub deficit_sat: u64, +} + +/// One state change of a round during reconciliation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoundTransition { + /// Round id. + pub round: u64, + /// State before. + pub from: RoundState, + /// State after. + pub to: RoundState, + /// Satoshis credited to balances by this transition. + pub credited_sat: u64, + /// Satoshis removed from balances by this transition. + pub reversed_sat: u64, + /// Part of `reversed_sat` that could not be removed from `available` and became deficit. + pub deficit_created_sat: u64, +} + +// --------------------------------------------------------------------------------------------- +// Exact integer accounting +// --------------------------------------------------------------------------------------------- + +/// Split a gross reward into `(pool_fee, net)`; the fee rounds down so miners get the remainder. +/// +/// # Errors +/// Returns [`LedgerError::InvalidInput`] for `fee_bps > 10000` and [`LedgerError::Overflow`] on +/// impossible arithmetic. +pub fn split_pool_fee(gross_sat: u64, fee_bps: u16) -> Result<(u64, u64), LedgerError> { + if fee_bps > 10_000 { + return Err(LedgerError::InvalidInput("fee bps exceeds 10000")); + } + let fee = u128::from(gross_sat) * u128::from(fee_bps) / 10_000; + let fee = u64::try_from(fee).map_err(|_| LedgerError::Overflow("pool fee"))?; + let net = gross_sat + .checked_sub(fee) + .ok_or(LedgerError::Overflow("pool fee exceeds gross"))?; + Ok((fee, net)) +} + +const MUL_DIV_LOW_LIMB: u128 = 0xffff_ffff_ffff_ffff; +const MUL_DIV_BITS: usize = 192; + +/// Exact `(floor(a * b / d), (a * b) mod d)` with a 192-bit intermediate product. +/// +/// Requires `0 < d` and `b <= d`, which guarantees the quotient is at most `a`. +/// +/// # Errors +/// Returns [`LedgerError::InvalidInput`] if `d == 0` or `b > d`. +pub fn mul_div_rem(a: u64, b: u128, d: u128) -> Result<(u128, u128), LedgerError> { + if d == 0 { + return Err(LedgerError::InvalidInput("division by zero")); + } + if b > d { + return Err(LedgerError::InvalidInput("weight exceeds total")); + } + let a = u128::from(a); + let low = a * (b & MUL_DIV_LOW_LIMB); + let high = a * (b >> 64); + let mid = high + (low >> 64); + let limbs: [u128; 3] = [low & MUL_DIV_LOW_LIMB, mid & MUL_DIV_LOW_LIMB, mid >> 64]; + let mut quotient: u128 = 0; + let mut remainder: u128 = 0; + for index in (0..MUL_DIV_BITS).rev() { + let bit = (limbs[index / 64] >> (index % 64)) & 1; + let carry = remainder >> 127; + remainder = (remainder << 1) | bit; + quotient <<= 1; + if carry == 1 || remainder >= d { + remainder = remainder.wrapping_sub(d); + quotient |= 1; + } + } + Ok((quotient, remainder)) +} + +/// Split `net_sat` across `weights` (strictly ascending by account, all non-zero). +/// +/// Each account gets `floor(net * weight / total)`; the leftover satoshis go one each to the +/// largest fractional remainders (ties: lower `MinerId`). The result sums to exactly `net_sat`. +/// Zero allocations are omitted. +/// +/// # Errors +/// Returns [`LedgerError::EmptyWindow`] for no weights, [`LedgerError::InvalidInput`] for unsorted +/// or zero weights, and [`LedgerError::Overflow`] on arithmetic failure. +pub fn split_reward( + net_sat: u64, + weights: &[(MinerId, u128)], +) -> Result, LedgerError> { + if weights.is_empty() { + return Err(LedgerError::EmptyWindow); + } + let mut total: u128 = 0; + let mut previous: Option = None; + for (miner, weight) in weights { + if *weight == 0 { + return Err(LedgerError::InvalidInput("zero window weight")); + } + if previous.is_some_and(|prev| *miner <= prev) { + return Err(LedgerError::InvalidInput( + "window weights must be strictly ascending", + )); + } + previous = Some(*miner); + total = total + .checked_add(*weight) + .ok_or(LedgerError::Overflow("window total"))?; + } + let mut parts: Vec<(MinerId, u64, u128)> = Vec::with_capacity(weights.len()); + let mut floor_sum: u64 = 0; + for (miner, weight) in weights { + let (quotient, remainder) = mul_div_rem(net_sat, *weight, total)?; + let quotient = u64::try_from(quotient).map_err(|_| LedgerError::Overflow("allocation"))?; + floor_sum = floor_sum + .checked_add(quotient) + .ok_or(LedgerError::Overflow("allocation sum"))?; + parts.push((*miner, quotient, remainder)); + } + let residual = net_sat + .checked_sub(floor_sum) + .ok_or(LedgerError::Overflow("allocation floors exceed net"))?; + let residual = usize::try_from(residual).map_err(|_| LedgerError::Overflow("residual"))?; + if residual > parts.len() { + return Err(LedgerError::Overflow("residual exceeds participants")); + } + let mut order: Vec = (0..parts.len()).collect(); + order.sort_by(|a, b| { + parts[*b] + .2 + .cmp(&parts[*a].2) + .then(parts[*a].0.cmp(&parts[*b].0)) + }); + for index in order.into_iter().take(residual) { + parts[index].1 = parts[index] + .1 + .checked_add(1) + .ok_or(LedgerError::Overflow("allocation residual"))?; + } + Ok(parts + .into_iter() + .filter(|(_, amount, _)| *amount > 0) + .map(|(miner, amount, _)| (miner, amount)) + .collect()) +} + +/// Builds a bounded PPLNS window from shares fed newest first. +/// +/// The share that straddles the window edge is truncated in integer work units so the window +/// total is exactly `target` once enough work exists. +#[derive(Clone, Debug)] +pub struct WindowAccumulator { + target: u128, + total: u128, + by_account: BTreeMap, +} + +impl WindowAccumulator { + /// New window of `target` work units. + #[must_use] + pub fn new(target: u128) -> Self { + Self { + target, + total: 0, + by_account: BTreeMap::new(), + } + } + + /// True once the window holds `target` work. + #[must_use] + pub fn is_full(&self) -> bool { + self.total >= self.target + } + + /// Work currently in the window. + #[must_use] + pub fn total(&self) -> u128 { + self.total + } + + /// Add the next-older share, truncated to the remaining room. + /// + /// # Errors + /// Returns [`LedgerError::Overflow`] if a per-account total overflows. + pub fn push_older(&mut self, account: MinerId, work: u128) -> Result<(), LedgerError> { + let remaining = self.target.saturating_sub(self.total); + let take = work.min(remaining); + if take == 0 { + return Ok(()); + } + let entry = self.by_account.entry(account).or_insert(0); + *entry = entry + .checked_add(take) + .ok_or(LedgerError::Overflow("window account work"))?; + self.total = self + .total + .checked_add(take) + .ok_or(LedgerError::Overflow("window total"))?; + Ok(()) + } + + /// Per-account weights, ascending by account. + #[must_use] + pub fn into_weights(self) -> Vec<(MinerId, u128)> { + self.by_account.into_iter().collect() + } +} + +// --------------------------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------------------------- + +/// A transaction output point. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OutPoint { + /// Funding transaction id (internal byte order). + pub txid: Hash32, + /// Output index. + pub vout: u32, +} + +/// A transaction output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TxOut { + /// Value in satoshis. + pub value_sat: u64, + /// Locking script. + pub script_pubkey: Vec, +} + +/// Transaction parse failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TxParseError { + /// Input ended early. + Truncated, + /// Bytes remained after the transaction. + TrailingBytes, + /// A compact size was not minimally encoded. + NonCanonicalVarint, + /// Transaction has no inputs. + NoInputs, + /// Transaction has no outputs. + NoOutputs, + /// Segwit marker present with a flag other than 1. + BadWitnessFlag, + /// Segwit encoding with all-empty witnesses. + SuperfluousWitness, + /// Transaction exceeds [`MAX_RAW_TX_BYTES`]. + TooLarge, + /// An output value or the output sum exceeds [`MAX_MONEY_SAT`]. + ValueOutOfRange, + /// A count cannot fit in the remaining bytes. + CountTooLarge, +} + +/// A parsed transaction: only what payout verification needs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedTransaction { + /// Transaction id recomputed from the bytes (internal byte order). + pub txid: Hash32, + /// Version field. + pub version: i32, + /// Lock time field. + pub lock_time: u32, + /// True if the bytes used the segwit serialization. + pub has_witness: bool, + /// Spent outpoints in order. + pub inputs: Vec, + /// Outputs in order. + pub outputs: Vec, +} + +struct TxCursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> TxCursor<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], TxParseError> { + let end = self.pos.checked_add(n).ok_or(TxParseError::Truncated)?; + let slice = self.buf.get(self.pos..end).ok_or(TxParseError::Truncated)?; + self.pos = end; + Ok(slice) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16_le(&mut self) -> Result { + let bytes: [u8; 2] = self + .take(2)? + .try_into() + .map_err(|_| TxParseError::Truncated)?; + Ok(u16::from_le_bytes(bytes)) + } + + fn u32_le(&mut self) -> Result { + let bytes: [u8; 4] = self + .take(4)? + .try_into() + .map_err(|_| TxParseError::Truncated)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn u64_le(&mut self) -> Result { + let bytes: [u8; 8] = self + .take(8)? + .try_into() + .map_err(|_| TxParseError::Truncated)?; + Ok(u64::from_le_bytes(bytes)) + } + + fn hash(&mut self) -> Result { + self.take(32)? + .try_into() + .map_err(|_| TxParseError::Truncated) + } + + fn varint(&mut self) -> Result { + let first = self.u8()?; + match first { + 0..=0xfc => Ok(u64::from(first)), + 0xfd => { + let value = u64::from(self.u16_le()?); + if value < 0xfd { + return Err(TxParseError::NonCanonicalVarint); + } + Ok(value) + } + 0xfe => { + let value = u64::from(self.u32_le()?); + if value < 0x1_0000 { + return Err(TxParseError::NonCanonicalVarint); + } + Ok(value) + } + 0xff => { + let value = self.u64_le()?; + if value < 0x1_0000_0000 { + return Err(TxParseError::NonCanonicalVarint); + } + Ok(value) + } + } + } + + /// Bound an element count by what the remaining bytes can possibly hold. + fn bounded(&self, count: u64, min_item_bytes: usize) -> Result { + let count = usize::try_from(count).map_err(|_| TxParseError::CountTooLarge)?; + let remaining = self.buf.len().saturating_sub(self.pos); + if count > remaining / min_item_bytes { + return Err(TxParseError::CountTooLarge); + } + Ok(count) + } + + fn var_bytes(&mut self) -> Result<&'a [u8], TxParseError> { + let len = self.varint()?; + let len = usize::try_from(len).map_err(|_| TxParseError::Truncated)?; + self.take(len) + } +} + +/// Strictly parse a raw transaction (legacy or BIP144 segwit serialization) and recompute its +/// txid. The whole input must be consumed. +/// +/// # Errors +/// Returns a [`TxParseError`] for any malformed or non-canonical encoding. +pub fn parse_transaction(raw: &[u8]) -> Result { + if raw.len() > MAX_RAW_TX_BYTES { + return Err(TxParseError::TooLarge); + } + let mut cursor = TxCursor { buf: raw, pos: 0 }; + let version = i32::from_le_bytes( + cursor + .take(4)? + .try_into() + .map_err(|_| TxParseError::Truncated)?, + ); + let mut has_witness = false; + let mut body_start = cursor.pos; + let mut input_count = cursor.varint()?; + if input_count == 0 { + if cursor.u8()? != 1 { + return Err(TxParseError::BadWitnessFlag); + } + has_witness = true; + body_start = cursor.pos; + input_count = cursor.varint()?; + } + if input_count == 0 { + return Err(TxParseError::NoInputs); + } + let input_count = cursor.bounded(input_count, 41)?; + let mut inputs = Vec::with_capacity(input_count); + for _ in 0..input_count { + let txid = cursor.hash()?; + let vout = cursor.u32_le()?; + cursor.var_bytes()?; + cursor.u32_le()?; + inputs.push(OutPoint { txid, vout }); + } + let output_count = cursor.varint()?; + if output_count == 0 { + return Err(TxParseError::NoOutputs); + } + let output_count = cursor.bounded(output_count, 9)?; + let mut outputs = Vec::with_capacity(output_count); + let mut output_sum: u64 = 0; + for _ in 0..output_count { + let value_sat = cursor.u64_le()?; + if value_sat > MAX_MONEY_SAT { + return Err(TxParseError::ValueOutOfRange); + } + output_sum = output_sum + .checked_add(value_sat) + .filter(|sum| *sum <= MAX_MONEY_SAT) + .ok_or(TxParseError::ValueOutOfRange)?; + let script_pubkey = cursor.var_bytes()?.to_vec(); + outputs.push(TxOut { + value_sat, + script_pubkey, + }); + } + let body_end = cursor.pos; + if has_witness { + let mut any_witness = false; + for _ in 0..inputs.len() { + let items = cursor.varint()?; + let items = cursor.bounded(items, 1)?; + if items > 0 { + any_witness = true; + } + for _ in 0..items { + cursor.var_bytes()?; + } + } + if !any_witness { + return Err(TxParseError::SuperfluousWitness); + } + } + let lock_time = cursor.u32_le()?; + if cursor.pos != raw.len() { + return Err(TxParseError::TrailingBytes); + } + let txid = if has_witness { + let mut stripped = Vec::with_capacity(8 + (body_end - body_start)); + stripped.extend_from_slice(&raw[..4]); + stripped.extend_from_slice(&raw[body_start..body_end]); + stripped.extend_from_slice(&raw[raw.len() - 4..]); + sha256d(&stripped) + } else { + sha256d(raw) + }; + Ok(ParsedTransaction { + txid, + version, + lock_time, + has_witness, + inputs, + outputs, + }) +} + +// --------------------------------------------------------------------------------------------- +// Payout request / receipt (the external signer contract) +// --------------------------------------------------------------------------------------------- + +/// One payout destination and amount. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayoutOutput { + /// Account being paid. + pub account: MinerId, + /// Destination script, snapshotted when the batch was prepared. + pub script_pubkey: Vec, + /// Amount in satoshis (at least the dust limit). + pub amount_sat: u64, +} + +/// Unsigned payout instructions for an external signer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayoutRequest { + /// Immutable batch id. + pub batch_id: Hash32, + /// Outputs the signed transaction MUST contain exactly (script and amount). + pub outputs: Vec, + /// Sum of the output amounts. + pub total_sat: u64, + /// Scripts the signer may use for a single change output. + pub allowed_change_scripts: Vec>, +} + +fn check_outputs(outputs: &[PayoutOutput]) -> Result { + if outputs.is_empty() || outputs.len() > MAX_BATCH_OUTPUTS { + return Err(LedgerError::InvalidInput( + "payout output count out of range", + )); + } + let mut total: u64 = 0; + for output in outputs { + validate_script(&output.script_pubkey)?; + if output.amount_sat == 0 || output.amount_sat > MAX_MONEY_SAT { + return Err(LedgerError::InvalidInput("payout amount out of range")); + } + total = total + .checked_add(output.amount_sat) + .filter(|sum| *sum <= MAX_MONEY_SAT) + .ok_or(LedgerError::Overflow("payout total"))?; + } + Ok(total) +} + +impl PayoutRequest { + /// Canonical wire form: `"BRPR" | 0x01 | batch_id | n_outputs u32 | (account u64 | amount u64 + /// | script_len u32 | script)* | n_change u32 | (len u32 | script)*`, all big-endian. + /// + /// # Errors + /// Returns an error if the request violates the bounds or does not fit the wire format. + pub fn encode(&self) -> Result, LedgerError> { + let total = check_outputs(&self.outputs)?; + if total != self.total_sat { + return Err(LedgerError::InvalidInput( + "request total does not match outputs", + )); + } + let mut enc = codec::Enc::new(); + enc.raw(REQUEST_MAGIC); + enc.u8(WIRE_VERSION); + enc.hash(&self.batch_id); + enc.len32(self.outputs.len()); + for output in &self.outputs { + enc.u64(output.account.0); + enc.u64(output.amount_sat); + enc.bytes(&output.script_pubkey); + } + enc.len32(self.allowed_change_scripts.len()); + for script in &self.allowed_change_scripts { + enc.bytes(script); + } + enc.finish() + } + + /// Strict inverse of [`PayoutRequest::encode`]. + /// + /// # Errors + /// Returns [`LedgerError::Codec`] or [`LedgerError::InvalidInput`] on any deviation. + pub fn decode(bytes: &[u8]) -> Result { + let mut dec = codec::Dec::new(bytes); + if dec.take(4)? != REQUEST_MAGIC { + return Err(LedgerError::Codec("bad request magic")); + } + if dec.u8()? != WIRE_VERSION { + return Err(LedgerError::Codec("unsupported request version")); + } + let batch_id = dec.hash()?; + let count = dec.count(MAX_BATCH_OUTPUTS, 20)?; + let mut outputs = Vec::with_capacity(count); + for _ in 0..count { + let account = MinerId(dec.u64()?); + let amount_sat = dec.u64()?; + let script_pubkey = dec.bytes(MAX_SCRIPT_LEN)?.to_vec(); + outputs.push(PayoutOutput { + account, + script_pubkey, + amount_sat, + }); + } + let change_count = dec.count(MAX_CHANGE_SCRIPTS, 4)?; + let mut allowed_change_scripts = Vec::with_capacity(change_count); + for _ in 0..change_count { + let script = dec.bytes(MAX_SCRIPT_LEN)?.to_vec(); + validate_script(&script)?; + allowed_change_scripts.push(script); + } + dec.finish()?; + let total_sat = check_outputs(&outputs)?; + Ok(Self { + batch_id, + outputs, + total_sat, + allowed_change_scripts, + }) + } + + /// SHA-256 of the canonical encoding (what an air-gapped signer can display and confirm). + /// + /// # Errors + /// Same as [`PayoutRequest::encode`]. + pub fn digest(&self) -> Result { + Ok(sha256(&self.encode()?)) + } +} + +/// The signer's answer: the fully signed transaction. The txid is recomputed by the ledger, so a +/// receipt cannot vouch for a transaction it does not contain. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayoutReceipt { + /// Batch the transaction pays. + pub batch_id: Hash32, + /// Claimed txid (internal byte order); must equal the recomputed txid. + pub txid: Hash32, + /// Serialized signed transaction (legacy or segwit serialization). + pub raw_tx: Vec, +} + +impl PayoutReceipt { + /// Canonical wire form: `"BRPT" | 0x01 | batch_id | txid | raw_len u32 | raw_tx`. + /// + /// # Errors + /// Returns an error if the transaction exceeds [`MAX_RAW_TX_BYTES`]. + pub fn encode(&self) -> Result, LedgerError> { + if self.raw_tx.len() > MAX_RAW_TX_BYTES { + return Err(LedgerError::InvalidInput("raw transaction too large")); + } + let mut enc = codec::Enc::new(); + enc.raw(RECEIPT_MAGIC); + enc.u8(WIRE_VERSION); + enc.hash(&self.batch_id); + enc.hash(&self.txid); + enc.bytes(&self.raw_tx); + enc.finish() + } + + /// Strict inverse of [`PayoutReceipt::encode`]. + /// + /// # Errors + /// Returns [`LedgerError::Codec`] on any deviation. + pub fn decode(bytes: &[u8]) -> Result { + let mut dec = codec::Dec::new(bytes); + if dec.take(4)? != RECEIPT_MAGIC { + return Err(LedgerError::Codec("bad receipt magic")); + } + if dec.u8()? != WIRE_VERSION { + return Err(LedgerError::Codec("unsupported receipt version")); + } + let batch_id = dec.hash()?; + let txid = dec.hash()?; + let raw_tx = dec.bytes(MAX_RAW_TX_BYTES)?.to_vec(); + dec.finish()?; + Ok(Self { + batch_id, + txid, + raw_tx, + }) + } +} + +/// The external signer adapter contract. +/// +/// Implementations live outside this crate (wallet adapters); this crate never ships one and never +/// holds keys. Asynchronous wallets can skip the trait and deliver a [`PayoutReceipt`] to +/// `Ledger::record_signed` later. +pub trait ExternalSigner { + /// Sign `request`'s exact outputs and return the signed transaction. + /// + /// # Errors + /// Returns [`SignerError`] if the signer refuses or fails. + fn sign(&self, request: &PayoutRequest) -> Result; +} + +/// Failure reported by an external signer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignerError(pub String); + +impl fmt::Display for SignerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "external signer: {}", self.0) + } +} + +impl std::error::Error for SignerError {} + +/// Why a signed transaction was refused. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReceiptError { + /// The raw bytes did not parse. + Parse(TxParseError), + /// The claimed txid differs from the recomputed one. + TxidMismatch, + /// The transaction has no inputs. + NoInputs, + /// An input spends the null outpoint (coinbase-style input). + NullPrevout, + /// The same outpoint is spent twice. + DuplicateInput, + /// A batch output (by index) is not present with exact script and amount. + MissingOutput { + /// Index into the batch outputs. + index: usize, + }, + /// An extra output (by transaction index) is not an allowed change output. + UnexpectedOutput { + /// Index into the transaction outputs. + index: usize, + }, + /// More extra outputs than [`MAX_CHANGE_OUTPUTS`]. + TooManyExtraOutputs, + /// The txid already belongs to another batch. + TxidInOtherBatch, + /// A replacement does not spend an input of an earlier signed transaction of the batch, so + /// both could confirm and pay twice. + NotConflicting { + /// The earlier transaction that the new one does not conflict with. + existing_txid: Hash32, + }, +} + +impl From for ReceiptError { + fn from(error: TxParseError) -> Self { + Self::Parse(error) + } +} + +/// Verify that `tx` pays exactly the batch `outputs` (script and amount, as a multiset) plus at +/// most [`MAX_CHANGE_OUTPUTS`] output to an allowed change script. +/// +/// Signatures and input ownership are not checked here: that is the signer's and the node's job. +/// The chain observation of the recomputed txid is what proves the node accepted the signatures. +/// +/// # Errors +/// Returns the first [`ReceiptError`] found. +pub fn verify_payout_transaction( + outputs: &[PayoutOutput], + allowed_change_scripts: &[Vec], + tx: &ParsedTransaction, +) -> Result<(), ReceiptError> { + if tx.inputs.is_empty() { + return Err(ReceiptError::NoInputs); + } + let mut seen = BTreeSet::new(); + for input in &tx.inputs { + if input.txid == [0u8; 32] && input.vout == u32::MAX { + return Err(ReceiptError::NullPrevout); + } + if !seen.insert(*input) { + return Err(ReceiptError::DuplicateInput); + } + } + let mut used = vec![false; tx.outputs.len()]; + for (index, expected) in outputs.iter().enumerate() { + let found = (0..tx.outputs.len()).find(|candidate| { + !used[*candidate] + && tx.outputs[*candidate].value_sat == expected.amount_sat + && tx.outputs[*candidate].script_pubkey == expected.script_pubkey + }); + match found { + Some(position) => used[position] = true, + None => return Err(ReceiptError::MissingOutput { index }), + } + } + let extras: Vec = (0..tx.outputs.len()) + .filter(|index| !used[*index]) + .collect(); + if extras.len() > MAX_CHANGE_OUTPUTS { + return Err(ReceiptError::TooManyExtraOutputs); + } + for index in extras { + if !allowed_change_scripts + .iter() + .any(|script| *script == tx.outputs[index].script_pubkey) + { + return Err(ReceiptError::UnexpectedOutput { index }); + } + } + Ok(()) +} + +/// Immutable batch id: SHA-256 over a domain tag, the idempotency key and the canonical outputs. +/// +/// # Errors +/// Returns an error if a length does not fit the wire format. +pub fn compute_batch_id( + idempotency_key: &Hash32, + outputs: &[PayoutOutput], +) -> Result { + let mut enc = codec::Enc::new(); + enc.raw(BATCH_ID_DOMAIN); + enc.hash(idempotency_key); + enc.len32(outputs.len()); + for output in outputs { + enc.u64(output.account.0); + enc.u64(output.amount_sat); + enc.bytes(&output.script_pubkey); + } + Ok(sha256(&enc.finish()?)) +} + +// --------------------------------------------------------------------------------------------- +// Payout batches +// --------------------------------------------------------------------------------------------- + +/// Payout batch lifecycle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BatchState { + /// Reserved; instructions exist but no signed transaction is known. + Prepared, + /// A signed transaction matching the batch was recorded. + ExternallySigned, + /// A signed transaction was observed in the mempool or a block. + Broadcast, + /// A signed transaction is confirmed on the active chain at the configured depth. + Confirmed, + /// Cancelled before any signed transaction existed; reservation released. + Cancelled, + /// Every known signed transaction was conclusively double-spent; reservation released. + Failed, +} + +impl BatchState { + pub(crate) fn code(self) -> u8 { + match self { + Self::Prepared => 1, + Self::ExternallySigned => 2, + Self::Broadcast => 3, + Self::Confirmed => 4, + Self::Cancelled => 5, + Self::Failed => 6, + } + } + + pub(crate) fn from_code(code: u8) -> Option { + match code { + 1 => Some(Self::Prepared), + 2 => Some(Self::ExternallySigned), + 3 => Some(Self::Broadcast), + 4 => Some(Self::Confirmed), + 5 => Some(Self::Cancelled), + 6 => Some(Self::Failed), + _ => None, + } + } + + /// True while the batch still holds its reservation (not yet paid, not released). + #[must_use] + pub fn holds_reservation(self) -> bool { + matches!( + self, + Self::Prepared | Self::ExternallySigned | Self::Broadcast + ) + } +} + +/// A signed transaction recorded for a batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignedTx { + /// Recomputed txid. + pub txid: Hash32, + /// Outpoints the transaction spends. + pub inputs: Vec, +} + +/// Where a batch's payment confirmed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Confirmation { + /// Confirming transaction. + pub txid: Hash32, + /// Block that contains it. + pub block_hash: Hash32, + /// Height of that block. + pub height: u32, +} + +/// Operator attestation when cancelling a Prepared batch. The ledger cannot verify either value: +/// it is recorded in the audit trail. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CancelBasis { + /// The instructions were never given to a signer. + NeverHandedToSigner, + /// The signer was told to void the request and confirmed it will not sign. + SignerRevoked, +} + +impl CancelBasis { + pub(crate) fn code(self) -> u8 { + match self { + Self::NeverHandedToSigner => 1, + Self::SignerRevoked => 2, + } + } + + pub(crate) fn from_code(code: u8) -> Option { + match code { + 1 => Some(Self::NeverHandedToSigner), + 2 => Some(Self::SignerRevoked), + _ => None, + } + } +} + +/// Terminal reason of a Cancelled or Failed batch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Closure { + /// Operator cancelled a Prepared batch. + Cancelled(CancelBasis), + /// All signed transactions were double-spent; `conflicting_txid` spent an input of the first. + Failed { + /// Confirmed transaction that spent an input of a signed transaction. + conflicting_txid: Hash32, + }, +} + +/// Parameters for selecting accounts into a payout batch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PayoutPolicy { + /// Accounts need at least this much available balance (and at least the dust limit). + pub min_payout_sat: u64, + /// Most outputs in the batch (1..=[`MAX_BATCH_OUTPUTS`]). + pub max_outputs: usize, +} + +/// A payout batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayoutBatch { + /// Immutable id. + pub id: Hash32, + /// Creation order. + pub seq: u64, + /// Idempotency key of the prepare call. + pub idempotency_key: Hash32, + /// Current state. + pub state: BatchState, + /// Revision counter, bumped on every change. + pub rev: u64, + /// Outputs (snapshot of destination scripts and amounts). + pub outputs: Vec, + /// Sum of outputs. + pub total_sat: u64, + /// Signed transactions recorded so far (RBF replacements add entries). + pub transactions: Vec, + /// Transaction last observed in the mempool or a block. + pub broadcast_txid: Option, + /// Confirmation, while Confirmed. + pub confirmation: Option, + /// Terminal reason, when Cancelled or Failed. + pub closure: Option, +} + +/// One batch state change during reconciliation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BatchTransition { + /// Batch id. + pub batch_id: Hash32, + /// State before. + pub from: BatchState, + /// State after (equal to `from` when a confirmation moved to another block). + pub to: BatchState, +} + +// --------------------------------------------------------------------------------------------- +// Chain observation seam +// --------------------------------------------------------------------------------------------- + +/// Failure reported by a chain observer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChainViewError(pub String); + +impl fmt::Display for ChainViewError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "chain view: {}", self.0) + } +} + +impl std::error::Error for ChainViewError {} + +/// Coinbase transaction of an observed block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObservedCoinbase { + /// Coinbase txid. + pub txid: Hash32, + /// Coinbase outputs. + pub outputs: Vec, +} + +/// Where a transaction is according to the active chain and mempool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TxStatus { + /// Neither confirmed nor in the mempool. + Unknown, + /// In the mempool. + InMempool, + /// In a block on the active chain. + Confirmed { + /// Containing block. + block_hash: Hash32, + /// Height of the containing block. + height: u32, + }, +} + +/// A confirmed spend of an outpoint. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfirmedSpend { + /// Spending transaction. + pub spender_txid: Hash32, + /// Block containing the spend. + pub block_hash: Hash32, + /// Height of that block. + pub height: u32, +} + +/// Authoritative observations of the ACTIVE chain. The ledger decides block, maturity, reorg and +/// confirmation state only from these answers, never from job flags or submission receipts. +pub trait ChainView { + /// Height of the active tip. + /// + /// # Errors + /// Returns [`ChainViewError`] if the observer cannot answer. + fn tip_height(&self) -> Result; + + /// Locate this exact hash on the active chain or identify it as side-chain/unknown. + /// `claimed_height` is only a lookup hint, never canonicality evidence. In particular a known + /// displaced block remains `Side` even when a replacement chain's tip is lower or equal. + /// + /// # Errors + /// Returns [`ChainViewError`] if the observer cannot answer. + fn block_location( + &self, + block_hash: &Hash32, + claimed_height: u32, + ) -> Result; + + /// Coinbase of the block with `block_hash`, `None` if the body is unavailable. + /// + /// # Errors + /// Returns [`ChainViewError`] if the observer cannot answer. + fn coinbase_of(&self, block_hash: &Hash32) -> Result, ChainViewError>; + + /// Position of `txid`. + /// + /// # Errors + /// Returns [`ChainViewError`] if the observer cannot answer. + fn tx_status(&self, txid: &Hash32) -> Result; + + /// The confirmed spend of `outpoint` on the active chain, if any. + /// + /// # Errors + /// Returns [`ChainViewError`] if the observer cannot answer. + fn confirmed_spender( + &self, + outpoint: &OutPoint, + ) -> Result, ChainViewError>; +} + +// --------------------------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------------------------- + +/// Kind of an audit record. Field meanings are documented per variant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuditKind { + /// subject 0; values `[below_seq, removed, kept_work_low64]`. + SharesPruned, + /// subject round id; hash block hash; values `[height, window_total_low64, solving_seq]`. + RoundRecorded, + /// subject round id; hash block hash; values `[gross, pool_fee, net]`. + RewardFixed, + /// subject round id; hash block hash; values `[credited_total, 0, 0]`. + RoundMatured, + /// subject round id; hash block hash; values `[reversed_total, deficit_created, was_credited]`. + RoundOrphaned, + /// subject round id; hash block hash; values `[0, 0, 0]` (Orphaned back to Immature). + RoundRevived, + /// subject account; hash SHA-256 of the script; values `[0, 0, 0]`. + PayoutScriptSet, + /// subject batch seq; hash batch id; values `[total, outputs, 0]`. + BatchPrepared, + /// subject batch seq; hash batch id; hash2 txid; values `[inputs, 0, 0]`. + BatchSigned, + /// subject batch seq; hash batch id; hash2 txid; values `[0, 0, 0]`. + BatchBroadcast, + /// subject batch seq; hash batch id; hash2 txid; values `[height, reconfirmed, 0]`. + BatchConfirmed, + /// subject batch seq; hash batch id; values `[from_state, to_state, 0]`. + BatchReverted, + /// subject batch seq; hash batch id; values `[basis, total, 0]`. + BatchCancelled, + /// subject batch seq; hash batch id; hash2 conflicting txid; values `[total, 0, 0]`. + BatchFailed, +} + +impl AuditKind { + pub(crate) fn code(self) -> u8 { + match self { + Self::SharesPruned => 1, + Self::RoundRecorded => 2, + Self::RewardFixed => 3, + Self::RoundMatured => 4, + Self::RoundOrphaned => 5, + Self::RoundRevived => 6, + Self::PayoutScriptSet => 7, + Self::BatchPrepared => 8, + Self::BatchSigned => 9, + Self::BatchBroadcast => 10, + Self::BatchConfirmed => 11, + Self::BatchReverted => 12, + Self::BatchCancelled => 13, + Self::BatchFailed => 14, + } + } + + pub(crate) fn from_code(code: u8) -> Option { + match code { + 1 => Some(Self::SharesPruned), + 2 => Some(Self::RoundRecorded), + 3 => Some(Self::RewardFixed), + 4 => Some(Self::RoundMatured), + 5 => Some(Self::RoundOrphaned), + 6 => Some(Self::RoundRevived), + 7 => Some(Self::PayoutScriptSet), + 8 => Some(Self::BatchPrepared), + 9 => Some(Self::BatchSigned), + 10 => Some(Self::BatchBroadcast), + 11 => Some(Self::BatchConfirmed), + 12 => Some(Self::BatchReverted), + 13 => Some(Self::BatchCancelled), + 14 => Some(Self::BatchFailed), + _ => None, + } + } +} + +/// One hash-chained audit record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AuditRecord { + /// Monotonic sequence number, starting at 0. + pub seq: u64, + /// Record kind. + pub kind: AuditKind, + /// Kind-specific subject (round id, account, batch seq). + pub subject: u64, + /// Kind-specific hash. + pub hash: Hash32, + /// Kind-specific second hash. + pub hash2: Hash32, + /// Kind-specific values. + pub values: [u64; 3], + /// `SHA-256(domain || previous chain || seq || body)`. + pub chain: Hash32, +} + +// --------------------------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------------------------- + +/// Hash-keyed active-chain membership, independent of tip-height comparisons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockLocation { + /// This exact block is on the active chain at this height. + Canonical { + /// Active-chain height of the queried block hash. + height: u32, + }, + /// This known block is not on the active chain. + Side, + /// The observer cannot establish the block's membership. + Unknown, +} + +/// Every failure of the ledger. +#[derive(Debug)] +pub enum LedgerError { + /// Pool configuration violates a bound. + InvalidConfig(&'static str), + /// The stored configuration differs from the supplied one. + ConfigMismatch, + /// The store has no ledger with a valid schema and revision. + NotALedger, + /// `create` refused to overwrite an occupied ledger namespace. + PathExists, + /// Backend failure; durability errors can be ambiguous after application. + Storage(bitcoin_rs_storage::StorageError), + /// Another ledger handle changed the revision; no effects were applied. + Conflict, + /// Stored data failed a decode or consistency check. + Corrupt(String), + /// Malformed canonical encoding. + Codec(&'static str), + /// Input violated a static bound. + InvalidInput(&'static str), + /// Checked arithmetic failed. + Overflow(&'static str), + /// No shares to split a reward over. + EmptyWindow, + /// The window would need shares that were pruned. + WindowPruned, + /// The job was retired; its shares are no longer accepted. + JobRetired(u64), + /// Same header hash with different work or identity. + ShareConflict { + /// Sequence number of the original credit. + seq: u64, + }, + /// Different header hash reusing a `(job, account, extranonce, nonce)` identity. + ShareIdentityConflict { + /// Sequence number of the original credit. + seq: u64, + }, + /// A referenced share does not exist. + UnknownShare, + /// The block is already recorded with a different claim. + BlockConflict, + /// No batch with this id. + UnknownBatch(Hash32), + /// The requested action is illegal in the batch's state. + IllegalTransition { + /// Batch id. + batch: Hash32, + /// Current state. + from: BatchState, + /// Attempted action. + action: &'static str, + }, + /// A signed transaction was refused. + Receipt(ReceiptError), + /// The chain observer has not seen the transaction. + NotObserved(Hash32), + /// Chain observer failure. + Chain(ChainViewError), + /// Signer failure. + Signer(SignerError), + /// A ledger invariant is violated. + Invariant(String), + /// A test fail plan fired. + #[cfg(feature = "test-seam")] + Injected(&'static str), +} + +impl fmt::Display for LedgerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(m) => write!(f, "invalid pool config: {m}"), + Self::ConfigMismatch => write!(f, "stored pool config differs from supplied config"), + Self::NotALedger => write!(f, "store is not a pool accounting ledger"), + Self::PathExists => write!(f, "ledger namespace already exists"), + Self::Storage(m) => write!(f, "storage: {m}"), + Self::Conflict => write!(f, "ledger revision changed; transaction not applied"), + Self::Corrupt(m) => write!(f, "corrupt ledger: {m}"), + Self::Codec(m) => write!(f, "codec: {m}"), + Self::InvalidInput(m) => write!(f, "invalid input: {m}"), + Self::Overflow(m) => write!(f, "arithmetic overflow: {m}"), + Self::EmptyWindow => write!(f, "no shares in the PPLNS window"), + Self::WindowPruned => write!(f, "PPLNS window needs pruned shares"), + Self::JobRetired(job) => write!(f, "job {job} is retired"), + Self::ShareConflict { seq } => write!(f, "share conflicts with credit {seq}"), + Self::ShareIdentityConflict { seq } => { + write!(f, "share identity already used by credit {seq}") + } + Self::UnknownShare => write!(f, "unknown share"), + Self::BlockConflict => write!(f, "block already recorded with a different claim"), + Self::UnknownBatch(id) => write!(f, "unknown batch {}", to_hex(id)), + Self::IllegalTransition { + batch, + from, + action, + } => { + write!( + f, + "batch {} in state {from:?} cannot {action}", + to_hex(batch) + ) + } + Self::Receipt(e) => write!(f, "receipt refused: {e:?}"), + Self::NotObserved(txid) => write!(f, "transaction {} not observed", to_hex(txid)), + Self::Chain(e) => write!(f, "{e}"), + Self::Signer(e) => write!(f, "{e}"), + Self::Invariant(m) => write!(f, "invariant violated: {m}"), + #[cfg(feature = "test-seam")] + Self::Injected(op) => write!(f, "injected failure in {op}"), + } + } +} + +impl std::error::Error for LedgerError {} + +impl From for LedgerError { + fn from(error: ChainViewError) -> Self { + Self::Chain(error) + } +} + +impl From for LedgerError { + fn from(error: SignerError) -> Self { + Self::Signer(error) + } +} + +impl From for LedgerError { + fn from(error: ReceiptError) -> Self { + Self::Receipt(error) + } +} + +impl From for LedgerError { + fn from(error: bitcoin_rs_storage::StorageError) -> Self { + Self::Storage(error) + } +} + +// --------------------------------------------------------------------------------------------- +// Canonical binary codec (big-endian, length-prefixed) +// --------------------------------------------------------------------------------------------- + +pub(crate) mod codec { + use super::{Hash32, LedgerError}; + + pub(crate) struct Enc { + buf: Vec, + overflow: bool, + } + + impl Enc { + pub(crate) fn new() -> Self { + Self { + buf: Vec::new(), + overflow: false, + } + } + + pub(crate) fn u8(&mut self, value: u8) { + self.buf.push(value); + } + + pub(crate) fn u32(&mut self, value: u32) { + self.buf.extend_from_slice(&value.to_be_bytes()); + } + + pub(crate) fn u64(&mut self, value: u64) { + self.buf.extend_from_slice(&value.to_be_bytes()); + } + + pub(crate) fn u128(&mut self, value: u128) { + self.buf.extend_from_slice(&value.to_be_bytes()); + } + + pub(crate) fn hash(&mut self, value: &Hash32) { + self.buf.extend_from_slice(value); + } + + pub(crate) fn raw(&mut self, value: &[u8]) { + self.buf.extend_from_slice(value); + } + + pub(crate) fn len32(&mut self, len: usize) { + if let Ok(value) = u32::try_from(len) { + self.u32(value); + } else { + self.overflow = true; + self.u32(0); + } + } + + pub(crate) fn bytes(&mut self, value: &[u8]) { + self.len32(value.len()); + self.raw(value); + } + + pub(crate) fn finish(self) -> Result, LedgerError> { + if self.overflow { + Err(LedgerError::Codec("length exceeds u32")) + } else { + Ok(self.buf) + } + } + } + + pub(crate) struct Dec<'a> { + buf: &'a [u8], + pos: usize, + } + + impl<'a> Dec<'a> { + pub(crate) fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], LedgerError> { + let end = self + .pos + .checked_add(n) + .ok_or(LedgerError::Codec("length overflow"))?; + let slice = self + .buf + .get(self.pos..end) + .ok_or(LedgerError::Codec("truncated"))?; + self.pos = end; + Ok(slice) + } + + pub(crate) fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + pub(crate) fn u32(&mut self) -> Result { + let bytes: [u8; 4] = self + .take(4)? + .try_into() + .map_err(|_| LedgerError::Codec("truncated"))?; + Ok(u32::from_be_bytes(bytes)) + } + + pub(crate) fn u64(&mut self) -> Result { + let bytes: [u8; 8] = self + .take(8)? + .try_into() + .map_err(|_| LedgerError::Codec("truncated"))?; + Ok(u64::from_be_bytes(bytes)) + } + + pub(crate) fn u128(&mut self) -> Result { + let bytes: [u8; 16] = self + .take(16)? + .try_into() + .map_err(|_| LedgerError::Codec("truncated"))?; + Ok(u128::from_be_bytes(bytes)) + } + + pub(crate) fn hash(&mut self) -> Result { + self.take(32)? + .try_into() + .map_err(|_| LedgerError::Codec("truncated")) + } + + /// Length-prefixed bytes, at most `max` long. + pub(crate) fn bytes(&mut self, max: usize) -> Result<&'a [u8], LedgerError> { + let len = usize::try_from(self.u32()?).map_err(|_| LedgerError::Codec("length"))?; + if len > max { + return Err(LedgerError::Codec("field too long")); + } + self.take(len) + } + + /// Element count, at most `max` and at most what the remaining bytes can hold. + pub(crate) fn count( + &mut self, + max: usize, + min_item_bytes: usize, + ) -> Result { + let count = usize::try_from(self.u32()?).map_err(|_| LedgerError::Codec("count"))?; + let remaining = self.buf.len().saturating_sub(self.pos); + if count > max || count > remaining / min_item_bytes.max(1) { + return Err(LedgerError::Codec("count too large")); + } + Ok(count) + } + + pub(crate) fn finish(self) -> Result<(), LedgerError> { + if self.pos == self.buf.len() { + Ok(()) + } else { + Err(LedgerError::Codec("trailing bytes")) + } + } + } +} diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs new file mode 100644 index 0000000000..1b13130d83 --- /dev/null +++ b/crates/pool-ledger/src/store.rs @@ -0,0 +1,2598 @@ +//! The authoritative backend-neutral ledger. +//! +//! Every mutation buffers all logical table effects against one coherent snapshot, then commits +//! one durable batch guarded by that snapshot's revision. A local mutex serializes this handle; +//! the revision comparison rejects stale writes from other handles. Reads use coherent snapshots. +//! Authoritative chain observations are fetched +//! before the write transaction (no external call is made while the write lock is held) and each +//! applied observation is re-validated against the stored record inside the transaction. Every +//! mutating call is idempotent: replaying it after a crash or a lost reply changes nothing the +//! second time, because the identity it keys on (header hash, block hash, idempotency key, txid, +//! round or batch state) is checked inside the same transaction that would apply it. + +use crate::kv::{Database, Table, TableDefinition, WriteTransaction}; +use bitcoin_rs_storage::KvStore; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +#[cfg(feature = "test-seam")] +use std::sync::Mutex; + +use crate::codec::{Dec, Enc}; +use crate::{ + AuditKind, AuditRecord, Balance, BatchState, BatchTransition, BlockFound, BlockRecorded, + CancelBasis, ChainView, Closure, Confirmation, ExternalSigner, Hash32, LedgerError, + MAX_BATCH_OUTPUTS, MAX_EXTRANONCE_LEN, MAX_MONEY_SAT, MAX_SCRIPT_LEN, MinerId, + ObservedCoinbase, OutPoint, PayoutBatch, PayoutOutput, PayoutPolicy, PayoutReceipt, + PayoutRequest, PoolConfig, ReceiptError, Round, RoundState, RoundTransition, ShareCredit, + ShareReceipt, SignedTx, TxStatus, WindowAccumulator, compute_batch_id, parse_transaction, + split_pool_fee, split_reward, verify_payout_transaction, +}; + +/// Most signed transactions (RBF replacements) recorded for one batch. +pub const MAX_SIGNED_TXS_PER_BATCH: usize = 32; +const MAX_INPUTS_PER_TX: usize = 100_000; +const SCHEMA_VERSION: u64 = 1; +const AUDIT_DOMAIN: &[u8] = b"btcrs.pool.audit.v1"; +const AUDIT_BODY_LEN: usize = 1 + 8 + 32 + 32 + 24; +const ZERO_HASH: Hash32 = [0u8; 32]; + +type U64Bytes = TableDefinition<'static, u64, &'static [u8]>; +type BytesBytes = TableDefinition<'static, &'static [u8], &'static [u8]>; +type BytesU64 = TableDefinition<'static, &'static [u8], u64>; +type BytesU128 = TableDefinition<'static, &'static [u8], u128>; +type U64Unit = TableDefinition<'static, u64, ()>; + +const META_U64: TableDefinition<'static, &'static str, u64> = + TableDefinition::new("pplns_meta_u64_v1"); +const META_BYTES: TableDefinition<'static, &'static str, &'static [u8]> = + TableDefinition::new("pplns_meta_bytes_v1"); +const SHARES: U64Bytes = TableDefinition::new("pplns_shares_v1"); +const SHARE_HASHES: BytesBytes = TableDefinition::new("pplns_share_hashes_v1"); +const SHARE_TUPLES: BytesBytes = TableDefinition::new("pplns_share_tuples_v1"); +const RETIRED_JOBS: U64Unit = TableDefinition::new("pplns_retired_jobs_v1"); +const ROUNDS: U64Bytes = TableDefinition::new("pplns_rounds_v1"); +const ROUND_BY_BLOCK: BytesU64 = TableDefinition::new("pplns_round_by_block_v1"); +const ROUND_WEIGHTS: BytesU128 = TableDefinition::new("pplns_round_weights_v1"); +const ROUND_ALLOCS: BytesU64 = TableDefinition::new("pplns_round_allocs_v1"); +const OPEN_ROUNDS: U64Unit = TableDefinition::new("pplns_open_rounds_v1"); +const BALANCES: U64Bytes = TableDefinition::new("pplns_balances_v1"); +const SCRIPTS: U64Bytes = TableDefinition::new("pplns_scripts_v1"); +const BATCHES: BytesBytes = TableDefinition::new("pplns_batches_v1"); +const ACTIVE_BATCHES: U64Bytes = TableDefinition::new("pplns_active_batches_v1"); +const BATCH_IDEM: BytesBytes = TableDefinition::new("pplns_batch_idem_v1"); +const BATCH_TXIDS: BytesBytes = TableDefinition::new("pplns_batch_txids_v1"); +const AUDIT: U64Bytes = TableDefinition::new("pplns_audit_v1"); + +const M_SCHEMA: &str = "schema"; +const M_NEXT_RUN_EPOCH: &str = "next_run_epoch"; +const M_NEXT_SHARE: &str = "next_share_seq"; +const M_NEXT_ROUND: &str = "next_round_id"; +const M_NEXT_BATCH: &str = "next_batch_seq"; +const M_NEXT_AUDIT: &str = "next_audit_seq"; +const M_PRUNED_BELOW: &str = "pruned_below_seq"; +const M_CONFIG: &str = "config"; +const M_AUDIT_HEAD: &str = "audit_head"; + +/// Operation name of [`Ledger::record_share`] for fail plans. +pub const OP_RECORD_SHARE: &str = "record_share"; +/// Operation name of [`Ledger::retire_job`] for fail plans. +pub const OP_RETIRE_JOB: &str = "retire_job"; +/// Operation name of [`Ledger::prune_shares`] for fail plans. +pub const OP_PRUNE_SHARES: &str = "prune_shares"; +/// Operation name of [`Ledger::record_found_block`] for fail plans. +pub const OP_RECORD_BLOCK: &str = "record_found_block"; +/// Operation name of [`Ledger::reconcile_rounds`] for fail plans. +pub const OP_RECONCILE_ROUNDS: &str = "reconcile_rounds"; +/// Operation name of [`Ledger::set_payout_script`] for fail plans. +pub const OP_SET_SCRIPT: &str = "set_payout_script"; +/// Operation name of [`Ledger::prepare_payout_batch`] for fail plans. +pub const OP_PREPARE_PAYOUT: &str = "prepare_payout_batch"; +/// Operation name of [`Ledger::record_signed`] for fail plans. +pub const OP_RECORD_SIGNED: &str = "record_signed"; +/// Operation name of [`Ledger::record_broadcast`] for fail plans. +pub const OP_RECORD_BROADCAST: &str = "record_broadcast"; +/// Operation name of [`Ledger::cancel_prepared`] for fail plans. +pub const OP_CANCEL_PREPARED: &str = "cancel_prepared"; +/// Operation name of [`Ledger::reconcile_payouts`] for fail plans. +pub const OP_RECONCILE_PAYOUTS: &str = "reconcile_payouts"; + +// --------------------------------------------------------------------------------------------- +// Fail plan (test seam for crash and power-loss interruption) +// --------------------------------------------------------------------------------------------- + +/// Where in an operation the fail plan fires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg(feature = "test-seam")] +pub enum FailPoint { + /// After every table change, immediately before durable commit (the change is lost). + BeforeCommit, + /// Immediately after durable commit (the change is durable but the caller sees no result). + AfterCommit, +} + +/// What the fail plan does when it fires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg(feature = "test-seam")] +pub enum FailAction { + /// Return [`LedgerError::Injected`]. + ReturnError, + /// Abort the whole process (SIGABRT), as a power loss would: no destructor runs. + AbortProcess, +} + +/// A one-shot interruption of the named operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg(feature = "test-seam")] +pub struct FailPlan { + /// One of the `OP_*` constants. + pub op: &'static str, + /// Where the plan fires. + pub point: FailPoint, + /// What it does. + pub action: FailAction, +} + +// --------------------------------------------------------------------------------------------- +// Reports +// --------------------------------------------------------------------------------------------- + +/// A round that needs operator attention and was left unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoundAnomaly { + /// Round id. + pub round: u64, + /// Why the round was not advanced. + pub reason: &'static str, +} + +/// Result of [`Ledger::reconcile_rounds`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RoundReconcileReport { + /// State changes applied. + pub transitions: Vec, + /// Rounds left unchanged because the chain contradicts the job owner's claim. + pub anomalies: Vec, +} + +/// Totals verified by [`Ledger::check_invariants`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InvariantReport { + /// Accounts with a balance or any credited allocation. + pub accounts: usize, + /// Rounds. + pub rounds: usize, + /// Batches. + pub batches: usize, + /// Sum of allocations of Matured rounds. + pub credited_sat: u128, + /// Sum of `available_sat`. + pub available_sat: u128, + /// Sum of `reserved_sat`. + pub reserved_sat: u128, + /// Sum of `paid_sat`. + pub paid_sat: u128, + /// Sum of `deficit_sat`. + pub deficit_sat: u128, +} + +// --------------------------------------------------------------------------------------------- +// Ledger +// --------------------------------------------------------------------------------------------- + +/// Durable PPLNS accounting over a node-owned backend-neutral store. +pub struct Ledger { + db: Database, + config: PoolConfig, + #[cfg(feature = "test-seam")] + fail_plan: Mutex>, +} + +struct Tables<'t> { + meta_u64: Table<'t, &'static str, u64>, + meta_bytes: Table<'t, &'static str, &'static [u8]>, + shares: Table<'t, u64, &'static [u8]>, + share_hashes: Table<'t, &'static [u8], &'static [u8]>, + share_tuples: Table<'t, &'static [u8], &'static [u8]>, + retired_jobs: Table<'t, u64, ()>, + rounds: Table<'t, u64, &'static [u8]>, + round_by_block: Table<'t, &'static [u8], u64>, + round_weights: Table<'t, &'static [u8], u128>, + round_allocs: Table<'t, &'static [u8], u64>, + open_rounds: Table<'t, u64, ()>, + balances: Table<'t, u64, &'static [u8]>, + scripts: Table<'t, u64, &'static [u8]>, + batches: Table<'t, &'static [u8], &'static [u8]>, + active_batches: Table<'t, u64, &'static [u8]>, + batch_idem: Table<'t, &'static [u8], &'static [u8]>, + batch_txids: Table<'t, &'static [u8], &'static [u8]>, + audit: Table<'t, u64, &'static [u8]>, +} + +impl<'t> Tables<'t> { + fn open(txn: &'t WriteTransaction<'_>) -> Result { + Ok(Self { + meta_u64: txn.open_table(META_U64)?, + meta_bytes: txn.open_table(META_BYTES)?, + shares: txn.open_table(SHARES)?, + share_hashes: txn.open_table(SHARE_HASHES)?, + share_tuples: txn.open_table(SHARE_TUPLES)?, + retired_jobs: txn.open_table(RETIRED_JOBS)?, + rounds: txn.open_table(ROUNDS)?, + round_by_block: txn.open_table(ROUND_BY_BLOCK)?, + round_weights: txn.open_table(ROUND_WEIGHTS)?, + round_allocs: txn.open_table(ROUND_ALLOCS)?, + open_rounds: txn.open_table(OPEN_ROUNDS)?, + balances: txn.open_table(BALANCES)?, + scripts: txn.open_table(SCRIPTS)?, + batches: txn.open_table(BATCHES)?, + active_batches: txn.open_table(ACTIVE_BATCHES)?, + batch_idem: txn.open_table(BATCH_IDEM)?, + batch_txids: txn.open_table(BATCH_TXIDS)?, + audit: txn.open_table(AUDIT)?, + }) + } +} + +impl Ledger { + /// Create a ledger in an empty ledger namespace of the supplied store. + /// + /// # Errors + /// Returns [`LedgerError::PathExists`] if the ledger namespace is occupied. + /// Existing data is never migrated, overwritten or reset implicitly. + pub fn create(store: Arc, config: PoolConfig) -> Result { + config.validate()?; + let encoded = config.encode()?; + let db = Database::new(store); + let txn = db.begin_write()?; + if !txn.namespace_is_empty()? { + return Err(LedgerError::PathExists); + } + { + let tables = Tables::open(&txn)?; + tables.meta_bytes.insert(M_CONFIG, encoded.as_slice())?; + tables.meta_u64.insert(M_SCHEMA, SCHEMA_VERSION)?; + } + txn.commit()?; + Ok(Self { + db, + config, + #[cfg(feature = "test-seam")] + fail_plan: Mutex::new(None), + }) + } + + /// Reopen an existing ledger. The stored configuration must equal `config`. + /// + /// # Errors + /// Returns [`LedgerError::NotALedger`] if the store holds no valid ledger, + /// [`LedgerError::ConfigMismatch`] if the stored configuration differs, or a storage error. + pub fn open(store: Arc, config: PoolConfig) -> Result { + config.validate()?; + let encoded = config.encode()?; + let db = Database::new(store); + { + let read = db.begin_read()?; + let bytes_table = read.open_table(META_BYTES)?; + let counter_table = read.open_table(META_U64)?; + let schema = counter_table.get(M_SCHEMA)?.map(|guard| guard.value()); + if schema != Some(SCHEMA_VERSION) || !read.has_revision() { + return Err(LedgerError::NotALedger); + } + let stored = bytes_table + .get(M_CONFIG)? + .map(|guard| guard.value().to_vec()); + match stored { + Some(existing) if existing == encoded => {} + Some(_) => return Err(LedgerError::ConfigMismatch), + None => return Err(LedgerError::NotALedger), + } + } + Ok(Self { + db, + config, + #[cfg(feature = "test-seam")] + fail_plan: Mutex::new(None), + }) + } + + /// The immutable accounting configuration. + #[must_use] + pub fn config(&self) -> &PoolConfig { + &self.config + } + + /// Allocate a durable, nonzero namespace for one pool runtime's wire-job identifiers. + /// + /// Combine this epoch with a 32-bit wire id as `(u64::from(epoch) << 32) | wire_id`. + /// Epochs are never reused, including after an ambiguous durability error; a caller must + /// allocate again rather than assume a failed allocation did not commit. + /// + /// # Errors + /// Returns a revision conflict, storage error, or overflow once the epoch space is exhausted. + pub fn allocate_run_epoch(&self) -> Result { + let txn = self.db.begin_write()?; + let epoch = { + let tables = Tables::open(&txn)?; + let epoch = bump(&tables.meta_u64, M_NEXT_RUN_EPOCH, 1)?; + u32::try_from(epoch).map_err(|_| LedgerError::Overflow("pool run epoch"))? + }; + self.commit(txn, "allocate_run_epoch")?; + Ok(epoch) + } + + /// Arm a one-shot interruption (test seam). Replaces any armed plan. + #[cfg(feature = "test-seam")] + pub fn arm_fail_plan(&self, plan: FailPlan) { + let mut slot = self + .fail_plan + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = Some(plan); + } + + /// Remove any armed plan. + #[cfg(feature = "test-seam")] + pub fn disarm_fail_plan(&self) { + let mut slot = self + .fail_plan + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = None; + } + + #[cfg(feature = "test-seam")] + fn inject(&self, op: &'static str, point: FailPoint) -> Result<(), LedgerError> { + let fired = { + let mut slot = self + .fail_plan + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match *slot { + Some(plan) if plan.op == op && plan.point == point => { + *slot = None; + Some(plan) + } + _ => None, + } + }; + match fired { + None => Ok(()), + Some(plan) => match plan.action { + FailAction::ReturnError => Err(LedgerError::Injected(op)), + FailAction::AbortProcess => std::process::abort(), + }, + } + } + + /// Commit with the fail plan hooks. Returning early drops (rolls back) the transaction. + fn commit(&self, txn: WriteTransaction<'_>, op: &'static str) -> Result<(), LedgerError> { + let _ = self; + #[cfg(feature = "test-seam")] + self.inject(op, FailPoint::BeforeCommit)?; + txn.commit()?; + #[cfg(feature = "test-seam")] + self.inject(op, FailPoint::AfterCommit)?; + let _ = op; + Ok(()) + } + + // ----------------------------------------------------------------------------------------- + // Shares + // ----------------------------------------------------------------------------------------- + + /// Credit one accepted share. Idempotent on the header hash. + /// + /// # Errors + /// Returns [`LedgerError::JobRetired`], [`LedgerError::ShareConflict`], + /// [`LedgerError::ShareIdentityConflict`] or an input/storage error. + pub fn record_share(&self, share: &ShareCredit) -> Result { + share.validate()?; + let txn = self.db.begin_write()?; + let receipt = { + let tables = Tables::open(&txn)?; + credit_share(&tables, share)? + }; + if matches!(receipt, ShareReceipt::Credited { .. }) { + self.commit(txn, OP_RECORD_SHARE)?; + } + Ok(receipt) + } + + /// Retire a job: later shares for it are refused and its identity index rows are deleted so + /// the index stays bounded. Call only after the job's found block (if any) is recorded. + /// Returns the number of index rows removed. + /// + /// # Errors + /// Returns a storage error. + pub fn retire_job(&self, job: u64) -> Result { + let txn = self.db.begin_write()?; + let removed = { + let tables = Tables::open(&txn)?; + if tables.retired_jobs.get(job)?.is_some() { + return Ok(0); + } + let start = job.to_be_bytes(); + let upper = job.checked_add(1).map(u64::to_be_bytes); + let mut rows: Vec<(Vec, Hash32)> = Vec::new(); + { + let iter = match upper.as_ref() { + Some(end) => tables + .share_tuples + .range(start.as_slice()..end.as_slice())?, + None => tables.share_tuples.range(start.as_slice()..)?, + }; + for item in iter { + let (key, value) = item?; + rows.push((key.value().to_vec(), hash_from(value.value())?)); + } + } + for (key, hash) in &rows { + tables.share_tuples.remove(key.as_slice())?; + tables.share_hashes.remove(hash.as_slice())?; + } + tables.retired_jobs.insert(job, ())?; + rows.len() + }; + self.commit(txn, OP_RETIRE_JOB)?; + Ok(removed) + } + + /// Delete the oldest shares, keeping the newest shares that hold at least `keep_work` work. + /// `keep_work` must cover the PPLNS window. A block found later for an older solving share + /// fails with [`LedgerError::WindowPruned`] instead of using a short window. + /// Returns the number of shares deleted. + /// + /// # Errors + /// Returns [`LedgerError::InvalidInput`] if `keep_work` is below the window, or an + /// overflow/storage error. + pub fn prune_shares(&self, keep_work: u128) -> Result { + if keep_work < self.config.window_work { + return Err(LedgerError::InvalidInput( + "keep_work must cover the PPLNS window", + )); + } + let txn = self.db.begin_write()?; + let removed = { + let tables = Tables::open(&txn)?; + let mut cumulative: u128 = 0; + let mut boundary: Option = None; + for item in tables.shares.rows_iter()?.rev() { + let (key, value) = item?; + let (_, work) = decode_share(value.value())?; + cumulative = cumulative + .checked_add(work) + .ok_or(LedgerError::Overflow("retained share work"))?; + if cumulative >= keep_work { + boundary = Some(key.value()); + break; + } + } + let Some(boundary) = boundary else { + return Ok(0); + }; + let doomed: Vec = tables + .shares + .range(..boundary)? + .map(|item| item.map(|(key, _)| key.value())) + .collect::, LedgerError>>()?; + if doomed.is_empty() { + return Ok(0); + } + for seq in &doomed { + tables.shares.remove(*seq)?; + } + let previous = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + if boundary > previous { + tables.meta_u64.insert(M_PRUNED_BELOW, boundary)?; + } + let removed = + u64::try_from(doomed.len()).map_err(|_| LedgerError::Overflow("count"))?; + append_audit( + &tables, + AuditKind::SharesPruned, + 0, + &ZERO_HASH, + &ZERO_HASH, + [boundary, removed, low64(cumulative)], + )?; + removed + }; + self.commit(txn, OP_PRUNE_SHARES)?; + Ok(removed) + } + + /// The PPLNS window ending at the newest share: `(account, work)` ascending by account. + /// + /// # Errors + /// Returns a storage or overflow error. + pub fn current_window(&self) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let shares = read.open_table(SHARES)?; + let meta = read.open_table(META_U64)?; + let newest = shares.last()?.map(|(key, _)| key.value()); + let Some(newest) = newest else { + return Ok(Vec::new()); + }; + let pruned_below = meta_u64(&meta, M_PRUNED_BELOW)?; + let window = collect_window(&shares, pruned_below, newest, self.config.window_work)?; + Ok(window.into_weights()) + } + + /// Sequence number and work of the credited share with this header hash. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn share_by_hash(&self, header_hash: &Hash32) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let hashes = read.open_table(SHARE_HASHES)?; + let entry = match hashes.get(header_hash.as_slice())? { + Some(guard) => Some(decode_hash_entry(guard.value())?), + None => None, + }; + Ok(entry.map(|(seq, work, _)| (seq, work))) + } + + /// Number of shares currently stored (pruned shares are not counted). + /// + /// # Errors + /// Returns a storage error. + pub fn share_count(&self) -> Result { + let read = self.db.begin_read()?; + read.open_table(SHARES)?.len() + } + + // ----------------------------------------------------------------------------------------- + // Found blocks and rounds + // ----------------------------------------------------------------------------------------- + + /// Record a found block: credit its share and snapshot the PPLNS window, in one transaction. + /// The round starts Pending; no money exists until [`Ledger::reconcile_rounds`] observes the + /// block on the active chain. Idempotent on the block hash. + /// + /// # Errors + /// Returns [`LedgerError::BlockConflict`] if the block is already recorded with a different + /// claim, [`LedgerError::WindowPruned`], or any share-crediting error. + pub fn record_found_block(&self, found: &BlockFound) -> Result { + found.share.validate()?; + let block_hash = found.share.header_hash; + let txn = self.db.begin_write()?; + let (recorded, newly) = { + let tables = Tables::open(&txn)?; + let existing = round_id_for_block(&tables.round_by_block, &block_hash)?; + if let Some(id) = existing { + let round = load_round(&tables.rounds, id)? + .ok_or_else(|| LedgerError::Corrupt("round index points at no round".into()))?; + if round.height != found.height + || round.coinbase_txid != found.coinbase_txid + || round.claimed_coinbase_value_sat != found.claimed_coinbase_value_sat + { + return Err(LedgerError::BlockConflict); + } + let seq = round.solving_seq; + return Ok(BlockRecorded { + round, + share: ShareReceipt::Duplicate { seq }, + newly_recorded: false, + }); + } + let share_receipt = credit_share(&tables, &found.share)?; + let solving_seq = share_receipt.seq(); + let pruned_below = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + let window = collect_window( + &tables.shares, + pruned_below, + solving_seq, + self.config.window_work, + )?; + let window_total = window.total(); + let weights = window.into_weights(); + let id = bump(&tables.meta_u64, M_NEXT_ROUND, 1)?; + let round = Round { + id, + block_hash, + height: found.height, + solving_seq, + coinbase_txid: found.coinbase_txid, + claimed_coinbase_value_sat: found.claimed_coinbase_value_sat, + window_work_total: window_total, + reward_fixed: false, + gross_sat: 0, + pool_fee_sat: 0, + net_sat: 0, + state: RoundState::Pending, + }; + store_round(&tables.rounds, &round)?; + tables.round_by_block.insert(block_hash.as_slice(), id)?; + for (account, weight) in &weights { + tables + .round_weights + .insert(round_key(id, *account).as_slice(), *weight)?; + } + tables.open_rounds.insert(id, ())?; + append_audit( + &tables, + AuditKind::RoundRecorded, + id, + &block_hash, + &ZERO_HASH, + [u64::from(found.height), low64(window_total), solving_seq], + )?; + ( + BlockRecorded { + round, + share: share_receipt, + newly_recorded: true, + }, + true, + ) + }; + if newly { + self.commit(txn, OP_RECORD_BLOCK)?; + } + Ok(recorded) + } + + /// Advance every monitored round from authoritative chain observations: fix the reward from + /// the observed coinbase, mature it, orphan it on a reorg, and reverse credits when a matured + /// block is displaced. Safe to call as often as desired. + /// + /// # Errors + /// Returns a chain-observer error (nothing is changed) or a storage/overflow error. + pub fn reconcile_rounds( + &self, + chain: &dyn ChainView, + ) -> Result { + let open = self.open_round_list()?; + let mut report = RoundReconcileReport::default(); + if open.is_empty() { + return Ok(report); + } + let tip = chain.tip_height()?; + let mut observations = Vec::with_capacity(open.len()); + for round in &open { + let canon = match chain.block_location(&round.block_hash, round.height)? { + crate::BlockLocation::Canonical { height } if height == round.height => { + Canon::Canonical + } + crate::BlockLocation::Canonical { .. } | crate::BlockLocation::Side => { + Canon::Displaced + } + crate::BlockLocation::Unknown => Canon::Unknown, + }; + let coinbase = if canon == Canon::Canonical && !round.reward_fixed { + chain.coinbase_of(&round.block_hash)? + } else { + None + }; + observations.push(RoundObservation { + id: round.id, + block_hash: round.block_hash, + canon, + coinbase, + }); + } + let txn = self.db.begin_write()?; + let mut dirty = false; + { + let tables = Tables::open(&txn)?; + for observation in &observations { + let outcome = apply_round_observation(&tables, &self.config, observation, tip)?; + dirty |= outcome.dirty; + if let Some(transition) = outcome.transition { + report.transitions.push(transition); + } + if let Some(reason) = outcome.anomaly { + report.anomalies.push(RoundAnomaly { + round: observation.id, + reason, + }); + } + } + } + if dirty { + self.commit(txn, OP_RECONCILE_ROUNDS)?; + } + Ok(report) + } + + fn open_round_list(&self) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let open = read.open_table(OPEN_ROUNDS)?; + let rounds = read.open_table(ROUNDS)?; + let mut list = Vec::new(); + for item in open.rows_iter()? { + let (key, _) = item?; + if let Some(round) = load_round(&rounds, key.value())? { + list.push(round); + } + } + Ok(list) + } + + /// The round with this id. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn round(&self, id: u64) -> Result, LedgerError> { + let read = self.db.begin_read()?; + load_round(&read.open_table(ROUNDS)?, id) + } + + /// The round recorded for this block hash. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn round_for_block(&self, block_hash: &Hash32) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let index = read.open_table(ROUND_BY_BLOCK)?; + match round_id_for_block(&index, block_hash)? { + Some(id) => load_round(&read.open_table(ROUNDS)?, id), + None => Ok(None), + } + } + + /// Stored allocations of a round (empty until the reward is fixed). + /// + /// # Errors + /// Returns a storage error. + pub fn round_allocations(&self, id: u64) -> Result, LedgerError> { + let read = self.db.begin_read()?; + load_allocs(&read.open_table(ROUND_ALLOCS)?, id) + } + + /// The PPLNS window weights snapshotted for a round. + /// + /// # Errors + /// Returns a storage error. + pub fn round_weights(&self, id: u64) -> Result, LedgerError> { + let read = self.db.begin_read()?; + load_weights(&read.open_table(ROUND_WEIGHTS)?, id) + } + + // ----------------------------------------------------------------------------------------- + // Accounts + // ----------------------------------------------------------------------------------------- + + /// Balance of an account (all zero if unknown). + /// + /// # Errors + /// Returns a storage or codec error. + pub fn balance(&self, account: MinerId) -> Result { + let read = self.db.begin_read()?; + load_balance(&read.open_table(BALANCES)?, account) + } + + /// The payout script of an account, if one is registered. + /// + /// # Errors + /// Returns a storage error. + pub fn payout_script(&self, account: MinerId) -> Result>, LedgerError> { + let read = self.db.begin_read()?; + let scripts = read.open_table(SCRIPTS)?; + Ok(scripts.get(account.0)?.map(|guard| guard.value().to_vec())) + } + + /// Register or change the payout script of an account. Batches already prepared keep the + /// script they snapshotted. Authenticating the account owner is the pool server's job. + /// + /// # Errors + /// Returns [`LedgerError::InvalidInput`] for an empty, oversized or `OP_RETURN` script. + pub fn set_payout_script(&self, account: MinerId, script: &[u8]) -> Result<(), LedgerError> { + crate::validate_script(script)?; + let txn = self.db.begin_write()?; + let changed = { + let tables = Tables::open(&txn)?; + let same = match tables.scripts.get(account.0)? { + Some(guard) => guard.value() == script, + None => false, + }; + if same { + false + } else { + tables.scripts.insert(account.0, script)?; + append_audit( + &tables, + AuditKind::PayoutScriptSet, + account.0, + &crate::sha256(script), + &ZERO_HASH, + [0, 0, 0], + )?; + true + } + }; + if changed { + self.commit(txn, OP_SET_SCRIPT)?; + } + Ok(()) + } + + // ----------------------------------------------------------------------------------------- + // Payout batches + // ----------------------------------------------------------------------------------------- + + /// Reserve available balances into a new immutable payout batch (or return the batch already + /// created for `idempotency_key`). Returns `None` when no account qualifies. + /// + /// # Errors + /// Returns [`LedgerError::InvalidInput`] for a policy below the dust limit or out of range. + pub fn prepare_payout_batch( + &self, + idempotency_key: Hash32, + policy: PayoutPolicy, + ) -> Result, LedgerError> { + if policy.min_payout_sat < self.config.dust_limit_sat + || policy.min_payout_sat > MAX_MONEY_SAT + { + return Err(LedgerError::InvalidInput( + "min_payout_sat below the dust limit", + )); + } + if policy.max_outputs == 0 || policy.max_outputs > MAX_BATCH_OUTPUTS { + return Err(LedgerError::InvalidInput("max_outputs out of range")); + } + let txn = self.db.begin_write()?; + let created = { + let tables = Tables::open(&txn)?; + let existing = match tables.batch_idem.get(idempotency_key.as_slice())? { + Some(guard) => Some(hash_from(guard.value())?), + None => None, + }; + if let Some(id) = existing { + let batch = load_batch(&tables.batches, &id)?.ok_or_else(|| { + LedgerError::Corrupt("idempotency index points at no batch".into()) + })?; + return Ok(Some(batch)); + } + let mut picks: Vec = Vec::new(); + for item in tables.balances.rows_iter()? { + let (key, value) = item?; + let balance = decode_balance(value.value())?; + if balance.available_sat < policy.min_payout_sat { + continue; + } + let account = MinerId(key.value()); + let script = match tables.scripts.get(account.0)? { + Some(guard) => guard.value().to_vec(), + None => continue, + }; + picks.push(PayoutOutput { + account, + script_pubkey: script, + amount_sat: balance.available_sat, + }); + if picks.len() == policy.max_outputs { + break; + } + } + if picks.is_empty() { + return Ok(None); + } + let total = crate::check_outputs(&picks)?; + let id = compute_batch_id(&idempotency_key, &picks)?; + let seq = bump(&tables.meta_u64, M_NEXT_BATCH, 1)?; + for pick in &picks { + let mut balance = load_balance(&tables.balances, pick.account)?; + balance.available_sat = balance + .available_sat + .checked_sub(pick.amount_sat) + .ok_or(LedgerError::Overflow("reserve exceeds available"))?; + balance.reserved_sat = balance + .reserved_sat + .checked_add(pick.amount_sat) + .ok_or(LedgerError::Overflow("reserved balance"))?; + store_balance(&tables.balances, pick.account, &balance)?; + } + let batch = PayoutBatch { + id, + seq, + idempotency_key, + state: BatchState::Prepared, + rev: 1, + outputs: picks, + total_sat: total, + transactions: Vec::new(), + broadcast_txid: None, + confirmation: None, + closure: None, + }; + store_batch(&tables.batches, &batch)?; + tables.active_batches.insert(seq, id.as_slice())?; + tables + .batch_idem + .insert(idempotency_key.as_slice(), id.as_slice())?; + let outputs = + u64::try_from(batch.outputs.len()).map_err(|_| LedgerError::Overflow("count"))?; + append_audit( + &tables, + AuditKind::BatchPrepared, + seq, + &id, + &ZERO_HASH, + [total, outputs, 0], + )?; + batch + }; + self.commit(txn, OP_PREPARE_PAYOUT)?; + Ok(Some(created)) + } + + /// The batch with this id. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn batch(&self, id: &Hash32) -> Result, LedgerError> { + let read = self.db.begin_read()?; + load_batch(&read.open_table(BATCHES)?, id) + } + + /// The batch created for an idempotency key. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn batch_for_idempotency_key( + &self, + key: &Hash32, + ) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let index = read.open_table(BATCH_IDEM)?; + let id = match index.get(key.as_slice())? { + Some(guard) => Some(hash_from(guard.value())?), + None => None, + }; + match id { + Some(id) => load_batch(&read.open_table(BATCHES)?, &id), + None => Ok(None), + } + } + + /// Batches still reserving funds or inside the reorg watch window, oldest first. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn active_batches(&self) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let active = read.open_table(ACTIVE_BATCHES)?; + let batches = read.open_table(BATCHES)?; + let mut list = Vec::new(); + for item in active.rows_iter()? { + let (_, value) = item?; + let id = hash_from(value.value())?; + if let Some(batch) = load_batch(&batches, &id)? { + list.push(batch); + } + } + Ok(list) + } + + /// The canonical unsigned instructions for an open batch. + /// + /// # Errors + /// Returns [`LedgerError::UnknownBatch`] or [`LedgerError::IllegalTransition`] for a batch + /// that no longer awaits payment. + pub fn request_for(&self, id: &Hash32) -> Result { + let batch = self.batch(id)?.ok_or(LedgerError::UnknownBatch(*id))?; + if !batch.state.holds_reservation() { + return Err(LedgerError::IllegalTransition { + batch: *id, + from: batch.state, + action: "export a payout request", + }); + } + Ok(PayoutRequest { + batch_id: batch.id, + outputs: batch.outputs, + total_sat: batch.total_sat, + allowed_change_scripts: self.config.change_scripts.clone(), + }) + } + + /// Ask `signer` to sign the batch and record its receipt. + /// + /// # Errors + /// Returns the signer's error, or any [`Ledger::record_signed`] error. + pub fn sign_via( + &self, + id: &Hash32, + signer: &dyn ExternalSigner, + ) -> Result { + let request = self.request_for(id)?; + let receipt = signer.sign(&request)?; + if receipt.batch_id != *id { + return Err(LedgerError::InvalidInput( + "signer answered for a different batch", + )); + } + self.record_signed(&receipt) + } + + /// Record a signed transaction for a batch after verifying that it pays exactly the batch + /// outputs (plus at most one allowed change output) and that its recomputed txid equals the + /// claimed one. A replacement transaction must share an input with every earlier signed + /// transaction of the batch so at most one of them can ever confirm. Idempotent per txid. + /// + /// # Errors + /// Returns [`LedgerError::Receipt`] when the transaction is refused, + /// [`LedgerError::UnknownBatch`] or [`LedgerError::IllegalTransition`]. + pub fn record_signed(&self, receipt: &PayoutReceipt) -> Result { + let parsed = parse_transaction(&receipt.raw_tx).map_err(ReceiptError::from)?; + if parsed.txid != receipt.txid { + return Err(ReceiptError::TxidMismatch.into()); + } + let txn = self.db.begin_write()?; + let batch = { + let tables = Tables::open(&txn)?; + let mut batch = load_batch(&tables.batches, &receipt.batch_id)? + .ok_or(LedgerError::UnknownBatch(receipt.batch_id))?; + if batch.transactions.iter().any(|tx| tx.txid == parsed.txid) { + return Ok(batch); + } + if !batch.state.holds_reservation() { + return Err(LedgerError::IllegalTransition { + batch: batch.id, + from: batch.state, + action: "record a signed transaction", + }); + } + if batch.transactions.len() >= MAX_SIGNED_TXS_PER_BATCH { + return Err(LedgerError::InvalidInput( + "too many signed transactions for one batch", + )); + } + if tables.batch_txids.get(parsed.txid.as_slice())?.is_some() { + return Err(ReceiptError::TxidInOtherBatch.into()); + } + verify_payout_transaction(&batch.outputs, &self.config.change_scripts, &parsed)?; + let new_inputs: BTreeSet = parsed.inputs.iter().copied().collect(); + for existing in &batch.transactions { + if !existing + .inputs + .iter() + .any(|input| new_inputs.contains(input)) + { + return Err(ReceiptError::NotConflicting { + existing_txid: existing.txid, + } + .into()); + } + } + let input_count = + u64::try_from(parsed.inputs.len()).map_err(|_| LedgerError::Overflow("count"))?; + batch.transactions.push(SignedTx { + txid: parsed.txid, + inputs: parsed.inputs.clone(), + }); + if batch.state == BatchState::Prepared { + batch.state = BatchState::ExternallySigned; + } + batch.rev = next_rev(batch.rev)?; + store_batch(&tables.batches, &batch)?; + tables + .batch_txids + .insert(parsed.txid.as_slice(), batch.id.as_slice())?; + append_audit( + &tables, + AuditKind::BatchSigned, + batch.seq, + &batch.id, + &parsed.txid, + [input_count, 0, 0], + )?; + batch + }; + self.commit(txn, OP_RECORD_SIGNED)?; + Ok(batch) + } + + /// Mark a batch Broadcast after the chain observer sees one of its signed transactions in the + /// mempool or in a block. Nothing is inferred from the signer or from a "sent" claim. + /// + /// # Errors + /// Returns [`LedgerError::NotObserved`] if the observer does not know the transaction. + pub fn record_broadcast( + &self, + batch_id: &Hash32, + txid: &Hash32, + chain: &dyn ChainView, + ) -> Result { + let current = self + .batch(batch_id)? + .ok_or(LedgerError::UnknownBatch(*batch_id))?; + if !current.transactions.iter().any(|tx| tx.txid == *txid) { + return Err(LedgerError::InvalidInput( + "txid is not a signed transaction of this batch", + )); + } + if chain.tx_status(txid)? == TxStatus::Unknown { + return Err(LedgerError::NotObserved(*txid)); + } + let txn = self.db.begin_write()?; + let (batch, changed) = { + let tables = Tables::open(&txn)?; + let mut batch = load_batch(&tables.batches, batch_id)? + .ok_or(LedgerError::UnknownBatch(*batch_id))?; + if !batch.transactions.iter().any(|tx| tx.txid == *txid) { + return Err(LedgerError::InvalidInput( + "txid is not a signed transaction of this batch", + )); + } + let changed = match batch.state { + BatchState::ExternallySigned => { + batch.state = BatchState::Broadcast; + batch.broadcast_txid = Some(*txid); + true + } + BatchState::Broadcast => { + if batch.broadcast_txid == Some(*txid) { + false + } else { + batch.broadcast_txid = Some(*txid); + true + } + } + BatchState::Confirmed => false, + other => { + return Err(LedgerError::IllegalTransition { + batch: batch.id, + from: other, + action: "record a broadcast", + }); + } + }; + if changed { + batch.rev = next_rev(batch.rev)?; + store_batch(&tables.batches, &batch)?; + append_audit( + &tables, + AuditKind::BatchBroadcast, + batch.seq, + &batch.id, + txid, + [0, 0, 0], + )?; + } + (batch, changed) + }; + if changed { + self.commit(txn, OP_RECORD_BROADCAST)?; + } + Ok(batch) + } + + /// Cancel a Prepared batch and release its reservation. The ledger cannot verify the + /// operator's attestation about the signer; it is recorded in the audit trail. A batch with a + /// signed transaction can never be cancelled, only failed by chain evidence. + /// + /// # Errors + /// Returns [`LedgerError::IllegalTransition`] unless the batch is Prepared (or already + /// Cancelled with the same basis). + pub fn cancel_prepared( + &self, + id: &Hash32, + basis: CancelBasis, + ) -> Result { + let txn = self.db.begin_write()?; + let (batch, changed) = { + let tables = Tables::open(&txn)?; + let mut batch = + load_batch(&tables.batches, id)?.ok_or(LedgerError::UnknownBatch(*id))?; + match batch.state { + BatchState::Prepared => {} + BatchState::Cancelled if batch.closure == Some(Closure::Cancelled(basis)) => { + return Ok(batch); + } + other => { + return Err(LedgerError::IllegalTransition { + batch: batch.id, + from: other, + action: "cancel", + }); + } + } + release_reservation(&tables, &batch)?; + batch.state = BatchState::Cancelled; + batch.closure = Some(Closure::Cancelled(basis)); + batch.rev = next_rev(batch.rev)?; + store_batch(&tables.batches, &batch)?; + tables.active_batches.remove(batch.seq)?; + append_audit( + &tables, + AuditKind::BatchCancelled, + batch.seq, + &batch.id, + &ZERO_HASH, + [u64::from(basis.code()), batch.total_sat, 0], + )?; + (batch, true) + }; + if changed { + self.commit(txn, OP_CANCEL_PREPARED)?; + } + Ok(batch) + } + + /// Advance every active batch from authoritative chain observations: Broadcast/Signed to + /// Confirmed at `payout_confirmations` depth, Confirmed back to Broadcast when its block is + /// reorged out, and Failed (reservation released) only when every signed transaction was + /// conclusively double-spent at `abandon_confirmations` depth. + /// + /// # Errors + /// Returns a chain-observer error (nothing is changed) or a storage/overflow error. + pub fn reconcile_payouts( + &self, + chain: &dyn ChainView, + ) -> Result, LedgerError> { + let active = self.active_batches()?; + let mut observations = Vec::new(); + let mut tip: Option = None; + for batch in &active { + if !batch.state_is_observable() { + continue; + } + let tip_height = if let Some(height) = tip { + height + } else { + let height = chain.tip_height()?; + tip = Some(height); + height + }; + observations.push(observe_batch(chain, &self.config, batch, tip_height)?); + } + let mut transitions = Vec::new(); + if observations.is_empty() { + return Ok(transitions); + } + let tip = tip.unwrap_or(0); + let txn = self.db.begin_write()?; + let mut dirty = false; + { + let tables = Tables::open(&txn)?; + for observation in &observations { + let Some(mut batch) = load_batch(&tables.batches, &observation.id)? else { + continue; + }; + if batch.rev != observation.rev { + continue; + } + dirty |= apply_batch_decision( + &tables, + &mut batch, + observation, + &self.config, + &mut transitions, + )?; + if batch.state == BatchState::Confirmed + && let Some(confirmation) = batch.confirmation + && u64::from(tip) + >= u64::from(confirmation.height) + u64::from(self.config.reorg_watch_depth) + { + tables.active_batches.remove(batch.seq)?; + dirty = true; + } + } + } + if dirty { + self.commit(txn, OP_RECONCILE_PAYOUTS)?; + } + Ok(transitions) + } + + // ----------------------------------------------------------------------------------------- + // Audit and integrity + // ----------------------------------------------------------------------------------------- + + /// Audit records with `seq >= from_seq`, at most `limit`. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn audit_records( + &self, + from_seq: u64, + limit: usize, + ) -> Result, LedgerError> { + let read = self.db.begin_read()?; + let audit = read.open_table(AUDIT)?; + let end = from_seq.saturating_add(u64::try_from(limit).unwrap_or(u64::MAX)); + let mut records = Vec::new(); + for item in audit.range(from_seq..end)?.take(limit) { + let (key, value) = item?; + records.push(decode_audit(key.value(), value.value())?); + } + Ok(records) + } + + /// Return up to `limit` newest audit records in ascending sequence order. + /// + /// Uses the audit counter and rows from one coherent snapshot; does not verify the full chain. + /// + /// # Errors + /// Returns a storage or codec error. + pub fn audit_tail(&self, limit: usize) -> Result, LedgerError> { + if limit == 0 { + return Ok(Vec::new()); + } + let read = self.db.begin_read()?; + let counters = read.open_table(META_U64)?; + let next = meta_u64(&counters, M_NEXT_AUDIT)?; + let count = u64::try_from(limit).unwrap_or(u64::MAX); + let start = next.saturating_sub(count); + let audit = read.open_table(AUDIT)?; + let mut records = Vec::new(); + for item in audit.range(start..next)?.take(limit) { + let (key, value) = item?; + records.push(decode_audit(key.value(), value.value())?); + } + Ok(records) + } + + /// Recompute the whole audit hash chain and compare it with the stored head. + /// + /// # Errors + /// Returns [`LedgerError::Invariant`] on any gap, mismatch or truncation. + pub fn verify_audit_chain(&self) -> Result { + let read = self.db.begin_read()?; + verify_audit_tables( + &read.open_table(AUDIT)?, + &read.open_table(META_U64)?, + &read.open_table(META_BYTES)?, + ) + } + + /// Verify the conservation invariants over the whole ledger: + /// per account `available + reserved + paid - deficit == credited allocations of Matured + /// rounds`, `reserved == outputs of batches holding a reservation`, `paid == outputs of + /// Confirmed batches`; per round `fee + net == gross` and `sum(allocations) == net`; and the + /// audit chain. + /// + /// # Errors + /// Returns [`LedgerError::Invariant`] describing the first violation. + pub fn check_invariants(&self) -> Result { + let read = self.db.begin_read()?; + verify_audit_tables( + &read.open_table(AUDIT)?, + &read.open_table(META_U64)?, + &read.open_table(META_BYTES)?, + )?; + let rounds = read.open_table(ROUNDS)?; + let allocs = read.open_table(ROUND_ALLOCS)?; + let batches = read.open_table(BATCHES)?; + let balances = read.open_table(BALANCES)?; + let mut report = InvariantReport::default(); + let mut credited: BTreeMap = BTreeMap::new(); + for item in rounds.rows_iter()? { + let (_, value) = item?; + let round = decode_round(value.value())?; + report.rounds += 1; + let list = load_allocs(&allocs, round.id)?; + let mut sum: u64 = 0; + for (_, amount) in &list { + sum = sum + .checked_add(*amount) + .ok_or(LedgerError::Overflow("allocation sum"))?; + } + if round.reward_fixed { + if round.gross_sat.checked_sub(round.pool_fee_sat) != Some(round.net_sat) { + return Err(LedgerError::Invariant(format!( + "round {} fee + net != gross", + round.id + ))); + } + if sum != round.net_sat { + return Err(LedgerError::Invariant(format!( + "round {} allocations differ from net", + round.id + ))); + } + } else if !list.is_empty() || round.state == RoundState::Matured { + return Err(LedgerError::Invariant(format!( + "round {} has allocations without a fixed reward", + round.id + ))); + } + if round.state == RoundState::Matured { + for (account, amount) in &list { + let entry = credited.entry(account.0).or_insert(0); + *entry += u128::from(*amount); + report.credited_sat += u128::from(*amount); + } + } + } + let mut reserved: BTreeMap = BTreeMap::new(); + let mut paid: BTreeMap = BTreeMap::new(); + for item in batches.rows_iter()? { + let (_, value) = item?; + let batch = decode_batch(value.value())?; + report.batches += 1; + let mut sum: u64 = 0; + for output in &batch.outputs { + sum = sum + .checked_add(output.amount_sat) + .ok_or(LedgerError::Overflow("batch total"))?; + } + if sum != batch.total_sat { + return Err(LedgerError::Invariant( + "batch total differs from outputs".into(), + )); + } + if batch.state == BatchState::Confirmed && batch.confirmation.is_none() { + return Err(LedgerError::Invariant( + "confirmed batch without confirmation".into(), + )); + } + let target = if batch.state.holds_reservation() { + Some(&mut reserved) + } else if batch.state == BatchState::Confirmed { + Some(&mut paid) + } else { + None + }; + if let Some(target) = target { + for output in &batch.outputs { + *target.entry(output.account.0).or_insert(0) += u128::from(output.amount_sat); + } + } + } + verify_account_conservation(&balances, &credited, &reserved, &paid, &mut report)?; + Ok(report) + } +} + +fn verify_audit_tables( + audit: &Table<'_, u64, &'static [u8]>, + counters: &Table<'_, &'static str, u64>, + meta_bytes: &Table<'_, &'static str, &'static [u8]>, +) -> Result { + let mut previous = ZERO_HASH; + let mut expected_seq = 0u64; + for item in audit.rows_iter()? { + let (key, value) = item?; + let seq = key.value(); + if seq != expected_seq { + return Err(LedgerError::Invariant(format!( + "audit gap at {expected_seq}" + ))); + } + let bytes = value.value(); + if bytes.len() != AUDIT_BODY_LEN + 32 { + return Err(LedgerError::Invariant(format!( + "audit record {seq} has a bad length" + ))); + } + let (body, stored) = bytes.split_at(AUDIT_BODY_LEN); + let chain = audit_chain_hash(&previous, seq, body); + if stored != chain.as_slice() { + return Err(LedgerError::Invariant(format!( + "audit chain broken at {seq}" + ))); + } + previous = chain; + expected_seq = expected_seq + .checked_add(1) + .ok_or(LedgerError::Overflow("audit seq"))?; + } + if meta_u64(counters, M_NEXT_AUDIT)? != expected_seq { + return Err(LedgerError::Invariant( + "audit counter differs from records".into(), + )); + } + let head = match meta_bytes.get(M_AUDIT_HEAD)? { + Some(guard) => hash_from(guard.value())?, + None => ZERO_HASH, + }; + if head != previous { + return Err(LedgerError::Invariant( + "audit head differs from records".into(), + )); + } + Ok(expected_seq) +} + +fn verify_account_conservation( + balances: &Table<'_, u64, &'static [u8]>, + credited: &BTreeMap, + reserved: &BTreeMap, + paid: &BTreeMap, + report: &mut InvariantReport, +) -> Result<(), LedgerError> { + let mut accounts = BTreeSet::new(); + accounts.extend(credited.keys().copied()); + accounts.extend(reserved.keys().copied()); + accounts.extend(paid.keys().copied()); + for item in balances.rows_iter()? { + let (key, _) = item?; + accounts.insert(key.value()); + } + for account in &accounts { + let balance = load_balance(balances, MinerId(*account))?; + let want_credited = credited.get(account).copied().unwrap_or(0); + let want_reserved = reserved.get(account).copied().unwrap_or(0); + let want_paid = paid.get(account).copied().unwrap_or(0); + if u128::from(balance.reserved_sat) != want_reserved { + return Err(LedgerError::Invariant(format!( + "account {account} reserved differs from open batches" + ))); + } + if u128::from(balance.paid_sat) != want_paid { + return Err(LedgerError::Invariant(format!( + "account {account} paid differs from confirmed batches" + ))); + } + let positive = u128::from(balance.available_sat) + + u128::from(balance.reserved_sat) + + u128::from(balance.paid_sat); + if positive.checked_sub(u128::from(balance.deficit_sat)) != Some(want_credited) { + return Err(LedgerError::Invariant(format!( + "account {account} balance does not equal credited allocations" + ))); + } + report.available_sat += u128::from(balance.available_sat); + report.reserved_sat += u128::from(balance.reserved_sat); + report.paid_sat += u128::from(balance.paid_sat); + report.deficit_sat += u128::from(balance.deficit_sat); + } + report.accounts = accounts.len(); + Ok(()) +} + +impl PayoutBatch { + /// True for states a chain observer can still move. + fn state_is_observable(&self) -> bool { + matches!( + self.state, + BatchState::ExternallySigned | BatchState::Broadcast | BatchState::Confirmed + ) + } +} + +// --------------------------------------------------------------------------------------------- +// Share crediting and the PPLNS window +// --------------------------------------------------------------------------------------------- + +fn credit_share(tables: &Tables<'_>, share: &ShareCredit) -> Result { + if tables.retired_jobs.get(share.job)?.is_some() { + return Err(LedgerError::JobRetired(share.job)); + } + let tuple = share.tuple_key(); + let existing = match tables.share_hashes.get(share.header_hash.as_slice())? { + Some(guard) => Some(decode_hash_entry(guard.value())?), + None => None, + }; + if let Some((seq, work, stored_tuple)) = existing { + if work != share.work || stored_tuple != tuple { + return Err(LedgerError::ShareConflict { seq }); + } + return Ok(ShareReceipt::Duplicate { seq }); + } + let owner = match tables.share_tuples.get(tuple.as_slice())? { + Some(guard) => Some(hash_from(guard.value())?), + None => None, + }; + if let Some(owner_hash) = owner { + let seq = match tables.share_hashes.get(owner_hash.as_slice())? { + Some(guard) => decode_hash_entry(guard.value())?.0, + None => { + return Err(LedgerError::Corrupt( + "share tuple index points at no share".into(), + )); + } + }; + return Err(LedgerError::ShareIdentityConflict { seq }); + } + let seq = bump(&tables.meta_u64, M_NEXT_SHARE, 0)?; + tables + .shares + .insert(seq, encode_share(share.account, share.work)?.as_slice())?; + tables.share_hashes.insert( + share.header_hash.as_slice(), + encode_hash_entry(seq, share.work, &tuple)?.as_slice(), + )?; + tables + .share_tuples + .insert(tuple.as_slice(), share.header_hash.as_slice())?; + Ok(ShareReceipt::Credited { seq }) +} + +/// Walk shares newest first from `end_seq`, filling a window of `target` work. +fn collect_window( + shares: &Table<'_, u64, &'static [u8]>, + pruned_below: u64, + end_seq: u64, + target: u128, +) -> Result { + let mut window = WindowAccumulator::new(target); + for item in shares.range(..=end_seq)?.rev() { + let (_, value) = item?; + let (account, work) = decode_share(value.value())?; + window.push_older(account, work)?; + if window.is_full() { + return Ok(window); + } + } + if pruned_below > 0 { + return Err(LedgerError::WindowPruned); + } + Ok(window) +} + +// --------------------------------------------------------------------------------------------- +// Rounds +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Canon { + Canonical, + Displaced, + Unknown, +} + +fn apply_batch_decision( + tables: &Tables<'_>, + batch: &mut PayoutBatch, + observation: &BatchObservation, + config: &PoolConfig, + transitions: &mut Vec, +) -> Result { + let from = batch.state; + let (kind, hash, fields) = match decide(batch, observation, config) { + Decision::None => return Ok(false), + Decision::Confirm(confirmation) => { + settle_paid(tables, batch)?; + batch.state = BatchState::Confirmed; + batch.broadcast_txid = Some(confirmation.txid); + batch.confirmation = Some(confirmation); + ( + AuditKind::BatchConfirmed, + confirmation.txid, + [u64::from(confirmation.height), 0, 0], + ) + } + Decision::Reconfirm(confirmation) => { + batch.broadcast_txid = Some(confirmation.txid); + batch.confirmation = Some(confirmation); + ( + AuditKind::BatchConfirmed, + confirmation.txid, + [u64::from(confirmation.height), 1, 0], + ) + } + Decision::Revert => { + unsettle_paid(tables, batch)?; + batch.state = BatchState::Broadcast; + batch.confirmation = None; + ( + AuditKind::BatchReverted, + ZERO_HASH, + [ + u64::from(BatchState::Confirmed.code()), + u64::from(BatchState::Broadcast.code()), + 0, + ], + ) + } + Decision::Fail(conflicting_txid) => { + release_reservation(tables, batch)?; + batch.state = BatchState::Failed; + batch.closure = Some(Closure::Failed { conflicting_txid }); + tables.active_batches.remove(batch.seq)?; + ( + AuditKind::BatchFailed, + conflicting_txid, + [batch.total_sat, 0, 0], + ) + } + }; + batch.rev = next_rev(batch.rev)?; + store_batch(&tables.batches, batch)?; + append_audit(tables, kind, batch.seq, &batch.id, &hash, fields)?; + transitions.push(BatchTransition { + batch_id: batch.id, + from, + to: batch.state, + }); + Ok(true) +} + +struct RoundObservation { + id: u64, + block_hash: Hash32, + canon: Canon, + coinbase: Option, +} + +struct RoundOutcome { + transition: Option, + anomaly: Option<&'static str>, + dirty: bool, +} + +impl RoundOutcome { + fn unchanged() -> Self { + Self { + transition: None, + anomaly: None, + dirty: false, + } + } +} + +fn confirmations(tip: u32, height: u32) -> u64 { + tip.checked_sub(height) + .map_or(0, |depth| u64::from(depth) + 1) +} + +fn apply_round_observation( + tables: &Tables<'_>, + config: &PoolConfig, + observation: &RoundObservation, + tip: u32, +) -> Result { + let Some(mut round) = load_round(&tables.rounds, observation.id)? else { + return Ok(RoundOutcome::unchanged()); + }; + if round.block_hash != observation.block_hash { + return Ok(RoundOutcome::unchanged()); + } + let from = round.state; + let was_fixed = round.reward_fixed; + let depth = confirmations(tip, round.height); + let mut outcome = RoundOutcome::unchanged(); + let mut credited_sat = 0; + let mut reversed_sat = 0; + let mut deficit_created_sat = 0; + match (from, observation.canon) { + (RoundState::Matured, Canon::Displaced) => { + let (reversed, deficit) = reverse_round_credits(tables, round.id)?; + reversed_sat = reversed; + deficit_created_sat = deficit; + round.state = RoundState::Orphaned; + append_audit( + tables, + AuditKind::RoundOrphaned, + round.id, + &round.block_hash, + &ZERO_HASH, + [reversed, deficit, 1], + )?; + } + (RoundState::Matured, _) | (_, Canon::Unknown) => {} + (_, Canon::Displaced) => { + if from != RoundState::Orphaned { + round.state = RoundState::Orphaned; + append_audit( + tables, + AuditKind::RoundOrphaned, + round.id, + &round.block_hash, + &ZERO_HASH, + [0, 0, 0], + )?; + } + } + (_, Canon::Canonical) => { + match apply_canonical_round(tables, config, &mut round, observation, depth)? { + CanonicalApply::Applied(credited) => credited_sat = credited, + CanonicalApply::Anomaly(reason) => { + outcome.anomaly = Some(reason); + return Ok(outcome); + } + CanonicalApply::MissingCoinbase => return Ok(RoundOutcome::unchanged()), + } + } + } + let settled = u64::from(tip) >= u64::from(round.height) + u64::from(config.reorg_watch_depth) + && (observation.canon != Canon::Canonical || round.reward_fixed); + if settled { + if matches!(round.state, RoundState::Pending | RoundState::Immature) { + round.state = RoundState::Orphaned; + append_audit( + tables, + AuditKind::RoundOrphaned, + round.id, + &round.block_hash, + &ZERO_HASH, + [0, 0, 0], + )?; + } + tables.open_rounds.remove(round.id)?; + outcome.dirty = true; + } + if round.state != from || settled || round.reward_fixed != was_fixed { + store_round(&tables.rounds, &round)?; + outcome.dirty = true; + } + if round.state != from { + outcome.transition = Some(RoundTransition { + round: round.id, + from, + to: round.state, + credited_sat, + reversed_sat, + deficit_created_sat, + }); + } + Ok(outcome) +} + +/// Read the actual coinbase value from the chain, fix fee, net and allocations. Returns a reason +/// when the chain contradicts the job owner's claim (the round is left unchanged). +enum CanonicalApply { + MissingCoinbase, + Anomaly(&'static str), + Applied(u64), +} + +fn apply_canonical_round( + tables: &Tables<'_>, + config: &PoolConfig, + round: &mut Round, + observation: &RoundObservation, + depth: u64, +) -> Result { + let from = round.state; + if !round.reward_fixed { + let Some(coinbase) = observation.coinbase.as_ref() else { + return Ok(CanonicalApply::MissingCoinbase); + }; + if let Some(reason) = fix_reward(tables, config, round, coinbase)? { + return Ok(CanonicalApply::Anomaly(reason)); + } + } + if depth >= u64::from(config.coinbase_maturity) { + let credited = credit_round_allocations(tables, round.id)?; + round.state = RoundState::Matured; + append_audit( + tables, + AuditKind::RoundMatured, + round.id, + &round.block_hash, + &ZERO_HASH, + [credited, 0, 0], + )?; + return Ok(CanonicalApply::Applied(credited)); + } + if from != RoundState::Immature { + round.state = RoundState::Immature; + if from == RoundState::Orphaned { + append_audit( + tables, + AuditKind::RoundRevived, + round.id, + &round.block_hash, + &ZERO_HASH, + [0, 0, 0], + )?; + } + } + Ok(CanonicalApply::Applied(0)) +} + +fn fix_reward( + tables: &Tables<'_>, + config: &PoolConfig, + round: &mut Round, + coinbase: &ObservedCoinbase, +) -> Result, LedgerError> { + if coinbase.txid != round.coinbase_txid { + return Ok(Some("observed coinbase txid differs from the claimed txid")); + } + let mut total: u64 = 0; + let mut pool: u64 = 0; + for output in &coinbase.outputs { + let Some(next_total) = total.checked_add(output.value_sat) else { + return Ok(Some("observed coinbase total overflows")); + }; + total = next_total; + if output.script_pubkey == config.pool_coinbase_script { + let Some(next_pool) = pool.checked_add(output.value_sat) else { + return Ok(Some("observed pool coinbase output total overflows")); + }; + pool = next_pool; + } + } + if total > MAX_MONEY_SAT { + return Ok(Some("observed coinbase exceeds the money supply")); + } + if total != round.claimed_coinbase_value_sat { + return Ok(Some( + "observed coinbase total differs from the claimed value", + )); + } + let (fee, net) = split_pool_fee(pool, config.pool_fee_bps)?; + let weights = load_weights(&tables.round_weights, round.id)?; + let allocations = split_reward(net, &weights)?; + for (account, amount) in &allocations { + tables + .round_allocs + .insert(round_key(round.id, *account).as_slice(), *amount)?; + } + round.reward_fixed = true; + round.gross_sat = pool; + round.pool_fee_sat = fee; + round.net_sat = net; + append_audit( + tables, + AuditKind::RewardFixed, + round.id, + &round.block_hash, + &ZERO_HASH, + [pool, fee, net], + )?; + Ok(None) +} + +fn credit_round_allocations(tables: &Tables<'_>, round_id: u64) -> Result { + let allocations = load_allocs(&tables.round_allocs, round_id)?; + let mut total: u64 = 0; + for (account, amount) in allocations { + let mut balance = load_balance(&tables.balances, account)?; + let offset = balance.deficit_sat.min(amount); + balance.deficit_sat -= offset; + balance.available_sat = balance + .available_sat + .checked_add(amount - offset) + .ok_or(LedgerError::Overflow("available balance"))?; + store_balance(&tables.balances, account, &balance)?; + total = total + .checked_add(amount) + .ok_or(LedgerError::Overflow("credited total"))?; + } + Ok(total) +} + +/// Remove a matured round's credits. Returns `(reversed_total, deficit_created)`. +fn reverse_round_credits(tables: &Tables<'_>, round_id: u64) -> Result<(u64, u64), LedgerError> { + let allocations = load_allocs(&tables.round_allocs, round_id)?; + let mut reversed: u64 = 0; + let mut deficit_total: u64 = 0; + for (account, amount) in allocations { + let mut balance = load_balance(&tables.balances, account)?; + let take = balance.available_sat.min(amount); + balance.available_sat -= take; + let shortfall = amount - take; + balance.deficit_sat = balance + .deficit_sat + .checked_add(shortfall) + .ok_or(LedgerError::Overflow("deficit balance"))?; + store_balance(&tables.balances, account, &balance)?; + reversed = reversed + .checked_add(amount) + .ok_or(LedgerError::Overflow("reversed total"))?; + deficit_total = deficit_total + .checked_add(shortfall) + .ok_or(LedgerError::Overflow("deficit total"))?; + } + Ok((reversed, deficit_total)) +} + +// --------------------------------------------------------------------------------------------- +// Payout reconciliation +// --------------------------------------------------------------------------------------------- + +struct TxObservation { + txid: Hash32, + status: TxStatus, + canonical: bool, + confirmations: u64, + dead_by: Option, +} + +struct BatchObservation { + id: Hash32, + rev: u64, + txs: Vec, +} + +enum Decision { + None, + Confirm(Confirmation), + Reconfirm(Confirmation), + Revert, + Fail(Hash32), +} + +fn observe_batch( + chain: &dyn ChainView, + config: &PoolConfig, + batch: &PayoutBatch, + tip: u32, +) -> Result { + let mut txs = Vec::with_capacity(batch.transactions.len()); + for tx in &batch.transactions { + let status = chain.tx_status(&tx.txid)?; + let (canonical, depth) = match status { + TxStatus::Confirmed { block_hash, height } => ( + matches!(chain.block_location(&block_hash, height)?, crate::BlockLocation::Canonical { height: observed } if observed == height), + confirmations(tip, height), + ), + _ => (false, 0), + }; + let confirmed_here = canonical && matches!(status, TxStatus::Confirmed { .. }); + let dead_by = if confirmed_here || status == TxStatus::InMempool { + None + } else { + find_dead_spend(chain, config, batch, tx, tip)? + }; + txs.push(TxObservation { + txid: tx.txid, + status, + canonical, + confirmations: depth, + dead_by, + }); + } + Ok(BatchObservation { + id: batch.id, + rev: batch.rev, + txs, + }) +} + +/// A confirmed spend of one of `tx`'s inputs by a transaction outside the batch, buried at least +/// `abandon_confirmations` deep on the active chain. +fn find_dead_spend( + chain: &dyn ChainView, + config: &PoolConfig, + batch: &PayoutBatch, + tx: &SignedTx, + tip: u32, +) -> Result, LedgerError> { + for input in &tx.inputs { + let Some(spend) = chain.confirmed_spender(input)? else { + continue; + }; + if batch + .transactions + .iter() + .any(|known| known.txid == spend.spender_txid) + { + continue; + } + let canonical = matches!(chain.block_location(&spend.block_hash, spend.height)?, crate::BlockLocation::Canonical { height } if height == spend.height); + if canonical && confirmations(tip, spend.height) >= u64::from(config.abandon_confirmations) + { + return Ok(Some(spend.spender_txid)); + } + } + Ok(None) +} + +fn decide(batch: &PayoutBatch, observation: &BatchObservation, config: &PoolConfig) -> Decision { + let enough = observation + .txs + .iter() + .find(|tx| tx.canonical && tx.confirmations >= u64::from(config.payout_confirmations)) + .and_then(|tx| match tx.status { + TxStatus::Confirmed { block_hash, height } => Some(Confirmation { + txid: tx.txid, + block_hash, + height, + }), + _ => None, + }); + match batch.state { + BatchState::ExternallySigned | BatchState::Broadcast => { + if let Some(confirmation) = enough { + return Decision::Confirm(confirmation); + } + if !observation.txs.is_empty() + && observation.txs.iter().all(|tx| tx.dead_by.is_some()) + && let Some(conflicting) = observation.txs.iter().find_map(|tx| tx.dead_by) + { + return Decision::Fail(conflicting); + } + Decision::None + } + BatchState::Confirmed => { + let still_confirmed = batch.confirmation.is_some_and(|recorded| { + observation.txs.iter().any(|tx| { + tx.txid == recorded.txid + && tx.canonical + && matches!( + tx.status, + TxStatus::Confirmed { block_hash, height } + if block_hash == recorded.block_hash && height == recorded.height + ) + }) + }); + if still_confirmed { + Decision::None + } else if let Some(confirmation) = enough { + Decision::Reconfirm(confirmation) + } else { + Decision::Revert + } + } + _ => Decision::None, + } +} + +fn settle_paid(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { + for output in &batch.outputs { + let mut balance = load_balance(&tables.balances, output.account)?; + balance.reserved_sat = balance + .reserved_sat + .checked_sub(output.amount_sat) + .ok_or_else(|| { + LedgerError::Corrupt("confirming a payout exceeds the reservation".into()) + })?; + balance.paid_sat = balance + .paid_sat + .checked_add(output.amount_sat) + .ok_or(LedgerError::Overflow("paid balance"))?; + store_balance(&tables.balances, output.account, &balance)?; + } + Ok(()) +} + +fn unsettle_paid(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { + for output in &batch.outputs { + let mut balance = load_balance(&tables.balances, output.account)?; + balance.paid_sat = balance + .paid_sat + .checked_sub(output.amount_sat) + .ok_or_else(|| { + LedgerError::Corrupt("reverting a payout exceeds the paid balance".into()) + })?; + balance.reserved_sat = balance + .reserved_sat + .checked_add(output.amount_sat) + .ok_or(LedgerError::Overflow("reserved balance"))?; + store_balance(&tables.balances, output.account, &balance)?; + } + Ok(()) +} + +fn release_reservation(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { + for output in &batch.outputs { + let mut balance = load_balance(&tables.balances, output.account)?; + balance.reserved_sat = balance + .reserved_sat + .checked_sub(output.amount_sat) + .ok_or_else(|| { + LedgerError::Corrupt("releasing a payout exceeds the reservation".into()) + })?; + balance.available_sat = balance + .available_sat + .checked_add(output.amount_sat) + .ok_or(LedgerError::Overflow("available balance"))?; + store_balance(&tables.balances, output.account, &balance)?; + } + Ok(()) +} + +fn next_rev(rev: u64) -> Result { + rev.checked_add(1) + .ok_or(LedgerError::Overflow("batch revision")) +} + +// --------------------------------------------------------------------------------------------- +// Counters, audit +// --------------------------------------------------------------------------------------------- + +fn meta_u64(meta: &Table<'_, &'static str, u64>, key: &'static str) -> Result { + Ok(meta.get(key)?.map_or(0, |guard| guard.value())) +} + +/// Return the current counter value (or `first` when unset) and store the next one. +fn bump( + meta: &Table<'_, &'static str, u64>, + key: &'static str, + first: u64, +) -> Result { + let current = meta.get(key)?.map_or(first, |guard| guard.value()); + let next = current + .checked_add(1) + .ok_or(LedgerError::Overflow("counter"))?; + meta.insert(key, next)?; + Ok(current) +} + +fn low64(value: u128) -> u64 { + u64::try_from(value & u128::from(u64::MAX)).unwrap_or(u64::MAX) +} + +fn audit_chain_hash(previous: &Hash32, seq: u64, body: &[u8]) -> Hash32 { + let mut hasher = Sha256::new(); + hasher.update(AUDIT_DOMAIN); + hasher.update(previous); + hasher.update(seq.to_be_bytes()); + hasher.update(body); + hasher.finalize().into() +} + +fn append_audit( + tables: &Tables<'_>, + kind: AuditKind, + subject: u64, + hash: &Hash32, + hash2: &Hash32, + values: [u64; 3], +) -> Result<(), LedgerError> { + let seq = bump(&tables.meta_u64, M_NEXT_AUDIT, 0)?; + let previous = match tables.meta_bytes.get(M_AUDIT_HEAD)? { + Some(guard) => hash_from(guard.value())?, + None => ZERO_HASH, + }; + let mut body = Enc::new(); + body.u8(kind.code()); + body.u64(subject); + body.hash(hash); + body.hash(hash2); + for value in values { + body.u64(value); + } + let body = body.finish()?; + let chain = audit_chain_hash(&previous, seq, &body); + let mut record = body; + record.extend_from_slice(&chain); + tables.audit.insert(seq, record.as_slice())?; + tables.meta_bytes.insert(M_AUDIT_HEAD, chain.as_slice())?; + Ok(()) +} + +fn decode_audit(seq: u64, bytes: &[u8]) -> Result { + let mut dec = Dec::new(bytes); + let kind = AuditKind::from_code(dec.u8()?).ok_or(LedgerError::Codec("unknown audit kind"))?; + let subject = dec.u64()?; + let hash = dec.hash()?; + let hash2 = dec.hash()?; + let values = [dec.u64()?, dec.u64()?, dec.u64()?]; + let chain = dec.hash()?; + dec.finish()?; + Ok(AuditRecord { + seq, + kind, + subject, + hash, + hash2, + values, + chain, + }) +} + +// --------------------------------------------------------------------------------------------- +// Row encoding and loading +// --------------------------------------------------------------------------------------------- + +fn hash_from(bytes: &[u8]) -> Result { + bytes + .try_into() + .map_err(|_| LedgerError::Corrupt("stored hash has the wrong length".into())) +} + +fn round_key(round: u64, account: MinerId) -> [u8; 16] { + let mut key = [0u8; 16]; + key[..8].copy_from_slice(&round.to_be_bytes()); + key[8..].copy_from_slice(&account.0.to_be_bytes()); + key +} + +fn encode_share(account: MinerId, work: u128) -> Result, LedgerError> { + let mut enc = Enc::new(); + enc.u64(account.0); + enc.u128(work); + enc.finish() +} + +fn decode_share(bytes: &[u8]) -> Result<(MinerId, u128), LedgerError> { + let mut dec = Dec::new(bytes); + let account = MinerId(dec.u64()?); + let work = dec.u128()?; + dec.finish()?; + Ok((account, work)) +} + +fn encode_hash_entry(seq: u64, work: u128, tuple: &[u8]) -> Result, LedgerError> { + let mut enc = Enc::new(); + enc.u64(seq); + enc.u128(work); + enc.bytes(tuple); + enc.finish() +} + +fn decode_hash_entry(bytes: &[u8]) -> Result<(u64, u128, Vec), LedgerError> { + let mut dec = Dec::new(bytes); + let seq = dec.u64()?; + let work = dec.u128()?; + let tuple = dec.bytes(MAX_EXTRANONCE_LEN + 20)?.to_vec(); + dec.finish()?; + Ok((seq, work, tuple)) +} + +fn encode_balance(balance: &Balance) -> Result, LedgerError> { + let mut enc = Enc::new(); + enc.u64(balance.available_sat); + enc.u64(balance.reserved_sat); + enc.u64(balance.paid_sat); + enc.u64(balance.deficit_sat); + enc.finish() +} + +fn decode_balance(bytes: &[u8]) -> Result { + let mut dec = Dec::new(bytes); + let balance = Balance { + available_sat: dec.u64()?, + reserved_sat: dec.u64()?, + paid_sat: dec.u64()?, + deficit_sat: dec.u64()?, + }; + dec.finish()?; + Ok(balance) +} + +fn load_balance( + balances: &Table<'_, u64, &'static [u8]>, + account: MinerId, +) -> Result { + match balances.get(account.0)? { + Some(guard) => decode_balance(guard.value()), + None => Ok(Balance::default()), + } +} + +fn store_balance( + balances: &Table<'_, u64, &'static [u8]>, + account: MinerId, + balance: &Balance, +) -> Result<(), LedgerError> { + balances.insert(account.0, encode_balance(balance)?.as_slice())?; + Ok(()) +} + +fn encode_round(round: &Round) -> Result, LedgerError> { + let mut enc = Enc::new(); + enc.u64(round.id); + enc.hash(&round.block_hash); + enc.u32(round.height); + enc.u64(round.solving_seq); + enc.hash(&round.coinbase_txid); + enc.u64(round.claimed_coinbase_value_sat); + enc.u128(round.window_work_total); + enc.u8(u8::from(round.reward_fixed)); + enc.u64(round.gross_sat); + enc.u64(round.pool_fee_sat); + enc.u64(round.net_sat); + enc.u8(round.state.code()); + enc.finish() +} + +fn decode_round(bytes: &[u8]) -> Result { + let mut dec = Dec::new(bytes); + let id = dec.u64()?; + let block_hash = dec.hash()?; + let height = dec.u32()?; + let solving_seq = dec.u64()?; + let coinbase_txid = dec.hash()?; + let claimed_coinbase_value_sat = dec.u64()?; + let window_work_total = dec.u128()?; + let reward_fixed = match dec.u8()? { + 0 => false, + 1 => true, + _ => return Err(LedgerError::Codec("bad reward_fixed flag")), + }; + let gross_sat = dec.u64()?; + let pool_fee_sat = dec.u64()?; + let net_sat = dec.u64()?; + let state = RoundState::from_code(dec.u8()?).ok_or(LedgerError::Codec("bad round state"))?; + dec.finish()?; + Ok(Round { + id, + block_hash, + height, + solving_seq, + coinbase_txid, + claimed_coinbase_value_sat, + window_work_total, + reward_fixed, + gross_sat, + pool_fee_sat, + net_sat, + state, + }) +} + +fn load_round( + rounds: &Table<'_, u64, &'static [u8]>, + id: u64, +) -> Result, LedgerError> { + match rounds.get(id)? { + Some(guard) => Ok(Some(decode_round(guard.value())?)), + None => Ok(None), + } +} + +fn store_round(rounds: &Table<'_, u64, &'static [u8]>, round: &Round) -> Result<(), LedgerError> { + rounds.insert(round.id, encode_round(round)?.as_slice())?; + Ok(()) +} + +fn round_id_for_block( + index: &Table<'_, &'static [u8], u64>, + block_hash: &Hash32, +) -> Result, LedgerError> { + Ok(index.get(block_hash.as_slice())?.map(|guard| guard.value())) +} + +fn load_allocs( + allocs: &Table<'_, &'static [u8], u64>, + round: u64, +) -> Result, LedgerError> { + let start = round_key(round, MinerId(0)); + let end = round_key(round, MinerId(u64::MAX)); + let mut list = Vec::new(); + for item in allocs.range(start.as_slice()..=end.as_slice())? { + let (key, value) = item?; + list.push((account_from_round_key(key.value())?, value.value())); + } + Ok(list) +} + +fn load_weights( + weights: &Table<'_, &'static [u8], u128>, + round: u64, +) -> Result, LedgerError> { + let start = round_key(round, MinerId(0)); + let end = round_key(round, MinerId(u64::MAX)); + let mut list = Vec::new(); + for item in weights.range(start.as_slice()..=end.as_slice())? { + let (key, value) = item?; + list.push((account_from_round_key(key.value())?, value.value())); + } + Ok(list) +} + +fn account_from_round_key(key: &[u8]) -> Result { + let tail: [u8; 8] = key + .get(8..) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| LedgerError::Corrupt("round key has the wrong length".into()))?; + Ok(MinerId(u64::from_be_bytes(tail))) +} + +fn encode_batch(batch: &PayoutBatch) -> Result, LedgerError> { + let mut enc = Enc::new(); + enc.hash(&batch.id); + enc.u64(batch.seq); + enc.hash(&batch.idempotency_key); + enc.u8(batch.state.code()); + enc.u64(batch.rev); + enc.u64(batch.total_sat); + enc.len32(batch.outputs.len()); + for output in &batch.outputs { + enc.u64(output.account.0); + enc.u64(output.amount_sat); + enc.bytes(&output.script_pubkey); + } + enc.len32(batch.transactions.len()); + for tx in &batch.transactions { + enc.hash(&tx.txid); + enc.len32(tx.inputs.len()); + for input in &tx.inputs { + enc.hash(&input.txid); + enc.u32(input.vout); + } + } + match batch.broadcast_txid { + Some(txid) => { + enc.u8(1); + enc.hash(&txid); + } + None => enc.u8(0), + } + match batch.confirmation { + Some(confirmation) => { + enc.u8(1); + enc.hash(&confirmation.txid); + enc.hash(&confirmation.block_hash); + enc.u32(confirmation.height); + } + None => enc.u8(0), + } + match batch.closure { + None => enc.u8(0), + Some(Closure::Cancelled(basis)) => { + enc.u8(1); + enc.u8(basis.code()); + } + Some(Closure::Failed { conflicting_txid }) => { + enc.u8(2); + enc.hash(&conflicting_txid); + } + } + enc.finish() +} + +fn decode_batch(bytes: &[u8]) -> Result { + let mut dec = Dec::new(bytes); + let id = dec.hash()?; + let seq = dec.u64()?; + let idempotency_key = dec.hash()?; + let state = BatchState::from_code(dec.u8()?).ok_or(LedgerError::Codec("bad batch state"))?; + let rev = dec.u64()?; + let total_sat = dec.u64()?; + let output_count = dec.count(MAX_BATCH_OUTPUTS, 20)?; + let mut outputs = Vec::with_capacity(output_count); + for _ in 0..output_count { + let account = MinerId(dec.u64()?); + let amount_sat = dec.u64()?; + let script_pubkey = dec.bytes(MAX_SCRIPT_LEN)?.to_vec(); + outputs.push(PayoutOutput { + account, + script_pubkey, + amount_sat, + }); + } + let tx_count = dec.count(MAX_SIGNED_TXS_PER_BATCH, 36)?; + let mut transactions = Vec::with_capacity(tx_count); + for _ in 0..tx_count { + let txid = dec.hash()?; + let input_count = dec.count(MAX_INPUTS_PER_TX, 36)?; + let mut inputs = Vec::with_capacity(input_count); + for _ in 0..input_count { + let outpoint_txid = dec.hash()?; + let vout = dec.u32()?; + inputs.push(OutPoint { + txid: outpoint_txid, + vout, + }); + } + transactions.push(SignedTx { txid, inputs }); + } + let broadcast_txid = match dec.u8()? { + 0 => None, + 1 => Some(dec.hash()?), + _ => return Err(LedgerError::Codec("bad broadcast flag")), + }; + let confirmation = match dec.u8()? { + 0 => None, + 1 => Some(Confirmation { + txid: dec.hash()?, + block_hash: dec.hash()?, + height: dec.u32()?, + }), + _ => return Err(LedgerError::Codec("bad confirmation flag")), + }; + let closure = match dec.u8()? { + 0 => None, + 1 => Some(Closure::Cancelled( + CancelBasis::from_code(dec.u8()?).ok_or(LedgerError::Codec("bad cancel basis"))?, + )), + 2 => Some(Closure::Failed { + conflicting_txid: dec.hash()?, + }), + _ => return Err(LedgerError::Codec("bad closure tag")), + }; + dec.finish()?; + Ok(PayoutBatch { + id, + seq, + idempotency_key, + state, + rev, + outputs, + total_sat, + transactions, + broadcast_txid, + confirmation, + closure, + }) +} + +fn load_batch( + batches: &Table<'_, &'static [u8], &'static [u8]>, + id: &Hash32, +) -> Result, LedgerError> { + match batches.get(id.as_slice())? { + Some(guard) => Ok(Some(decode_batch(guard.value())?)), + None => Ok(None), + } +} + +fn store_batch( + batches: &Table<'_, &'static [u8], &'static [u8]>, + batch: &PayoutBatch, +) -> Result<(), LedgerError> { + batches.insert(batch.id.as_slice(), encode_batch(batch)?.as_slice())?; + Ok(()) +} diff --git a/crates/pool-ledger/tests/ledger.rs b/crates/pool-ledger/tests/ledger.rs new file mode 100644 index 0000000000..ecc89416c4 --- /dev/null +++ b/crates/pool-ledger/tests/ledger.rs @@ -0,0 +1,9 @@ +//! The complete accounting suite runs against each real node storage backend. +mod redb { + const BACKEND: &str = "redb"; + include!("support/ledger_cases.rs"); +} +mod fjall { + const BACKEND: &str = "fjall"; + include!("support/ledger_cases.rs"); +} diff --git a/crates/pool-ledger/tests/support/kv_transactions.rs b/crates/pool-ledger/tests/support/kv_transactions.rs new file mode 100644 index 0000000000..5f7ab4e793 --- /dev/null +++ b/crates/pool-ledger/tests/support/kv_transactions.rs @@ -0,0 +1,71 @@ +use super::*; +use bitcoin_rs_storage::{FjallStore, RedbStore}; + +const SHARES: TableDefinition<'static, u64, &'static [u8]> = + TableDefinition::new("pplns_shares_v1"); +const BALANCES: TableDefinition<'static, u64, &'static [u8]> = + TableDefinition::new("pplns_balances_v1"); + +fn exercise(store: &Arc) -> Result<(), Box> { + let first = Database::new(Arc::clone(store)); + let second = Database::new(Arc::clone(store)); + let stale_read = first.begin_read()?; + let a = first.begin_write()?; + let b = second.begin_write()?; + a.open_table(SHARES)?.insert(1u64, b"first".as_slice())?; + a.open_table(BALANCES)?.insert(1u64, b"credit".as_slice())?; + b.open_table(SHARES)?.insert(2u64, b"stale".as_slice())?; + b.open_table(BALANCES)? + .insert(2u64, b"stale credit".as_slice())?; + assert!( + store.iter_prefix(CF, PREFIX)?.next().is_none(), + "buffering has no effects" + ); + a.commit()?; + assert!( + matches!(b.commit(), Err(LedgerError::Conflict)), + "stale cross-handle revision rejects entire batch" + ); + assert!( + stale_read.open_table(SHARES)?.get(1u64)?.is_none(), + "read snapshot remains coherent" + ); + let read = second.begin_read()?; + let first_row = read + .open_table(SHARES)? + .get(1u64)? + .ok_or_else(|| std::io::Error::other("missing committed row"))?; + assert_eq!(first_row.value(), b"first"); + assert!(read.open_table(SHARES)?.get(2u64)?.is_none()); + assert!( + read.open_table(BALANCES)?.get(2u64)?.is_none(), + "no partial effects in another logical table" + ); + let dropped = first.begin_write()?; + dropped.open_table(SHARES)?.remove(1u64)?; + assert!(dropped.open_table(SHARES)?.get(1u64)?.is_none()); + drop(dropped); + assert!( + second + .begin_read()? + .open_table(SHARES)? + .get(1u64)? + .is_some(), + "dropping a transaction rolls back all buffered changes" + ); + Ok(()) +} + +#[test] +fn redb_snapshot_and_cross_handle_conditional_commit() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store: Arc = Arc::new(RedbStore::open(dir.path().join("store.redb"))?); + exercise(&store) +} + +#[test] +fn fjall_snapshot_and_cross_handle_conditional_commit() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store: Arc = Arc::new(FjallStore::open(dir.path().join("store"))?); + exercise(&store) +} diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/pool-ledger/tests/support/ledger_cases.rs new file mode 100644 index 0000000000..f8f63bed54 --- /dev/null +++ b/crates/pool-ledger/tests/support/ledger_cases.rs @@ -0,0 +1,2827 @@ +// Shared cases run unchanged against both real storage engines. + +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +use pool_ledger::*; +use std::sync::Arc; +use bitcoin_rs_storage::{ColumnFamily, FjallStore, KvStore, RedbStore}; +use tempfile::TempDir; + +trait FixtureValue { + #[track_caller] + fn fixture(self, context: &str) -> T; +} + +impl FixtureValue for Result { + #[track_caller] + fn fixture(self, context: &str) -> T { + self.unwrap_or_else(|error| panic!("{context}: {error:?}")) + } +} + +impl FixtureValue for Option { + #[track_caller] + fn fixture(self, context: &str) -> T { + self.unwrap_or_else(|| panic!("{context}")) + } +} + +// --------------------------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------------------------- + +fn hash(tag: u8) -> Hash32 { + [tag; 32] +} + +fn script(tag: u8) -> Vec { + let mut script = vec![0x00, 0x14]; + script.extend_from_slice(&[tag; 20]); + script +} + +fn pool_script() -> Vec { + script(0xA0) +} + +fn change_script() -> Vec { + script(0xC0) +} + +fn account_script(account: u64) -> Vec { + script(0x10 + u8::try_from(account).fixture("small account")) +} + +fn config(window: u128, fee_bps: u16) -> PoolConfig { + let mut config = PoolConfig::new(window, pool_script()); + config.pool_fee_bps = fee_bps; + config.coinbase_maturity = 3; + config.payout_confirmations = 2; + config.abandon_confirmations = 2; + config.reorg_watch_depth = 10; + config.change_scripts = vec![change_script()]; + config +} + +fn share(job: u64, account: u64, nonce: u32, work: u128) -> ShareCredit { + let mut seed = Vec::new(); + seed.extend_from_slice(&job.to_be_bytes()); + seed.extend_from_slice(&account.to_be_bytes()); + seed.extend_from_slice(&nonce.to_be_bytes()); + seed.extend_from_slice(&work.to_be_bytes()); + ShareCredit { + header_hash: sha256(&seed), + job, + account: MinerId(account), + extranonce: vec![u8::try_from(account).fixture("small account"), 1, 2, 3], + nonce, + work, + } +} + +fn found(share: ShareCredit, height: u32, claimed: u64) -> BlockFound { + let coinbase_txid = sha256(&[b"coinbase".as_slice(), share.header_hash.as_slice()].concat()); + BlockFound { + share, + height, + coinbase_txid, + claimed_coinbase_value_sat: claimed, + } +} + +fn coinbase_for(block: &BlockFound, pool_value: u64) -> ObservedCoinbase { + ObservedCoinbase { + txid: block.coinbase_txid, + outputs: vec![ + TxOut { + value_sat: pool_value, + script_pubkey: pool_script(), + }, + TxOut { + value_sat: 0, + script_pubkey: vec![0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed], + }, + ], + } +} + +fn idem(tag: u8) -> Hash32 { + [tag; 32] +} + +fn policy() -> PayoutPolicy { + PayoutPolicy { + min_payout_sat: 546, + max_outputs: 100, + } +} + +fn outpoint(tag: u8, vout: u32) -> OutPoint { + OutPoint { + txid: hash(tag), + vout, + } +} + +fn miner_amounts(list: &[(u64, u64)]) -> Vec<(MinerId, u64)> { + list.iter() + .map(|(id, amount)| (MinerId(*id), *amount)) + .collect() +} + +// --------------------------------------------------------------------------------------------- +// Fake chain observer +// --------------------------------------------------------------------------------------------- + +#[derive(Default)] +struct ChainState { + tip: u32, + blocks: BTreeMap, + displaced: BTreeSet, + coinbases: HashMap, + txs: HashMap, + spends: HashMap, + down: bool, +} + +#[derive(Default)] +struct FakeChain { + state: RefCell, +} + +impl FakeChain { + fn set_tip(&self, tip: u32) { + let mut state = self.state.borrow_mut(); + let displaced: Vec = state.blocks.iter() + .filter(|(height, _)| **height > tip && **height <= state.tip) + .map(|(_, hash)| *hash).collect(); + state.displaced.extend(displaced); + state.tip = tip; + } + + fn set_block(&self, height: u32, block: Hash32) { + let mut state = self.state.borrow_mut(); + if let Some(previous) = state.blocks.insert(height, block) { + if previous != block && height <= state.tip { + state.displaced.insert(previous); + } + } + } + + fn set_coinbase(&self, block: Hash32, coinbase: ObservedCoinbase) { + self.state.borrow_mut().coinbases.insert(block, coinbase); + } + + fn set_tx(&self, txid: Hash32, status: TxStatus) { + self.state.borrow_mut().txs.insert(txid, status); + } + + fn set_spend(&self, outpoint: OutPoint, spend: ConfirmedSpend) { + self.state.borrow_mut().spends.insert(outpoint, spend); + } + + fn take_down(&self) { + self.state.borrow_mut().down = true; + } + + fn bring_up(&self) { + self.state.borrow_mut().down = false; + } + + fn check(&self) -> Result<(), ChainViewError> { + if self.state.borrow().down { + Err(ChainViewError("observer down".into())) + } else { + Ok(()) + } + } +} + +impl ChainView for FakeChain { + fn tip_height(&self) -> Result { + self.check()?; + Ok(self.state.borrow().tip) + } + + fn block_location(&self, block_hash: &Hash32, height: u32) -> Result { + self.check()?; + let state = self.state.borrow(); + if let Some((&observed, _)) = state.blocks.iter().find(|(h, hash)| **h <= state.tip && *hash == block_hash) { + return Ok(BlockLocation::Canonical { height: observed }); + } + if state.displaced.contains(block_hash) || (state.blocks.contains_key(&height) && height <= state.tip) { + return Ok(BlockLocation::Side); + } + Ok(BlockLocation::Unknown) + } + + fn coinbase_of(&self, block_hash: &Hash32) -> Result, ChainViewError> { + self.check()?; + Ok(self.state.borrow().coinbases.get(block_hash).cloned()) + } + + fn tx_status(&self, txid: &Hash32) -> Result { + self.check()?; + Ok(self + .state + .borrow() + .txs + .get(txid) + .copied() + .unwrap_or(TxStatus::Unknown)) + } + + fn confirmed_spender( + &self, + outpoint: &OutPoint, + ) -> Result, ChainViewError> { + self.check()?; + Ok(self.state.borrow().spends.get(outpoint).copied()) + } +} + +// --------------------------------------------------------------------------------------------- +// Transaction builder and signer +// --------------------------------------------------------------------------------------------- + +fn varint(value: u64) -> Vec { + match value { + 0..=0xfc => vec![u8::try_from(value).fixture("fits")], + 0xfd..=0xffff => { + let mut out = vec![0xfd]; + out.extend_from_slice(&u16::try_from(value).fixture("fits").to_le_bytes()); + out + } + _ => { + let mut out = vec![0xfe]; + out.extend_from_slice(&u32::try_from(value).fixture("fits").to_le_bytes()); + out + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Witness { + None, + Items, + Empty, +} + +struct BuiltTx { + raw: Vec, + txid: Hash32, +} + +fn build_tx(inputs: &[OutPoint], outputs: &[(u64, Vec)], witness: Witness) -> BuiltTx { + let version = 2i32.to_le_bytes(); + let lock_time = 0u32.to_le_bytes(); + let mut body = Vec::new(); + body.extend_from_slice(&varint(u64::try_from(inputs.len()).fixture("fits"))); + for input in inputs { + body.extend_from_slice(&input.txid); + body.extend_from_slice(&input.vout.to_le_bytes()); + body.extend_from_slice(&varint(0)); + body.extend_from_slice(&0xffff_fffdu32.to_le_bytes()); + } + body.extend_from_slice(&varint(u64::try_from(outputs.len()).fixture("fits"))); + for (value, script) in outputs { + body.extend_from_slice(&value.to_le_bytes()); + body.extend_from_slice(&varint(u64::try_from(script.len()).fixture("fits"))); + body.extend_from_slice(script); + } + let mut stripped = Vec::new(); + stripped.extend_from_slice(&version); + stripped.extend_from_slice(&body); + stripped.extend_from_slice(&lock_time); + let txid = sha256d(&stripped); + let raw = match witness { + Witness::None => stripped, + Witness::Items | Witness::Empty => { + let mut raw = Vec::new(); + raw.extend_from_slice(&version); + raw.extend_from_slice(&[0x00, 0x01]); + raw.extend_from_slice(&body); + for _ in inputs { + if witness == Witness::Items { + raw.extend_from_slice(&varint(2)); + raw.extend_from_slice(&varint(3)); + raw.extend_from_slice(&[1, 2, 3]); + raw.extend_from_slice(&varint(2)); + raw.extend_from_slice(&[4, 5]); + } else { + raw.extend_from_slice(&varint(0)); + } + } + raw.extend_from_slice(&lock_time); + raw + } + }; + BuiltTx { raw, txid } +} + +fn pay_tx(batch: &PayoutBatch, inputs: &[OutPoint], change: Option) -> BuiltTx { + let mut outputs: Vec<(u64, Vec)> = batch + .outputs + .iter() + .rev() + .map(|output| (output.amount_sat, output.script_pubkey.clone())) + .collect(); + if let Some(change) = change { + outputs.push((change, change_script())); + } + build_tx(inputs, &outputs, Witness::Items) +} + +fn receipt(batch: &PayoutBatch, tx: &BuiltTx) -> PayoutReceipt { + PayoutReceipt { + batch_id: batch.id, + txid: tx.txid, + raw_tx: tx.raw.clone(), + } +} + +struct TestSigner { + inputs: Vec, + refuse: bool, +} + +impl ExternalSigner for TestSigner { + fn sign(&self, request: &PayoutRequest) -> Result { + if self.refuse { + return Err(SignerError("operator refused".into())); + } + let outputs: Vec<(u64, Vec)> = request + .outputs + .iter() + .map(|output| (output.amount_sat, output.script_pubkey.clone())) + .collect(); + let tx = build_tx(&self.inputs, &outputs, Witness::Items); + Ok(PayoutReceipt { + batch_id: request.batch_id, + txid: tx.txid, + raw_tx: tx.raw, + }) + } +} + +// --------------------------------------------------------------------------------------------- +// Funded scenario: window 100 split 50/30/20 across accounts 1/2/3, block at height 100 +// --------------------------------------------------------------------------------------------- + +struct Fund { + _dir: TempDir, + path: PathBuf, + ledger: Ledger, + chain: FakeChain, + block: BlockFound, +} + +fn backend(path: impl AsRef) -> Arc { + if BACKEND == "fjall" { + Arc::new(FjallStore::open(path).fixture("open fjall")) + } else { + Arc::new(RedbStore::open(path).fixture("open redb")) + } +} + +fn create(path: impl AsRef, config: PoolConfig) -> Result { + Ledger::create(backend(path), config) +} + +fn open(path: impl AsRef, config: PoolConfig) -> Result { + Ledger::open(backend(path), config) +} + +fn ledger_path(dir: &TempDir) -> PathBuf { + dir.path().join("pool.redb") +} + +/// Shares and a Pending round exist; the chain does not show the block yet (tip 99). +fn staged_at( + path: &Path, + fee_bps: u16, + claimed: u64, + pool_value: u64, +) -> (Ledger, FakeChain, BlockFound) { + let ledger = create(path, config(100, fee_bps)).fixture("create"); + ledger.record_share(&share(1, 1, 1, 50)).fixture("share 1"); + ledger.record_share(&share(1, 2, 1, 30)).fixture("share 2"); + let block = found(share(1, 3, 1, 20), 100, claimed); + let recorded = ledger.record_found_block(&block).fixture("block"); + assert!(recorded.newly_recorded); + let chain = FakeChain::default(); + chain.set_block(100, block.share.header_hash); + chain.set_coinbase(block.share.header_hash, coinbase_for(&block, pool_value)); + chain.set_tip(99); + for account in 1..=3 { + ledger + .set_payout_script(MinerId(account), &account_script(account)) + .fixture("script"); + } + (ledger, chain, block) +} + +fn staged(fee_bps: u16, claimed: u64) -> Fund { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + let (ledger, chain, block) = staged_at(&path, fee_bps, claimed, claimed); + Fund { + _dir: dir, + path, + ledger, + chain, + block, + } +} + +fn mature(fund: &Fund) { + fund.chain.set_tip(102); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("reconcile"); + assert_eq!(report.anomalies, Vec::new()); + let round = fund + .ledger + .round_for_block(&fund.block.share.header_hash) + .fixture("round") + .fixture("exists"); + assert_eq!(round.state, RoundState::Matured); +} + +fn funded(fee_bps: u16, claimed: u64) -> Fund { + let fund = staged(fee_bps, claimed); + mature(&fund); + fund +} + +fn prepared(fund: &Fund) -> PayoutBatch { + fund.ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("prepare") + .fixture("a batch") +} + +fn confirm_tx(fund: &Fund, txid: Hash32, block_tag: u8, height: u32, tip: u32) { + fund.chain.set_block(height, hash(block_tag)); + fund.chain.set_tx( + txid, + TxStatus::Confirmed { + block_hash: hash(block_tag), + height, + }, + ); + fund.chain.set_tip(tip); +} + +// --------------------------------------------------------------------------------------------- +// Exact integer accounting +// --------------------------------------------------------------------------------------------- + +#[test] +fn equal_height_fork_reverses_matured_credits() { + let fund = funded(0, 1_000_001); + fund.chain.set_block(100, hash(0xF0)); + fund.chain.set_tip(102); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("reconcile equal-height fork"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].to, RoundState::Orphaned); + assert_eq!(fund.ledger.balance(MinerId(1)).fixture("balance").available_sat, 0); + fund.ledger.check_invariants().fixture("conservation"); +} + +#[test] +fn lower_height_fork_reverses_matured_credits() { + let fund = funded(0, 1_000_001); + fund.chain.set_block(100, hash(0xF1)); + fund.chain.set_tip(99); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("reconcile shorter fork"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].to, RoundState::Orphaned); + assert_eq!(fund.ledger.balance(MinerId(1)).fixture("balance").available_sat, 0); + fund.ledger.check_invariants().fixture("conservation"); +} + +#[test] +fn independent_ledger_handles_share_the_revision_and_restart_state() { + let dir = tempfile::tempdir().fixture("tempdir"); + let store = backend(ledger_path(&dir)); + let first = Ledger::create(Arc::clone(&store), config(100, 0)).fixture("create"); + let second = Ledger::open(Arc::clone(&store), config(100, 0)).fixture("second handle"); + first.record_share(&share(1, 1, 1, 10)).fixture("first write"); + second.record_share(&share(1, 2, 1, 20)).fixture("second write"); + assert_eq!(first.share_count().fixture("count"), 2); + assert_eq!(second.share_count().fixture("count"), 2); + first.check_invariants().fixture("first invariants"); + second.check_invariants().fixture("second invariants"); + drop(first); + drop(second); + let reopened = Ledger::open(store, config(100, 0)).fixture("reopen"); + assert_eq!(reopened.share_count().fixture("restart count"), 2); +} + +#[test] +fn audit_tail_pages_the_latest_records_in_sequence_order() { + let fund = funded(0, 1_000_001); + let all = fund.ledger.audit_records(0, usize::MAX).fixture("audit"); + let tail = fund.ledger.audit_tail(2).fixture("tail"); + assert_eq!(tail, all[all.len().saturating_sub(2)..]); + assert_eq!(fund.ledger.audit_tail(usize::MAX).fixture("all"), all); + assert_eq!(fund.ledger.audit_tail(0).fixture("empty tail"), Vec::new()); + let (_dir, empty) = plain_ledger(100); + assert_eq!(empty.audit_tail(20).fixture("empty ledger"), Vec::new()); +} + +#[test] +fn runtime_epochs_are_durable_and_shared_between_ledger_handles() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + let store = backend(&path); + let first = Ledger::create(Arc::clone(&store), config(100, 0)).fixture("create"); + let second = Ledger::open(Arc::clone(&store), config(100, 0)).fixture("second handle"); + assert_eq!(first.allocate_run_epoch().fixture("first epoch"), 1); + assert_eq!(second.allocate_run_epoch().fixture("second epoch"), 2); + drop(first); + drop(second); + drop(store); + let reopened = open(&path, config(100, 0)).fixture("restart backend"); + assert_eq!(reopened.allocate_run_epoch().fixture("restart epoch"), 3); +} + +fn wide_mul(a: u128, b: u128) -> (u128, u128) { + let mask = u128::from(u64::MAX); + let (a0, a1) = (a & mask, a >> 64); + let (b0, b1) = (b & mask, b >> 64); + let p00 = a0 * b0; + let p01 = a0 * b1; + let p10 = a1 * b0; + let p11 = a1 * b1; + let mid = (p00 >> 64) + (p01 & mask) + (p10 & mask); + let low = (p00 & mask) | ((mid & mask) << 64); + let high = p11 + (p01 >> 64) + (p10 >> 64) + (mid >> 64); + (high, low) +} + +#[test] +fn mul_div_rem_is_exact_across_the_192_bit_range() { + let cases: [(u64, u128, u128); 7] = [ + (0, 0, 1), + (7, 3, 9), + (u64::MAX, u128::MAX, u128::MAX), + (u64::MAX, 1 << 127, (1 << 127) + 12_345), + (123_456_789, 1 << 100, (1 << 100) + (1 << 64) + 7), + ( + 2_100_000_000_000_000, + 123_456_789_012_345_678_901_234_567_890, + 987_654_321_098_765_432_109_876_543_210_987_654, + ), + (1, 1, 1), + ]; + for (a, b, d) in cases { + let (quotient, remainder) = mul_div_rem(a, b, d).fixture("valid"); + assert!(remainder < d, "remainder below divisor for {a} {b} {d}"); + let (q_high, q_low) = wide_mul(quotient, d); + let (q_low, carry) = q_low.overflowing_add(remainder); + let q_high = q_high + u128::from(carry); + assert_eq!( + (q_high, q_low), + wide_mul(u128::from(a), b), + "q*d+r == a*b for {a} {b} {d}" + ); + assert!(quotient <= u128::from(a)); + } + assert!(matches!( + mul_div_rem(1, 1, 0), + Err(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + mul_div_rem(1, 2, 1), + Err(LedgerError::InvalidInput(_)) + )); +} + +#[test] +fn split_reward_rounds_deterministically_and_conserves_the_total() { + let thirds = [(MinerId(1), 1u128), (MinerId(2), 1), (MinerId(3), 1)]; + assert_eq!( + split_reward(100, &thirds).fixture("split"), + miner_amounts(&[(1, 34), (2, 33), (3, 33)]), + "equal remainders: the lowest account takes the residual satoshi" + ); + let quarter = [(MinerId(1), 1u128), (MinerId(2), 3)]; + assert_eq!( + split_reward(10, &quarter).fixture("split"), + miner_amounts(&[(1, 3), (2, 7)]) + ); + assert_eq!(split_reward(0, &thirds).fixture("zero net"), Vec::new()); + assert!(matches!( + split_reward(5, &[]), + Err(LedgerError::EmptyWindow) + )); + assert!(matches!( + split_reward(5, &[(MinerId(2), 1), (MinerId(1), 1)]), + Err(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + split_reward(5, &[(MinerId(1), 0)]), + Err(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + split_reward(5, &[(MinerId(1), u128::MAX), (MinerId(2), 1)]), + Err(LedgerError::Overflow(_)) + )); +} + +#[test] +fn split_reward_property_sum_equals_net_and_each_share_is_floor_or_ceil() { + let mut state: u64 = 0x9e37_79b9_7f4a_7c15; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for _ in 0..300 { + let accounts = usize::try_from(next() % 12).fixture("small") + 1; + let weights: Vec<(MinerId, u128)> = (0..accounts) + .map(|index| { + ( + MinerId(u64::try_from(index).fixture("small") + 1), + u128::from(next() % 1_000_000) + 1, + ) + }) + .collect(); + let net = next() % 2_100_000_000_000_000; + let total: u128 = weights.iter().map(|(_, weight)| *weight).sum(); + let result = split_reward(net, &weights).fixture("split"); + let sum: u64 = result.iter().map(|(_, amount)| *amount).sum(); + assert_eq!(sum, net, "allocation sum equals net exactly"); + for (miner, weight) in &weights { + let floor = u128::from(net) * weight / total; + let got = result + .iter() + .find(|(candidate, _)| candidate == miner) + .map_or(0, |(_, amount)| u128::from(*amount)); + assert!( + got == floor || got == floor + 1, + "within one satoshi of the exact share" + ); + } + } +} + +#[test] +fn split_reward_handles_network_scale_work_without_loss() { + let weights = [ + (MinerId(1), 300_000_000_000_000_000_000_000u128), + (MinerId(2), 100_000_000_000_000_000_000_000), + (MinerId(3), 7), + ]; + let net = 2_100_000_000_000_000; + let result = split_reward(net, &weights).fixture("split"); + let sum: u64 = result.iter().map(|(_, amount)| *amount).sum(); + assert_eq!(sum, net); + assert_eq!(result[0].1, 1_575_000_000_000_000); + assert_eq!(result[1].1, 525_000_000_000_000); +} + +#[test] +fn pool_fee_rounds_down_and_never_exceeds_gross() { + assert_eq!( + split_pool_fee(1_000_001, 250).fixture("fee"), + (25_000, 975_001) + ); + assert_eq!( + split_pool_fee(u64::MAX, 10_000).fixture("fee"), + (u64::MAX, 0) + ); + assert_eq!(split_pool_fee(0, 500).fixture("fee"), (0, 0)); + assert!(matches!( + split_pool_fee(1, 10_001), + Err(LedgerError::InvalidInput(_)) + )); +} + +#[test] +fn window_accumulator_truncates_the_boundary_share() { + let mut window = WindowAccumulator::new(10); + window.push_older(MinerId(1), 6).fixture("push"); + assert!(!window.is_full()); + window.push_older(MinerId(2), 6).fixture("push"); + assert!(window.is_full()); + window + .push_older(MinerId(3), 6) + .fixture("push beyond a full window"); + assert_eq!(window.total(), 10); + assert_eq!( + window.into_weights(), + vec![(MinerId(1), 6), (MinerId(2), 4)] + ); +} + +#[test] +fn share_work_converts_exactly_from_le_bytes() { + let mut le = [0u8; 32]; + le[..16].copy_from_slice(&u128::MAX.to_le_bytes()); + assert_eq!(work_from_le_bytes(le).fixture("fits"), u128::MAX); + le[16] = 1; + assert!(matches!( + work_from_le_bytes(le), + Err(LedgerError::Overflow(_)) + )); +} + +// --------------------------------------------------------------------------------------------- +// Transaction parsing +// --------------------------------------------------------------------------------------------- + +#[test] +fn legacy_and_segwit_serializations_share_one_txid() { + let inputs = [outpoint(1, 3)]; + let outputs = vec![(1_000u64, script(1)), (2_000, script(2))]; + let legacy = build_tx(&inputs, &outputs, Witness::None); + let segwit = build_tx(&inputs, &outputs, Witness::Items); + assert_eq!(legacy.txid, segwit.txid); + let parsed_legacy = parse_transaction(&legacy.raw).fixture("legacy"); + let parsed_segwit = parse_transaction(&segwit.raw).fixture("segwit"); + assert_eq!(parsed_legacy.txid, legacy.txid); + assert_eq!(parsed_segwit.txid, legacy.txid); + assert!(!parsed_legacy.has_witness); + assert!(parsed_segwit.has_witness); + assert_eq!(parsed_segwit.inputs, inputs.to_vec()); + assert_eq!(parsed_segwit.outputs.len(), 2); + assert_eq!(parsed_segwit.outputs[1].value_sat, 2_000); + assert_eq!(parsed_segwit.version, 2); +} + +#[test] +fn parser_rejects_malformed_encodings() { + let inputs = [outpoint(1, 0)]; + let outputs = vec![(1_000u64, script(1))]; + let good = build_tx(&inputs, &outputs, Witness::Items); + assert_eq!( + parse_transaction(&good.raw[..good.raw.len() - 1]), + Err(TxParseError::Truncated) + ); + let mut trailing = good.raw; + trailing.push(0); + assert_eq!( + parse_transaction(&trailing), + Err(TxParseError::TrailingBytes) + ); + let empty_witness = build_tx(&inputs, &outputs, Witness::Empty); + assert_eq!( + parse_transaction(&empty_witness.raw), + Err(TxParseError::SuperfluousWitness) + ); + let too_rich = build_tx(&inputs, &[(MAX_MONEY_SAT + 1, script(1))], Witness::None); + assert_eq!( + parse_transaction(&too_rich.raw), + Err(TxParseError::ValueOutOfRange) + ); + let mut non_canonical = vec![2, 0, 0, 0, 0xfd, 0x01, 0x00]; + non_canonical.extend_from_slice(&[0u8; 60]); + assert_eq!( + parse_transaction(&non_canonical), + Err(TxParseError::NonCanonicalVarint) + ); + let bad_flag = vec![2, 0, 0, 0, 0x00, 0x02, 0, 0, 0, 0]; + assert_eq!( + parse_transaction(&bad_flag), + Err(TxParseError::BadWitnessFlag) + ); + let huge_count = [ + vec![2, 0, 0, 0, 0xfe], + 0x00ff_ffffu32.to_le_bytes().to_vec(), + ] + .concat(); + assert_eq!( + parse_transaction(&huge_count), + Err(TxParseError::CountTooLarge) + ); + let no_outputs = build_tx(&inputs, &[], Witness::None); + assert_eq!( + parse_transaction(&no_outputs.raw), + Err(TxParseError::NoOutputs) + ); +} + +// --------------------------------------------------------------------------------------------- +// Canonical request / receipt codecs +// --------------------------------------------------------------------------------------------- + +#[test] +fn payout_request_and_receipt_round_trip_and_reject_corruption() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let request = fund.ledger.request_for(&batch.id).fixture("request"); + assert_eq!(request.allowed_change_scripts, vec![change_script()]); + let encoded = request.encode().fixture("encode"); + assert_eq!(&encoded[..4], b"BRPR"); + assert_eq!(PayoutRequest::decode(&encoded).fixture("decode"), request); + assert_eq!(request.digest().fixture("digest"), sha256(&encoded)); + let mut bad_magic = encoded.clone(); + bad_magic[0] = b'X'; + assert!(matches!( + PayoutRequest::decode(&bad_magic), + Err(LedgerError::Codec(_)) + )); + let mut trailing = encoded.clone(); + trailing.push(0); + assert!(matches!( + PayoutRequest::decode(&trailing), + Err(LedgerError::Codec(_)) + )); + assert!(matches!( + PayoutRequest::decode(&encoded[..encoded.len() - 3]), + Err(LedgerError::Codec(_)) + )); + let mut wrong_total = request; + wrong_total.total_sat += 1; + assert!(matches!( + wrong_total.encode(), + Err(LedgerError::InvalidInput(_)) + )); + + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], Some(50_000)); + let signed = receipt(&batch, &tx); + let wire = signed.encode().fixture("encode receipt"); + assert_eq!(&wire[..4], b"BRPT"); + assert_eq!( + PayoutReceipt::decode(&wire).fixture("decode receipt"), + signed + ); + assert!(matches!( + PayoutReceipt::decode(&wire[..wire.len() - 1]), + Err(LedgerError::Codec(_)) + )); +} + +// --------------------------------------------------------------------------------------------- +// Lifecycle, configuration, storage +// --------------------------------------------------------------------------------------------- + +#[test] +fn create_refuses_existing_paths_and_open_demands_the_same_config() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + assert!(matches!( + open(&path, config(100, 0)).err(), + Some(LedgerError::NotALedger) + )); + let ledger = create(&path, config(100, 0)).fixture("create"); + drop(ledger); + assert!(matches!( + create(&path, config(100, 0)).err(), + Some(LedgerError::PathExists) + )); + assert!(matches!( + open(&path, config(101, 0)).err(), + Some(LedgerError::ConfigMismatch) + )); + assert!(matches!( + open(&path, config(100, 1)).err(), + Some(LedgerError::ConfigMismatch) + )); + let reopened = open(&path, config(100, 0)).fixture("same config reopens"); + assert_eq!(reopened.config(), &config(100, 0)); +} + +#[test] +fn open_refuses_a_foreign_store_without_writing_to_it() { + let dir = tempfile::tempdir().fixture("tempdir"); + let store = backend(ledger_path(&dir)); + let mut batch = store.new_batch(); + batch.put(ColumnFamily::PoolLedger, b"foreign-owner", b"untouched"); + store.write_durable(batch).fixture("seed"); + assert!(matches!( + Ledger::open(Arc::clone(&store), config(100, 0)).err(), + Some(LedgerError::NotALedger) + )); + assert_eq!(store.get(ColumnFamily::PoolLedger, b"foreign-owner").fixture("read"), Some(b"untouched".to_vec())); + assert!(store.iter_prefix(ColumnFamily::PoolLedger, b"pool-ledger/v1/").fixture("scan").next().is_none()); +} + +#[test] +fn config_validation_rejects_every_unsafe_value() { + let ok = config(100, 0); + assert!(ok.validate().is_ok()); + let mut bad = ok.clone(); + bad.window_work = 0; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok.clone(); + bad.pool_fee_bps = 10_001; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok.clone(); + bad.coinbase_maturity = 0; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok.clone(); + bad.reorg_watch_depth = 2; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok.clone(); + bad.dust_limit_sat = 0; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok.clone(); + bad.pool_coinbase_script = vec![0x6a, 0x01]; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let mut bad = ok; + bad.change_scripts = vec![Vec::new()]; + assert!(matches!(bad.validate(), Err(LedgerError::InvalidConfig(_)))); + let dir = tempfile::tempdir().fixture("tempdir"); + assert!(matches!( + create(ledger_path(&dir), config(0, 0)).err(), + Some(LedgerError::InvalidConfig(_)) + )); +} + +// --------------------------------------------------------------------------------------------- +// Shares and the PPLNS window +// --------------------------------------------------------------------------------------------- + +fn plain_ledger(window: u128) -> (TempDir, Ledger) { + let dir = tempfile::tempdir().fixture("tempdir"); + let ledger = create(ledger_path(&dir), config(window, 0)).fixture("create"); + (dir, ledger) +} + +#[test] +fn shares_are_idempotent_by_header_hash_and_unique_by_identity() { + let (_dir, ledger) = plain_ledger(100); + let first = share(1, 1, 1, 10); + assert_eq!( + ledger.record_share(&first).fixture("credit"), + ShareReceipt::Credited { seq: 0 } + ); + assert_eq!( + ledger.record_share(&first).fixture("replay"), + ShareReceipt::Duplicate { seq: 0 } + ); + assert_eq!(ledger.share_count().fixture("count"), 1); + assert_eq!( + ledger.share_by_hash(&first.header_hash).fixture("lookup"), + Some((0, 10)) + ); + + let mut other_work = first.clone(); + other_work.work = 11; + assert!(matches!( + ledger.record_share(&other_work).err(), + Some(LedgerError::ShareConflict { seq: 0 }) + )); + + let mut same_identity = first; + same_identity.header_hash = hash(0xEE); + assert!(matches!( + ledger.record_share(&same_identity).err(), + Some(LedgerError::ShareIdentityConflict { seq: 0 }) + )); + + let mut zero = share(1, 1, 2, 1); + zero.work = 0; + assert!(matches!( + ledger.record_share(&zero).err(), + Some(LedgerError::InvalidInput(_)) + )); + let mut long = share(1, 1, 3, 1); + long.extranonce = vec![0; MAX_EXTRANONCE_LEN + 1]; + assert!(matches!( + ledger.record_share(&long).err(), + Some(LedgerError::InvalidInput(_)) + )); + assert_eq!( + ledger.share_count().fixture("count"), + 1, + "rejected shares credit nothing" + ); + ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn window_boundaries_are_exact_in_work_units() { + for (window, expected) in [ + (100u128, vec![(2u64, 40u128), (3, 60)]), + (120, vec![(2, 60), (3, 60)]), + (121, vec![(1, 1), (2, 60), (3, 60)]), + (1_000, vec![(1, 60), (2, 60), (3, 60)]), + (1, vec![(3, 1)]), + ] { + let (_dir, ledger) = plain_ledger(window); + for account in 1..=3 { + ledger + .record_share(&share(1, account, 1, 60)) + .fixture("share"); + } + let weights: Vec<(MinerId, u128)> = + expected.iter().map(|(a, w)| (MinerId(*a), *w)).collect(); + assert_eq!( + ledger.current_window().fixture("window"), + weights, + "window {window}" + ); + } +} + +#[test] +fn one_account_can_hold_several_shares_in_the_window() { + let (_dir, ledger) = plain_ledger(100); + ledger.record_share(&share(1, 1, 1, 30)).fixture("a"); + ledger.record_share(&share(1, 2, 1, 30)).fixture("b"); + ledger.record_share(&share(1, 1, 2, 30)).fixture("c"); + assert_eq!( + ledger.current_window().fixture("window"), + vec![(MinerId(1), 60), (MinerId(2), 30)] + ); +} + +#[test] +fn the_window_slides_across_blocks_and_snapshots_are_frozen() { + let (_dir, ledger) = plain_ledger(100); + ledger.record_share(&share(1, 1, 1, 50)).fixture("a1"); + let first = found(share(1, 2, 1, 50), 10, 1_000); + let first_round = ledger.record_found_block(&first).fixture("block 1").round; + let second = found(share(1, 3, 1, 100), 11, 1_000); + let second_round = ledger.record_found_block(&second).fixture("block 2").round; + assert_eq!( + ledger.round_weights(first_round.id).fixture("weights"), + vec![(MinerId(1), 50), (MinerId(2), 50)] + ); + assert_eq!( + ledger.round_weights(second_round.id).fixture("weights"), + vec![(MinerId(3), 100)] + ); + assert_eq!(first_round.window_work_total, 100); + ledger + .record_share(&share(2, 4, 1, 100)) + .fixture("later share"); + assert_eq!( + ledger.round_weights(first_round.id).fixture("weights"), + vec![(MinerId(1), 50), (MinerId(2), 50)], + "later shares never change a snapshot" + ); +} + +#[test] +fn short_history_uses_all_work_and_pruning_protects_old_windows() { + let (_dir, ledger) = plain_ledger(100); + for account in 1..=6 { + ledger + .record_share(&share(1, account, 1, 50)) + .fixture("share"); + } + assert!(matches!( + ledger.prune_shares(99).err(), + Some(LedgerError::InvalidInput(_)) + )); + assert_eq!(ledger.prune_shares(100).fixture("prune"), 4); + assert_eq!(ledger.share_count().fixture("count"), 2); + assert_eq!( + ledger.current_window().fixture("window"), + vec![(MinerId(5), 50), (MinerId(6), 50)] + ); + assert_eq!(ledger.prune_shares(100).fixture("nothing left to prune"), 0); + let old = found(share(1, 3, 1, 50), 5, 1_000); + assert!(matches!( + ledger.record_found_block(&old).err(), + Some(LedgerError::WindowPruned) + )); + let recent = found(share(1, 6, 1, 50), 6, 1_000); + let recorded = ledger.record_found_block(&recent).fixture("recent block"); + assert_eq!(recorded.share, ShareReceipt::Duplicate { seq: 5 }); + assert_eq!(recorded.round.window_work_total, 100); + ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn retiring_a_job_bounds_the_index_and_refuses_late_shares() { + let (_dir, ledger) = plain_ledger(100); + let old = share(7, 1, 1, 10); + ledger.record_share(&old).fixture("credit"); + ledger.record_share(&share(8, 2, 1, 10)).fixture("other job"); + assert_eq!(ledger.retire_job(7).fixture("retire"), 1); + assert_eq!(ledger.retire_job(7).fixture("again"), 0); + assert_eq!( + ledger.share_by_hash(&old.header_hash).fixture("lookup"), + None + ); + assert!(matches!( + ledger.record_share(&old).err(), + Some(LedgerError::JobRetired(7)) + )); + assert_eq!( + ledger.current_window().fixture("window"), + vec![(MinerId(1), 10), (MinerId(2), 10)], + "retired job work stays in the window" + ); + ledger + .record_share(&share(8, 3, 1, 10)) + .fixture("live job still credits"); +} + +// --------------------------------------------------------------------------------------------- +// Rounds: reward, maturity, reorg +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_found_block_is_recorded_once_and_pays_nothing_until_the_chain_shows_it() { + let fund = staged(0, 1_000_001); + let again = fund.ledger.record_found_block(&fund.block).fixture("replay"); + assert!(!again.newly_recorded); + assert_eq!(again.round.state, RoundState::Pending); + assert_eq!( + again.share, + ShareReceipt::Duplicate { + seq: again.round.solving_seq + } + ); + assert!(!again.round.reward_fixed); + + let mut moved = fund.block.clone(); + moved.height = 101; + assert!(matches!( + fund.ledger.record_found_block(&moved).err(), + Some(LedgerError::BlockConflict) + )); + let mut other_value = fund.block.clone(); + other_value.claimed_coinbase_value_sat += 1; + assert!(matches!( + fund.ledger.record_found_block(&other_value).err(), + Some(LedgerError::BlockConflict) + )); + + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("reconcile"); + assert_eq!(report.transitions, Vec::new(), "block above the tip"); + assert_eq!(report.anomalies, Vec::new(), "block above the tip"); + for account in 1..=3 { + assert_eq!( + fund.ledger.balance(MinerId(account)).fixture("balance"), + Balance::default() + ); + } + assert_eq!( + fund.ledger.round_allocations(1).fixture("allocs"), + Vec::new() + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn rounds_mature_only_after_the_configured_confirmations_on_the_active_chain() { + let fund = staged(0, 1_000_001); + fund.chain.set_tip(100); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("tip 100"); + assert_eq!( + report.transitions, + vec![RoundTransition { + round: 1, + from: RoundState::Pending, + to: RoundState::Immature, + credited_sat: 0, + reversed_sat: 0, + deficit_created_sat: 0, + }] + ); + let round = fund.ledger.round(1).fixture("round").fixture("exists"); + assert!(round.reward_fixed); + assert_eq!( + (round.gross_sat, round.pool_fee_sat, round.net_sat), + (1_000_001, 0, 1_000_001) + ); + assert_eq!( + fund.ledger.round_allocations(1).fixture("allocs"), + miner_amounts(&[(1, 500_001), (2, 300_000), (3, 200_000)]), + "largest remainder takes the residual satoshi" + ); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance"), + Balance::default() + ); + + fund.chain.set_tip(101); + assert_eq!(fund.ledger + .reconcile_rounds(&fund.chain) + .fixture("tip 101") + .transitions + , Vec::new()); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance"), + Balance::default() + ); + + fund.chain.set_tip(102); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("tip 102"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].from, RoundState::Immature); + assert_eq!(report.transitions[0].to, RoundState::Matured); + assert_eq!(report.transitions[0].credited_sat, 1_000_001); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .available_sat, + 500_001 + ); + assert_eq!( + fund.ledger + .balance(MinerId(2)) + .fixture("balance") + .available_sat, + 300_000 + ); + assert_eq!( + fund.ledger + .balance(MinerId(3)) + .fixture("balance") + .available_sat, + 200_000 + ); + let invariants = fund.ledger.check_invariants().fixture("invariants"); + assert_eq!(invariants.credited_sat, 1_000_001); + assert_eq!(invariants.available_sat, 1_000_001); +} + +#[test] +fn the_pool_fee_comes_from_the_observed_reward_and_allocations_sum_to_net() { + let fund = funded(250, 1_000_001); + let round = fund.ledger.round(1).fixture("round").fixture("exists"); + assert_eq!( + (round.gross_sat, round.pool_fee_sat, round.net_sat), + (1_000_001, 25_000, 975_001) + ); + let allocations = fund.ledger.round_allocations(1).fixture("allocs"); + assert_eq!( + allocations, + miner_amounts(&[(1, 487_501), (2, 292_500), (3, 195_000)]) + ); + let sum: u64 = allocations.iter().map(|(_, amount)| *amount).sum(); + assert_eq!(sum, round.net_sat); + assert_eq!(sum + round.pool_fee_sat, round.gross_sat); +} + +#[test] +fn reconciliation_is_idempotent_and_writes_no_audit_noise() { + let fund = funded(0, 1_000_001); + let before = fund.ledger.audit_records(0, 1_000).fixture("audit"); + for _ in 0..3 { + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("again"); + assert_eq!(report.transitions, Vec::new()); + assert_eq!(report.anomalies, Vec::new()); + } + assert_eq!(fund.ledger.audit_records(0, 1_000).fixture("audit"), before); +} + +#[test] +fn a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive() { + let fund = staged(0, 1_000_001); + fund.chain.set_tip(100); + fund.ledger.reconcile_rounds(&fund.chain).fixture("immature"); + fund.chain.set_block(100, hash(0xDD)); + fund.chain.set_tip(101); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("displaced"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].to, RoundState::Orphaned); + assert_eq!(report.transitions[0].reversed_sat, 0); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance"), + Balance::default() + ); + fund.ledger.check_invariants().fixture("invariants"); + + fund.chain.set_block(100, fund.block.share.header_hash); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("revived"); + assert_eq!(report.transitions[0].from, RoundState::Orphaned); + assert_eq!(report.transitions[0].to, RoundState::Immature); + fund.chain.set_tip(102); + fund.ledger.reconcile_rounds(&fund.chain).fixture("matured"); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .available_sat, + 500_001 + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns() { + let fund = funded(0, 1_000_001); + fund.chain.set_block(100, hash(0xDD)); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("displaced"); + assert_eq!(report.transitions.len(), 1); + let transition = report.transitions[0]; + assert_eq!( + (transition.from, transition.to), + (RoundState::Matured, RoundState::Orphaned) + ); + assert_eq!( + (transition.reversed_sat, transition.deficit_created_sat), + (1_000_001, 0) + ); + for account in 1..=3 { + assert_eq!( + fund.ledger.balance(MinerId(account)).fixture("balance"), + Balance::default() + ); + } + fund.ledger + .check_invariants() + .fixture("invariants after reversal"); + + fund.chain.set_block(100, fund.block.share.header_hash); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("returned"); + assert_eq!(report.transitions[0].to, RoundState::Matured); + assert_eq!(report.transitions[0].credited_sat, 1_000_001); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .available_sat, + 500_001 + ); + fund.ledger + .check_invariants() + .fixture("invariants after re-credit"); +} + +#[test] +fn reversing_credits_that_were_already_reserved_creates_an_explicit_deficit() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + assert_eq!(batch.total_sat, 1_000_001); + fund.chain.set_block(100, hash(0xDD)); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("displaced"); + assert_eq!(report.transitions[0].reversed_sat, 1_000_001); + assert_eq!(report.transitions[0].deficit_created_sat, 1_000_001); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!( + ( + balance.available_sat, + balance.reserved_sat, + balance.deficit_sat + ), + (0, 500_001, 500_001) + ); + fund.ledger + .check_invariants() + .fixture("deficit keeps the equation balanced"); + + fund.chain.set_block(100, fund.block.share.header_hash); + fund.ledger.reconcile_rounds(&fund.chain).fixture("returned"); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!( + ( + balance.available_sat, + balance.reserved_sat, + balance.deficit_sat + ), + (0, 500_001, 0), + "re-credit first clears the deficit, never double-credits" + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn unseen_rounds_stop_being_monitored_after_the_watch_depth() { + let fund = staged(0, 1_000_001); + fund.chain.set_block(100, hash(0xDD)); + fund.chain.set_tip(110); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("settle"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].to, RoundState::Orphaned); + fund.chain.set_block(100, fund.block.share.header_hash); + assert_eq!(fund.ledger + .reconcile_rounds(&fund.chain) + .fixture("no longer watched") + .transitions + , Vec::new()); + assert_eq!( + fund.ledger.round(1).fixture("round").fixture("exists").state, + RoundState::Orphaned + ); +} + +#[test] +fn a_claim_the_chain_contradicts_is_surfaced_and_never_applied() { + let fund = staged(0, 1_000_001); + fund.chain.set_tip(102); + let mut wrong_txid = coinbase_for(&fund.block, 1_000_001); + wrong_txid.txid = hash(0x99); + fund.chain + .set_coinbase(fund.block.share.header_hash, wrong_txid); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("reconcile"); + assert_eq!(report.transitions, Vec::new()); + assert_eq!(report.anomalies.len(), 1); + assert_eq!(report.anomalies[0].round, 1); + + fund.chain.set_coinbase( + fund.block.share.header_hash, + coinbase_for(&fund.block, 1_000_000), + ); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("reconcile"); + assert_eq!( + report.anomalies.len(), + 1, + "claimed total differs from the observed total" + ); + assert_eq!( + fund.ledger.round(1).fixture("round").fixture("exists").state, + RoundState::Pending + ); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance"), + Balance::default() + ); + + fund.chain.set_coinbase( + fund.block.share.header_hash, + coinbase_for(&fund.block, 1_000_001), + ); + let report = fund + .ledger + .reconcile_rounds(&fund.chain) + .fixture("reconcile"); + assert_eq!(report.anomalies, Vec::new()); + assert_eq!(report.transitions[0].to, RoundState::Matured); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_coinbase_that_pays_miners_directly_matures_with_a_zero_pool_reward() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + let (ledger, chain, block) = staged_at(&path, 0, 5_000_000, 0); + chain.set_coinbase( + block.share.header_hash, + ObservedCoinbase { + txid: block.coinbase_txid, + outputs: vec![TxOut { + value_sat: 5_000_000, + script_pubkey: script(0x55), + }], + }, + ); + chain.set_tip(102); + let report = ledger.reconcile_rounds(&chain).fixture("reconcile"); + assert_eq!(report.transitions[0].to, RoundState::Matured); + assert_eq!(report.transitions[0].credited_sat, 0); + let round = ledger.round(1).fixture("round").fixture("exists"); + assert_eq!((round.gross_sat, round.net_sat), (0, 0)); + assert!( + ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("prepare") + .is_none() + ); + ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn an_unavailable_observer_changes_nothing() { + let fund = staged(0, 1_000_001); + fund.chain.set_tip(102); + let audit_before = fund.ledger.audit_records(0, 100).fixture("audit"); + fund.chain.take_down(); + assert!(matches!( + fund.ledger.reconcile_rounds(&fund.chain).err(), + Some(LedgerError::Chain(_)) + )); + assert_eq!( + fund.ledger.audit_records(0, 100).fixture("audit"), + audit_before + ); + assert_eq!( + fund.ledger.round(1).fixture("round").fixture("exists").state, + RoundState::Pending + ); + fund.chain.bring_up(); + fund.ledger.reconcile_rounds(&fund.chain).fixture("recovers"); + assert_eq!( + fund.ledger.round(1).fixture("round").fixture("exists").state, + RoundState::Matured + ); +} + +// --------------------------------------------------------------------------------------------- +// Payout batches +// --------------------------------------------------------------------------------------------- + +#[test] +fn payout_scripts_are_validated_and_batches_skip_accounts_without_one() { + let fund = funded(0, 1_000_001); + assert!(matches!( + fund.ledger.set_payout_script(MinerId(9), &[]).err(), + Some(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + fund.ledger + .set_payout_script(MinerId(9), &[0x6a, 0x01, 0x02]) + .err(), + Some(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + fund.ledger + .set_payout_script(MinerId(9), &vec![0x51; MAX_SCRIPT_LEN + 1]) + .err(), + Some(LedgerError::InvalidInput(_)) + )); + assert_eq!(fund.ledger.payout_script(MinerId(9)).fixture("script"), None); + assert_eq!( + fund.ledger.payout_script(MinerId(1)).fixture("script"), + Some(account_script(1)) + ); + fund.ledger + .set_payout_script(MinerId(1), &account_script(1)) + .fixture("setting the same script is a no-op"); +} + +#[test] +fn preparing_a_batch_reserves_balances_atomically_and_never_double_allocates() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + assert_eq!(batch.state, BatchState::Prepared); + assert_eq!(batch.total_sat, 1_000_001); + assert_eq!( + batch.outputs, + vec![ + PayoutOutput { + account: MinerId(1), + script_pubkey: account_script(1), + amount_sat: 500_001 + }, + PayoutOutput { + account: MinerId(2), + script_pubkey: account_script(2), + amount_sat: 300_000 + }, + PayoutOutput { + account: MinerId(3), + script_pubkey: account_script(3), + amount_sat: 200_000 + }, + ] + ); + assert_eq!( + batch.id, + compute_batch_id(&idem(1), &batch.outputs).fixture("id") + ); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.available_sat, balance.reserved_sat), (0, 500_001)); + + let replay = fund + .ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("replay") + .fixture("same batch"); + assert_eq!( + replay, batch, + "same idempotency key returns the immutable batch" + ); + assert!( + fund.ledger + .prepare_payout_batch(idem(2), policy()) + .fixture("second") + .is_none(), + "reserved funds are not available to a second batch" + ); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .reserved_sat, + 500_001 + ); + assert_eq!( + fund.ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup"), + Some(batch.clone()) + ); + assert_eq!(fund.ledger.active_batches().fixture("active"), vec![batch]); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn payout_policy_is_bounded_by_dust_and_output_limits() { + let fund = funded(0, 1_000_001); + assert!(matches!( + fund.ledger + .prepare_payout_batch( + idem(1), + PayoutPolicy { + min_payout_sat: 545, + max_outputs: 10 + } + ) + .err(), + Some(LedgerError::InvalidInput(_)) + )); + assert!(matches!( + fund.ledger + .prepare_payout_batch( + idem(1), + PayoutPolicy { + min_payout_sat: 546, + max_outputs: 0 + } + ) + .err(), + Some(LedgerError::InvalidInput(_)) + )); + let small = fund + .ledger + .prepare_payout_batch( + idem(3), + PayoutPolicy { + min_payout_sat: 546, + max_outputs: 2, + }, + ) + .fixture("prepare") + .fixture("batch"); + assert_eq!( + small.outputs.len(), + 2, + "lowest accounts first, capped at max_outputs" + ); + assert_eq!( + fund.ledger + .balance(MinerId(3)) + .fixture("balance") + .available_sat, + 200_000 + ); + let high = fund + .ledger + .prepare_payout_batch( + idem(4), + PayoutPolicy { + min_payout_sat: 250_000, + max_outputs: 10, + }, + ) + .fixture("prepare"); + assert!( + high.is_none(), + "account 3 holds 200000 < 250000 and the others are reserved" + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], Some(50_000)); + + let signed = fund + .ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + assert_eq!(signed.state, BatchState::ExternallySigned); + assert_eq!(signed.transactions.len(), 1); + assert_eq!(signed.transactions[0].txid, tx.txid); + let replay = fund + .ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("replay"); + assert_eq!(replay, signed, "duplicate receipt changes nothing"); + + assert!(matches!( + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .err(), + Some(LedgerError::NotObserved(_)) + )); + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + let broadcast = fund + .ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("broadcast"); + assert_eq!(broadcast.state, BatchState::Broadcast); + assert_eq!(broadcast.broadcast_txid, Some(tx.txid)); + assert_eq!( + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("replay"), + broadcast + ); + assert_eq!(fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("mempool only") + , Vec::new()); + + confirm_tx(&fund, tx.txid, 0xB1, 102, 102); + assert_eq!(fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("one confirmation") + , Vec::new(), + "payout_confirmations is 2" + ); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance").paid_sat, + 0 + ); + + fund.chain.set_tip(103); + let transitions = fund + .ledger + .reconcile_payouts(&fund.chain) + .fixture("two confirmations"); + assert_eq!( + transitions, + vec![BatchTransition { + batch_id: batch.id, + from: BatchState::Broadcast, + to: BatchState::Confirmed + }] + ); + assert_confirmed_accounting(&fund, &batch, &tx); +} + +fn assert_confirmed_accounting(fund: &Fund, batch: &PayoutBatch, tx: &BuiltTx) { + let confirmed = fund + .ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists"); + assert_eq!( + confirmed.confirmation, + Some(Confirmation { + txid: tx.txid, + block_hash: hash(0xB1), + height: 102, + }) + ); + for (account, paid) in [(1u64, 500_001u64), (2, 300_000), (3, 200_000)] { + let balance = fund.ledger.balance(MinerId(account)).fixture("balance"); + assert_eq!( + ( + balance.available_sat, + balance.reserved_sat, + balance.paid_sat, + ), + (0, 0, paid) + ); + } + let invariants = fund.ledger.check_invariants().fixture("invariants"); + assert_eq!( + (invariants.paid_sat, invariants.reserved_sat), + (1_000_001, 0) + ); + assert_eq!( + fund.ledger.reconcile_payouts(&fund.chain).fixture("again"), + Vec::new() + ); + assert!(matches!( + fund.ledger.request_for(&batch.id).err(), + Some(LedgerError::IllegalTransition { .. }) + )); + assert!(matches!( + fund.ledger + .cancel_prepared(&batch.id, CancelBasis::NeverHandedToSigner) + .err(), + Some(LedgerError::IllegalTransition { .. }) + )); +} + +fn assert_invalid_receipts( + fund: &Fund, + batch: &PayoutBatch, + input: &[OutPoint], + paid: &[(u64, Vec)], +) { + let mut short_amount = paid.to_vec(); + short_amount[0].0 -= 1; + let tx = build_tx(input, &short_amount, Witness::Items); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::MissingOutput { index: 0 })) + )); + let tx = build_tx(input, &paid[..2], Witness::Items); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::MissingOutput { index: 2 })) + )); + let mut stray = paid.to_vec(); + stray.push((10_000, script(0x77))); + let tx = build_tx(input, &stray, Witness::Items); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::UnexpectedOutput { index: 3 })) + )); + let mut two_change = paid.to_vec(); + two_change.push((10_000, change_script())); + two_change.push((10_001, change_script())); + let tx = build_tx(input, &two_change, Witness::Items); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::TooManyExtraOutputs)) + )); + let tx = build_tx(input, paid, Witness::Items); + let mut wrong_txid = receipt(batch, &tx); + wrong_txid.txid = hash(0x99); + assert!(matches!( + fund.ledger.record_signed(&wrong_txid).err(), + Some(LedgerError::Receipt(ReceiptError::TxidMismatch)) + )); + let tx = build_tx( + &[OutPoint { + txid: [0; 32], + vout: u32::MAX, + }], + paid, + Witness::Items, + ); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::NullPrevout)) + )); + let tx = build_tx(&[outpoint(1, 0), outpoint(1, 0)], paid, Witness::Items); + assert!(matches!( + fund.ledger.record_signed(&receipt(batch, &tx)).err(), + Some(LedgerError::Receipt(ReceiptError::DuplicateInput)) + )); + let garbage = PayoutReceipt { + batch_id: batch.id, + txid: hash(1), + raw_tx: vec![1, 2, 3], + }; + assert!(matches!( + fund.ledger.record_signed(&garbage).err(), + Some(LedgerError::Receipt(ReceiptError::Parse(_))) + )); + let unknown = PayoutReceipt { + batch_id: hash(0x42), + txid: tx.txid, + raw_tx: tx.raw, + }; + assert!(matches!( + fund.ledger.record_signed(&unknown).err(), + Some(LedgerError::UnknownBatch(_)) + )); +} + +#[test] +fn a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let input = [outpoint(0xA1, 0)]; + let paid: Vec<(u64, Vec)> = batch + .outputs + .iter() + .map(|output| (output.amount_sat, output.script_pubkey.clone())) + .collect(); + + assert_invalid_receipts(&fund, &batch, &input, &paid); + + assert_eq!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .state, + BatchState::Prepared, + "every refusal left the batch untouched" + ); + let good = build_tx(&input, &paid, Witness::Items); + assert_eq!( + fund.ledger + .record_signed(&receipt(&batch, &good)) + .fixture("exact payment") + .state, + BatchState::ExternallySigned + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn replacements_must_conflict_with_every_earlier_signed_transaction() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let first = pay_tx( + &batch, + &[outpoint(0xA1, 0), outpoint(0xA2, 0)], + Some(40_000), + ); + fund.ledger + .record_signed(&receipt(&batch, &first)) + .fixture("first"); + let disjoint = pay_tx(&batch, &[outpoint(0xA3, 0)], Some(40_000)); + assert!(matches!( + fund.ledger.record_signed(&receipt(&batch, &disjoint)).err(), + Some(LedgerError::Receipt(ReceiptError::NotConflicting { existing_txid })) if existing_txid == first.txid + )); + let replacement = pay_tx( + &batch, + &[outpoint(0xA2, 0), outpoint(0xA4, 0)], + Some(30_000), + ); + let batch_now = fund + .ledger + .record_signed(&receipt(&batch, &replacement)) + .fixture("replacement"); + assert_eq!(batch_now.transactions.len(), 2); + assert_eq!(batch_now.state, BatchState::ExternallySigned); + + fund.chain.set_tx(replacement.txid, TxStatus::InMempool); + fund.ledger + .record_broadcast(&batch.id, &replacement.txid, &fund.chain) + .fixture("broadcast replacement"); + confirm_tx(&fund, replacement.txid, 0xB2, 102, 103); + fund.ledger.reconcile_payouts(&fund.chain).fixture("confirm"); + let confirmed = fund + .ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists"); + assert_eq!(confirmed.state, BatchState::Confirmed); + assert_eq!( + confirmed.confirmation.fixture("confirmation").txid, + replacement.txid + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + confirm_tx(&fund, tx.txid, 0xB1, 102, 103); + fund.ledger.reconcile_payouts(&fund.chain).fixture("confirm"); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance").paid_sat, + 500_001 + ); + + fund.chain.set_block(102, hash(0xB9)); + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + let transitions = fund.ledger.reconcile_payouts(&fund.chain).fixture("reorg"); + assert_eq!( + transitions, + vec![BatchTransition { + batch_id: batch.id, + from: BatchState::Confirmed, + to: BatchState::Broadcast + }] + ); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.reserved_sat, balance.paid_sat), (500_001, 0)); + assert!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .confirmation + .is_none() + ); + fund.ledger + .check_invariants() + .fixture("invariants while reverted"); + + confirm_tx(&fund, tx.txid, 0xB3, 103, 104); + let transitions = fund + .ledger + .reconcile_payouts(&fund.chain) + .fixture("reconfirm"); + assert_eq!(transitions[0].to, BatchState::Confirmed); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance").paid_sat, + 500_001 + ); + + confirm_tx(&fund, tx.txid, 0xB4, 104, 105); + let transitions = fund + .ledger + .reconcile_payouts(&fund.chain) + .fixture("moved block"); + assert_eq!( + (transitions[0].from, transitions[0].to), + (BatchState::Confirmed, BatchState::Confirmed), + "the same payment confirmed in a different block moves the record, not the money" + ); + assert_eq!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .confirmation + .fixture("c") + .height, + 104 + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let first = pay_tx( + &batch, + &[outpoint(0xA1, 0), outpoint(0xA2, 0)], + Some(40_000), + ); + let second = pay_tx( + &batch, + &[outpoint(0xA2, 0), outpoint(0xA3, 0)], + Some(30_000), + ); + fund.ledger + .record_signed(&receipt(&batch, &first)) + .fixture("first"); + fund.ledger + .record_signed(&receipt(&batch, &second)) + .fixture("second"); + + let thief = hash(0xEE); + fund.chain.set_block(104, hash(0xC1)); + fund.chain.set_spend( + outpoint(0xA1, 0), + ConfirmedSpend { + spender_txid: thief, + block_hash: hash(0xC1), + height: 104, + }, + ); + fund.chain.set_tip(105); + assert_eq!(fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("one dead") + , Vec::new(), + "only the first transaction lost an input" + ); + fund.chain.set_spend( + outpoint(0xA3, 0), + ConfirmedSpend { + spender_txid: thief, + block_hash: hash(0xC1), + height: 104, + }, + ); + fund.chain.set_tip(104); + assert_eq!(fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("too shallow") + , Vec::new(), + "depth 1 is below abandon_confirmations" + ); + fund.chain.set_tip(105); + let transitions = fund + .ledger + .reconcile_payouts(&fund.chain) + .fixture("both dead"); + assert_eq!( + transitions, + vec![BatchTransition { + batch_id: batch.id, + from: BatchState::ExternallySigned, + to: BatchState::Failed + }] + ); + let failed = fund + .ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists"); + assert_eq!( + failed.closure, + Some(Closure::Failed { + conflicting_txid: thief + }) + ); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.available_sat, balance.reserved_sat), (500_001, 0)); + assert_eq!(fund.ledger.active_batches().fixture("active"), Vec::new()); + fund.ledger.check_invariants().fixture("invariants"); + + let retry = fund + .ledger + .prepare_payout_batch(idem(2), policy()) + .fixture("retry") + .fixture("a fresh batch"); + assert_ne!(retry.id, batch.id); + assert_eq!(retry.outputs, batch.outputs); + assert!( + matches!( + fund.ledger.record_signed(&receipt(&retry, &first)).err(), + Some(LedgerError::Receipt(ReceiptError::TxidInOtherBatch)) + ), + "a stale transaction of the failed batch can never be attached to its replacement" + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let cancelled = fund + .ledger + .cancel_prepared(&batch.id, CancelBasis::NeverHandedToSigner) + .fixture("cancel"); + assert_eq!(cancelled.state, BatchState::Cancelled); + assert_eq!( + cancelled.closure, + Some(Closure::Cancelled(CancelBasis::NeverHandedToSigner)) + ); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.available_sat, balance.reserved_sat), (500_001, 0)); + assert_eq!( + fund.ledger + .cancel_prepared(&batch.id, CancelBasis::NeverHandedToSigner) + .fixture("idempotent"), + cancelled + ); + assert!(matches!( + fund.ledger + .cancel_prepared(&batch.id, CancelBasis::SignerRevoked) + .err(), + Some(LedgerError::IllegalTransition { .. }) + )); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + assert!(matches!( + fund.ledger.record_signed(&receipt(&batch, &tx)).err(), + Some(LedgerError::IllegalTransition { .. }) + )); + fund.ledger.check_invariants().fixture("invariants"); + + let second = fund + .ledger + .prepare_payout_batch(idem(2), policy()) + .fixture("prepare") + .fixture("batch"); + let tx = pay_tx(&second, &[outpoint(0xA1, 1)], None); + fund.ledger + .record_signed(&receipt(&second, &tx)) + .fixture("signed"); + assert!(matches!( + fund.ledger + .cancel_prepared(&second.id, CancelBasis::SignerRevoked) + .err(), + Some(LedgerError::IllegalTransition { .. }) + )); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let refuser = TestSigner { + inputs: vec![outpoint(0xA1, 0)], + refuse: true, + }; + assert!(matches!( + fund.ledger.sign_via(&batch.id, &refuser).err(), + Some(LedgerError::Signer(_)) + )); + assert_eq!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .state, + BatchState::Prepared + ); + let signer = TestSigner { + inputs: vec![outpoint(0xA1, 0)], + refuse: false, + }; + let signed = fund.ledger.sign_via(&batch.id, &signer).fixture("signed"); + assert_eq!(signed.state, BatchState::ExternallySigned); + fund.ledger.check_invariants().fixture("invariants"); +} + +// --------------------------------------------------------------------------------------------- +// Durability: restart, interrupted commits, process death +// --------------------------------------------------------------------------------------------- + +#[test] +fn state_survives_a_restart_byte_for_byte() { + let fund = funded(250, 1_000_001); + let batch = prepared(&fund); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], Some(1_000)); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + let balances: Vec = (1..=3) + .map(|account| fund.ledger.balance(MinerId(account)).fixture("balance")) + .collect(); + let audit = fund.ledger.audit_records(0, 1_000).fixture("audit"); + let round = fund.ledger.round(1).fixture("round"); + let window = fund.ledger.current_window().fixture("window"); + let path = fund.path.clone(); + let Fund { ledger, _dir: retained_dir, .. } = fund; + drop(ledger); + + let reopened = open(&path, config(100, 250)).fixture("reopen"); + let after: Vec = (1..=3) + .map(|account| reopened.balance(MinerId(account)).fixture("balance")) + .collect(); + assert_eq!(after, balances); + assert_eq!(reopened.audit_records(0, 1_000).fixture("audit"), audit); + assert_eq!(reopened.round(1).fixture("round"), round); + assert_eq!(reopened.current_window().fixture("window"), window); + let again = reopened.batch(&batch.id).fixture("batch").fixture("exists"); + assert_eq!(again.state, BatchState::ExternallySigned); + assert_eq!(again.transactions[0].txid, tx.txid); + assert_eq!( + reopened + .record_signed(&receipt(&batch, &tx)) + .fixture("replay after restart"), + again + ); + reopened.check_invariants().fixture("invariants"); + drop(retained_dir); +} + +fn snapshot(ledger: &Ledger) -> (Vec, Vec, u64, usize) { + let balances = (1..=3) + .map(|account| ledger.balance(MinerId(account)).fixture("balance")) + .collect(); + let audit = ledger.audit_records(0, 10_000).fixture("audit"); + let shares = ledger.share_count().fixture("count"); + let batches = ledger.active_batches().fixture("batches").len(); + (balances, audit, shares, batches) +} + +fn fail(op: &'static str, point: FailPoint) -> FailPlan { + FailPlan { + op, + point, + action: FailAction::ReturnError, + } +} + +fn prepare_after_interrupted_operations() -> (Fund, PayoutBatch) { + let (_dir, ledger) = plain_ledger(100); + let one = share(1, 1, 1, 10); + ledger.arm_fail_plan(fail(OP_RECORD_SHARE, FailPoint::BeforeCommit)); + assert!(matches!( + ledger.record_share(&one).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(ledger.share_count().fixture("count"), 0); + assert_eq!(ledger.share_by_hash(&one.header_hash).fixture("lookup"), None); + assert_eq!( + ledger.record_share(&one).fixture("retry"), + ShareReceipt::Credited { seq: 0 } + ); + + let fund = staged(0, 1_000_001); + let before = snapshot(&fund.ledger); + let other = found(share(1, 3, 2, 20), 101, 1_000); + fund.ledger + .arm_fail_plan(fail(OP_RECORD_BLOCK, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger.record_found_block(&other).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(snapshot(&fund.ledger), before); + assert!( + fund.ledger + .round_for_block(&other.share.header_hash) + .fixture("round") + .is_none() + ); + assert!( + fund.ledger + .record_found_block(&other) + .fixture("retry") + .newly_recorded + ); + + fund.chain.set_tip(102); + let before = snapshot(&fund.ledger); + fund.ledger + .arm_fail_plan(fail(OP_RECONCILE_ROUNDS, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger.reconcile_rounds(&fund.chain).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(snapshot(&fund.ledger), before); + assert_eq!( + fund.ledger + .reconcile_rounds(&fund.chain) + .fixture("retry") + .transitions + .len(), + 1 + ); + fund.ledger.check_invariants().fixture("invariants"); + + let before = snapshot(&fund.ledger); + fund.ledger + .arm_fail_plan(fail(OP_PREPARE_PAYOUT, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger.prepare_payout_batch(idem(1), policy()).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(snapshot(&fund.ledger), before); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .reserved_sat, + 0 + ); + let batch = prepared(&fund); + (fund, batch) +} + +#[test] +fn an_interrupted_commit_loses_the_whole_operation_and_the_retry_succeeds() { + let (fund, batch) = prepare_after_interrupted_operations(); + + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + let before = snapshot(&fund.ledger); + fund.ledger + .arm_fail_plan(fail(OP_RECORD_SIGNED, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger.record_signed(&receipt(&batch, &tx)).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(snapshot(&fund.ledger), before); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("retry"); + + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + fund.ledger + .arm_fail_plan(fail(OP_RECORD_BROADCAST, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .state, + BatchState::ExternallySigned + ); + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("retry"); + + confirm_tx(&fund, tx.txid, 0xB1, 102, 103); + let before = snapshot(&fund.ledger); + fund.ledger + .arm_fail_plan(fail(OP_RECONCILE_PAYOUTS, FailPoint::BeforeCommit)); + assert!(matches!( + fund.ledger.reconcile_payouts(&fund.chain).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(snapshot(&fund.ledger), before); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance").paid_sat, + 0 + ); + assert_eq!( + fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("retry") + .len(), + 1 + ); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_commit_whose_reply_was_lost_is_durable_and_the_replay_changes_nothing() { + let fund = funded(0, 1_000_001); + fund.ledger + .arm_fail_plan(fail(OP_PREPARE_PAYOUT, FailPoint::AfterCommit)); + assert!(matches!( + fund.ledger.prepare_payout_batch(idem(1), policy()).err(), + Some(LedgerError::Injected(_)) + )); + let batch = fund + .ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup") + .fixture("the batch committed"); + let replay = prepared(&fund); + assert_eq!(replay, batch); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .reserved_sat, + 500_001, + "no double reservation" + ); + + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + fund.ledger + .arm_fail_plan(fail(OP_RECORD_SIGNED, FailPoint::AfterCommit)); + assert!(matches!( + fund.ledger.record_signed(&receipt(&batch, &tx)).err(), + Some(LedgerError::Injected(_)) + )); + let signed = fund + .ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("replay"); + assert_eq!(signed.transactions.len(), 1); + + confirm_tx(&fund, tx.txid, 0xB1, 102, 103); + fund.ledger + .arm_fail_plan(fail(OP_RECONCILE_PAYOUTS, FailPoint::AfterCommit)); + assert!(matches!( + fund.ledger.reconcile_payouts(&fund.chain).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!( + fund.ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists") + .state, + BatchState::Confirmed + ); + assert_eq!(fund.ledger + .reconcile_payouts(&fund.chain) + .fixture("replay") + , Vec::new()); + assert_eq!( + fund.ledger.balance(MinerId(1)).fixture("balance").paid_sat, + 500_001, + "paid exactly once" + ); + + let (_dir, ledger) = plain_ledger(100); + let one = share(1, 1, 1, 10); + ledger.arm_fail_plan(fail(OP_RECORD_SHARE, FailPoint::AfterCommit)); + assert!(matches!( + ledger.record_share(&one).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!( + ledger.record_share(&one).fixture("replay"), + ShareReceipt::Duplicate { seq: 0 } + ); + assert_eq!(ledger.share_count().fixture("count"), 1); + fund.ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_fail_plan_fires_once_and_only_for_its_operation() { + let (_dir, ledger) = plain_ledger(100); + ledger.arm_fail_plan(fail(OP_RETIRE_JOB, FailPoint::BeforeCommit)); + ledger + .record_share(&share(1, 1, 1, 10)) + .fixture("other operations are unaffected"); + assert!(matches!( + ledger.retire_job(1).err(), + Some(LedgerError::Injected(_)) + )); + assert_eq!(ledger.retire_job(1).fixture("fires once"), 1); + ledger.arm_fail_plan(fail(OP_RECORD_SHARE, FailPoint::BeforeCommit)); + ledger.disarm_fail_plan(); + ledger.record_share(&share(2, 1, 1, 10)).fixture("disarmed"); +} + +#[cfg(unix)] +mod process_death { + use super::*; + use std::os::unix::process::ExitStatusExt; + use std::process::{Command, ExitStatus, Stdio}; + + const SIGABRT: i32 = 6; + + fn run_child(dir: &Path, mode: &str) -> ExitStatus { + let exe = std::env::current_exe().fixture("test binary path"); + Command::new(exe) + .arg(format!("{BACKEND}::process_death::crash_child_entry")) + .args([ + "--exact", + "--nocapture", + "--test-threads=1", + ]) + .env("POOL_CRASH_DIR", dir) + .env("POOL_CRASH_MODE", mode) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .fixture("spawn the child test binary") + } + + fn crash_chain(txid: Hash32) -> FakeChain { + let chain = FakeChain::default(); + chain.set_block(102, hash(0xB1)); + chain.set_tx( + txid, + TxStatus::Confirmed { + block_hash: hash(0xB1), + height: 102, + }, + ); + chain.set_tip(103); + chain + } + + /// Entry point executed inside the child process. It is a no-op in a normal test run. + #[test] + fn crash_child_entry() { + let Ok(dir) = std::env::var("POOL_CRASH_DIR") else { + return; + }; + let mode = std::env::var("POOL_CRASH_MODE").unwrap_or_default(); + let ledger = + open(PathBuf::from(dir).join("pool.redb"), config(100, 0)).fixture("open"); + let abort = |op: &'static str, point: FailPoint| FailPlan { + op, + point, + action: FailAction::AbortProcess, + }; + match mode.as_str() { + "prepare_before_commit" => { + ledger.arm_fail_plan(abort(OP_PREPARE_PAYOUT, FailPoint::BeforeCommit)); + let _ = ledger.prepare_payout_batch(idem(1), policy()); + } + "prepare_after_commit" => { + ledger.arm_fail_plan(abort(OP_PREPARE_PAYOUT, FailPoint::AfterCommit)); + let _ = ledger.prepare_payout_batch(idem(1), policy()); + } + "reconcile_after_commit" => { + let batch = ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup") + .fixture("seeded batch"); + let chain = crash_chain(batch.transactions[0].txid); + ledger.arm_fail_plan(abort(OP_RECONCILE_PAYOUTS, FailPoint::AfterCommit)); + let _ = ledger.reconcile_payouts(&chain); + } + other => panic!("unknown crash mode {other}"), + } + // The plan did not abort the process: make that visible to the parent. + std::process::exit(3); + } + + fn seed(path: &Path) { + let (ledger, chain, _block) = staged_at(path, 0, 1_000_001, 1_000_001); + chain.set_tip(102); + ledger.reconcile_rounds(&chain).fixture("mature"); + } + + #[test] + fn process_death_before_commit_leaves_the_ledger_exactly_as_it_was() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + seed(&path); + let status = run_child(dir.path(), "prepare_before_commit"); + assert_eq!( + status.signal(), + Some(SIGABRT), + "the child died by SIGABRT mid-operation" + ); + + let ledger = open(&path, config(100, 0)).fixture("recovers after the unclean exit"); + assert!( + ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup") + .is_none() + ); + assert_eq!(ledger.active_batches().fixture("active"), Vec::new()); + let balance = ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.available_sat, balance.reserved_sat), (500_001, 0)); + ledger + .check_invariants() + .fixture("invariants after the crash"); + let batch = ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("retry") + .fixture("batch"); + assert_eq!(batch.total_sat, 1_000_001); + ledger + .check_invariants() + .fixture("invariants after the retry"); + } + + #[test] + fn process_death_after_commit_keeps_the_batch_and_the_retry_never_double_reserves() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + seed(&path); + let status = run_child(dir.path(), "prepare_after_commit"); + assert_eq!(status.signal(), Some(SIGABRT)); + + let ledger = open(&path, config(100, 0)).fixture("recovers"); + let batch = ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup") + .fixture("the commit was durable"); + assert_eq!(batch.state, BatchState::Prepared); + let balance = ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.available_sat, balance.reserved_sat), (0, 500_001)); + let replay = ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("replay") + .fixture("same batch"); + assert_eq!(replay, batch); + assert_eq!( + ledger.balance(MinerId(1)).fixture("balance").reserved_sat, + 500_001 + ); + ledger.check_invariants().fixture("invariants"); + } + + #[test] + fn process_death_after_a_confirmation_commit_pays_exactly_once() { + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + let txid = { + let (ledger, chain, _block) = staged_at(&path, 0, 1_000_001, 1_000_001); + chain.set_tip(102); + ledger.reconcile_rounds(&chain).fixture("mature"); + let batch = ledger + .prepare_payout_batch(idem(1), policy()) + .fixture("prepare") + .fixture("batch"); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], Some(1_000)); + ledger.record_signed(&receipt(&batch, &tx)).fixture("signed"); + tx.txid + }; + let status = run_child(dir.path(), "reconcile_after_commit"); + assert_eq!(status.signal(), Some(SIGABRT)); + + let ledger = open(&path, config(100, 0)).fixture("recovers"); + let batch = ledger + .batch_for_idempotency_key(&idem(1)) + .fixture("lookup") + .fixture("batch"); + assert_eq!( + batch.state, + BatchState::Confirmed, + "the confirmation commit was durable" + ); + let chain = crash_chain(txid); + assert_eq!(ledger.reconcile_payouts(&chain).fixture("replay"), Vec::new()); + let balance = ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!((balance.reserved_sat, balance.paid_sat), (0, 500_001)); + let invariants = ledger.check_invariants().fixture("invariants"); + assert_eq!(invariants.paid_sat, 1_000_001); + } +} + +// --------------------------------------------------------------------------------------------- +// Audit trail +// --------------------------------------------------------------------------------------------- + +#[test] +fn the_audit_chain_is_deterministic_and_detects_tampering() { + let first = funded(0, 1_000_001); + let second = funded(0, 1_000_001); + let chain_of = |fund: &Fund| -> Vec<(AuditKind, u64, Hash32)> { + fund.ledger + .audit_records(0, 1_000) + .fixture("audit") + .into_iter() + .map(|record| (record.kind, record.subject, record.chain)) + .collect() + }; + assert_eq!( + chain_of(&first), + chain_of(&second), + "same inputs give the same hash chain" + ); + let records = first.ledger.audit_records(0, 1_000).fixture("audit"); + assert!( + records + .iter() + .any(|record| record.kind == AuditKind::RoundRecorded) + ); + assert!( + records + .iter() + .any(|record| record.kind == AuditKind::RewardFixed) + ); + assert!( + records + .iter() + .any(|record| record.kind == AuditKind::RoundMatured) + ); + assert!( + records + .iter() + .any(|record| record.kind == AuditKind::PayoutScriptSet) + ); + for (index, record) in records.iter().enumerate() { + assert_eq!(record.seq, u64::try_from(index).fixture("small")); + } + assert_eq!( + first.ledger.audit_records(2, 1).fixture("page").len(), + 1, + "audit records page by sequence" + ); + assert_eq!( + first.ledger.verify_audit_chain().fixture("verify"), + u64::try_from(records.len()).fixture("small") + ); + + let path = first.path.clone(); + let Fund { ledger, _dir: retained_dir, .. } = first; + drop(ledger); + { + let db = backend(&path); + let mut key = b"pool-ledger/v1/".to_vec(); + key.push(18); + key.extend_from_slice(&1u64.to_be_bytes()); + let mut bytes = db.get(ColumnFamily::PoolLedger, &key).fixture("get").fixture("record 1"); + bytes[9] ^= 0xff; + let mut batch = db.new_batch(); + batch.put(ColumnFamily::PoolLedger, &key, &bytes); + db.write_durable(batch).fixture("tamper"); + } + let tampered = open(&path, config(100, 0)).fixture("reopens"); + assert!(matches!( + tampered.verify_audit_chain().err(), + Some(LedgerError::Invariant(_)) + )); + assert!(matches!( + tampered.check_invariants().err(), + Some(LedgerError::Invariant(_)) + )); + drop(tampered); + drop(retained_dir); +} + +#[test] +fn payout_lifecycle_events_are_audited_in_order() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("broadcast"); + confirm_tx(&fund, tx.txid, 0xB1, 102, 103); + fund.ledger.reconcile_payouts(&fund.chain).fixture("confirm"); + let kinds: Vec = fund + .ledger + .audit_records(0, 1_000) + .fixture("audit") + .into_iter() + .map(|record| record.kind) + .filter(|kind| { + matches!( + kind, + AuditKind::BatchPrepared + | AuditKind::BatchSigned + | AuditKind::BatchBroadcast + | AuditKind::BatchConfirmed + ) + }) + .collect(); + assert_eq!( + kinds, + vec![ + AuditKind::BatchPrepared, + AuditKind::BatchSigned, + AuditKind::BatchBroadcast, + AuditKind::BatchConfirmed + ] + ); + let confirmed = fund + .ledger + .audit_records(0, 1_000) + .fixture("audit") + .into_iter() + .find(|record| record.kind == AuditKind::BatchConfirmed) + .fixture("confirmed record"); + assert_eq!(confirmed.hash, batch.id); + assert_eq!(confirmed.hash2, tx.txid); + assert_eq!(confirmed.values[0], 102); +} diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 4a794885bb..21c51fcd38 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [features] default = [] +pool = ["dep:bitcoin-rs-pool-ledger"] silent-payments = ["bitcoin-rs-index/silent-payments"] # Synthetic capability worlds for tests, not production composition. test-seam = ["bitcoin-rs-index/test-seam"] @@ -27,6 +28,7 @@ bitcoin-rs-mempool.workspace = true bitcoin-rs-p2p.workspace = true bitcoin-rs-utxo.workspace = true bitcoin-rs-mining.workspace = true +bitcoin-rs-pool-ledger = { workspace = true, optional = true } bitcoin-rs-script.workspace = true bitcoin.workspace = true miniscript.workspace = true @@ -48,6 +50,7 @@ bytes.workspace = true zmq = { workspace = true, optional = true } [dev-dependencies] +bitcoin-rs-storage = { workspace = true, default-features = false, features = ["fjall"] } bitcoin-rs-rpc = { workspace = true, features = ["test-seam"] } toml.workspace = true bitcoin-rs-e2e.workspace = true diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index bbd4384e2d..bd4eb2ffbc 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -310,6 +310,8 @@ pub struct ContextHandles { pub network: NetworkHandles, /// Mining capability: the template coordinator, when one is attached. pub mining: MiningHandles, + /// Optional node-owned pool accounting service. + pub pool: PoolHandles, /// Live ZMQ notification publisher backing the notifier surface. pub zmq_publisher: Arc, /// Configured node debug-log path for `getrpcinfo`. @@ -508,6 +510,23 @@ pub struct NetworkHandles { pub local_services: u64, } +/// Key-free access to the node-owned durable pool ledger. +#[cfg(feature = "pool")] +pub trait PoolOperator: Send + Sync { + /// Returns the single ledger shared with the pool runtime. + fn ledger(&self) -> &pool_ledger::Ledger; + /// Returns the explicitly configured payout minimum and output ceiling. + fn payout_policy(&self) -> pool_ledger::PayoutPolicy; +} + +/// Optional pool capability; absent in builds without pool support. +#[derive(Default)] +pub struct PoolHandles { + /// Node-owned operator, absent when the pool is disabled at runtime. + #[cfg(feature = "pool")] + pub operator: Option>, +} + /// Mining capability handles. #[derive(Default)] pub struct MiningHandles { @@ -537,6 +556,8 @@ pub struct Context { pub network: NetworkHandles, /// Mining capability: the template coordinator, when one is attached. pub mining: MiningHandles, + /// Optional node-owned pool accounting service. + pub pool: PoolHandles, /// Live ZMQ publisher, also the source of active notifier metadata. pub zmq_publisher: Arc, /// Configured node debug-log path for `getrpcinfo`. @@ -664,6 +685,7 @@ impl Default for ContextHandles { indexes: IndexHandles::default(), network: NetworkHandles::default(), mining: MiningHandles::default(), + pool: PoolHandles::default(), zmq_publisher: Arc::new(crate::zmq::NoOpZmqPublisher), debug_log_path: None, } @@ -727,6 +749,7 @@ impl Context { indexes, network, mining, + pool, zmq_publisher, debug_log_path, } = handles; @@ -740,6 +763,7 @@ impl Context { indexes, network, mining, + pool, zmq_publisher, debug_log_path, server_bound_at: Mutex::new(None), diff --git a/crates/rpc/src/handlers.rs b/crates/rpc/src/handlers.rs index bb8bd58ec1..563219e1d2 100644 --- a/crates/rpc/src/handlers.rs +++ b/crates/rpc/src/handlers.rs @@ -13,6 +13,8 @@ pub(crate) mod deployment; pub(crate) mod mempool; pub(crate) mod mining; pub(crate) mod network; +#[cfg(feature = "pool")] +pub(crate) mod pool; #[cfg(feature = "silent-payments")] pub(crate) mod silent_payments; pub(crate) mod tx; @@ -471,6 +473,28 @@ mod registry_tests { )); } + #[test] + fn pool_extensions_follow_compile_and_runtime_gates() { + let handler = Handler::new(Arc::new(Context::new())); + for method in [ + "registerpoolaccount", + "preparepayoutbatch", + "getpayoutrequest", + "recordpayoutreceipt", + "getpayoutbatch", + "cancelpayoutbatch", + "getpoolinfo", + "getpoolrounds", + "getpoolbalances", + ] { + let result = handler.dispatch(method, &json!([])); + #[cfg(feature = "pool")] + assert!(matches!(result, Err(RpcError::MethodDisabled(_)))); + #[cfg(not(feature = "pool"))] + assert!(matches!(result, Err(RpcError::MethodNotFound(_)))); + } + } + // Bitcoin Core 31.1 parameter-error classification, probed against the // pinned release: a wrong JSON type is -3 in Core's own words, a // correctly typed but unacceptable value is -8, and only shape and diff --git a/crates/rpc/src/handlers/pool.rs b/crates/rpc/src/handlers/pool.rs new file mode 100644 index 0000000000..e37fd8c6f6 --- /dev/null +++ b/crates/rpc/src/handlers/pool.rs @@ -0,0 +1,584 @@ +//! Authenticated management-only, key-free pool accounting extensions. +//! +//! BRPR/BRPT bytes are the ledger's canonical codecs. Recording a receipt +//! validates and persists an externally signed transaction; it never broadcasts it. +use pool_ledger::{ + CancelBasis, Hash32, Ledger, LedgerError, MinerId, PayoutBatch, PayoutPolicy, PayoutReceipt, +}; +use sonic_rs::{JsonContainerTrait as _, JsonValueTrait as _, Value, json}; +use std::sync::Arc; + +use crate::{context::Context, error::RpcError}; + +fn ledger(ctx: &Context) -> Result<&Ledger, RpcError> { + ctx.pool + .operator + .as_ref() + .map(|owner| owner.ledger()) + .ok_or(RpcError::MethodDisabled("Pool service is disabled")) +} + +fn params(params: &Value, min: usize, max: usize) -> Result<&sonic_rs::Array, RpcError> { + let values = params.as_array().ok_or(RpcError::InvalidParams( + "Expected positional parameter array", + ))?; + if values.len() < min || values.len() > max { + return Err(RpcError::InvalidParams( + "Incorrect number of pool parameters", + )); + } + Ok(values) +} + +fn number(value: &Value) -> Result { + value + .as_u64() + .ok_or(RpcError::InvalidParams("Expected unsigned integer")) +} + +fn bytes(value: &Value, max: usize) -> Result, RpcError> { + let text = value + .as_str() + .ok_or(RpcError::InvalidParams("Expected hexadecimal string"))?; + if text.len() > max.saturating_mul(2) { + return Err(RpcError::InvalidParams( + "Hexadecimal payload length is invalid", + )); + } + let (pairs, remainder) = text.as_bytes().as_chunks::<2>(); + if !remainder.is_empty() { + return Err(RpcError::InvalidParams( + "Hexadecimal payload length is invalid", + )); + } + pairs + .iter() + .map(|pair| { + let digit = |byte: u8| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + }; + digit(pair[0]) + .zip(digit(pair[1])) + .map(|(a, b)| a * 16 + b) + .ok_or(RpcError::InvalidParams("Invalid hexadecimal payload")) + }) + .collect() +} + +fn hash(value: &Value) -> Result { + bytes(value, 32)? + .try_into() + .map_err(|_| RpcError::InvalidParams("Expected exactly 32 bytes")) +} + +fn failure(error: &LedgerError) -> RpcError { + match error { + LedgerError::InvalidInput(_) + | LedgerError::Receipt(_) + | LedgerError::Codec(_) + | LedgerError::UnknownBatch(_) + | LedgerError::IllegalTransition { .. } => RpcError::InvalidParameter(error.to_string()), + _ => RpcError::Internal(error.to_string()), + } +} + +fn batch(batch: &PayoutBatch) -> Value { + json!({"batch_id": pool_ledger::to_hex(&batch.id), "sequence": batch.seq, + "state": match batch.state { + pool_ledger::BatchState::Prepared => "prepared", + pool_ledger::BatchState::ExternallySigned => "externally_signed", + pool_ledger::BatchState::Broadcast => "broadcast", + pool_ledger::BatchState::Confirmed => "confirmed", + pool_ledger::BatchState::Cancelled => "cancelled", + pool_ledger::BatchState::Failed => "failed", + }, "revision": batch.rev, + "total_sat": batch.total_sat, "outputs": batch.outputs.iter().map(|output| + json!({"account": output.account.0, "script_pubkey": pool_ledger::to_hex(&output.script_pubkey), + "amount_sat": output.amount_sat})).collect::>(), + "transactions": batch.transactions.iter().map(|tx| pool_ledger::to_hex(&tx.txid)).collect::>(), + "broadcast_txid": batch.broadcast_txid.map(|id| pool_ledger::to_hex(&id)), + "confirmation": batch.confirmation.map(|value| json!({ + "txid": pool_ledger::to_hex(&value.txid), + "block_hash": pool_ledger::to_hex(&value.block_hash), "height": value.height})), + "closure": batch.closure.map(|value| match value { + pool_ledger::Closure::Cancelled(basis) => json!({ + "state": "cancelled", "basis": match basis { + CancelBasis::NeverHandedToSigner => "never_handed_to_signer", + CancelBasis::SignerRevoked => "signer_revoked", + }}), + pool_ledger::Closure::Failed { conflicting_txid } => json!({ + "state": "failed", "conflicting_txid": pool_ledger::to_hex(&conflicting_txid)}), + })}) +} + +pub(crate) fn registerpoolaccount(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 2, 2)?; + let account = MinerId(number(&p[0])?); + let script = bytes(&p[1], 10_000)?; + ledger + .set_payout_script(account, &script) + .map_err(|error| failure(&error))?; + Ok(json!({"account": account.0, "script_pubkey": pool_ledger::to_hex(&script)})) +} + +pub(crate) fn preparepayoutbatch(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 3, 3)?; + let policy = PayoutPolicy { + min_payout_sat: number(&p[1])?, + max_outputs: usize::try_from(number(&p[2])?) + .map_err(|_| RpcError::InvalidParams("Output count out of range"))?, + }; + let configured = ctx + .pool + .operator + .as_ref() + .ok_or(RpcError::MethodDisabled("Pool service is disabled"))? + .payout_policy(); + if policy.min_payout_sat < configured.min_payout_sat + || policy.max_outputs > configured.max_outputs.min(pool_ledger::MAX_BATCH_OUTPUTS) + { + return Err(RpcError::InvalidParameter( + "Requested payout policy exceeds configured pool limits".to_owned(), + )); + } + let Some(prepared) = ledger + .prepare_payout_batch(hash(&p[0])?, policy) + .map_err(|error| failure(&error))? + else { + return Ok(Value::new_null()); + }; + let request = ledger + .request_for(&prepared.id) + .map_err(|error| failure(&error))?; + Ok( + json!({"batch": batch(&prepared), "request": pool_ledger::to_hex(&request.encode().map_err(|error| failure(&error))?)}), + ) +} + +pub(crate) fn getpayoutrequest(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 1, 1)?; + let request = ledger + .request_for(&hash(&p[0])?) + .map_err(|error| failure(&error))?; + Ok(json!({"batch_id": pool_ledger::to_hex(&request.batch_id), + "request": pool_ledger::to_hex(&request.encode().map_err(|error| failure(&error))?)})) +} + +pub(crate) fn recordpayoutreceipt(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 1, 1)?; + let receipt = PayoutReceipt::decode(&bytes(&p[0], pool_ledger::MAX_RAW_TX_BYTES + 128)?) + .map_err(|error| failure(&error))?; + Ok(batch( + &ledger + .record_signed(&receipt) + .map_err(|error| failure(&error))?, + )) +} + +pub(crate) fn getpayoutbatch(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 1, 1)?; + Ok(ledger + .batch(&hash(&p[0])?) + .map_err(|error| failure(&error))? + .as_ref() + .map_or_else(Value::new_null, batch)) +} + +pub(crate) fn cancelpayoutbatch(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 2, 2)?; + let basis = match p[1].as_str() { + Some("never_handed_to_signer") => CancelBasis::NeverHandedToSigner, + Some("signer_revoked") => CancelBasis::SignerRevoked, + _ => { + return Err(RpcError::InvalidParams( + "Cancellation requires never_handed_to_signer or signer_revoked", + )); + } + }; + Ok(batch( + &ledger + .cancel_prepared(&hash(&p[0])?, basis) + .map_err(|error| failure(&error))?, + )) +} + +pub(crate) fn getpoolbalances(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 1, 1)?; + let account = MinerId(number(&p[0])?); + let balance = ledger.balance(account).map_err(|error| failure(&error))?; + Ok( + json!({"account": account.0, "available_sat": balance.available_sat, + "reserved_sat": balance.reserved_sat, "paid_sat": balance.paid_sat, + "deficit_sat": balance.deficit_sat, + "script_pubkey": ledger.payout_script(account).map_err(|error| failure(&error))?.map(|script| pool_ledger::to_hex(&script))}), + ) +} + +pub(crate) fn getpoolrounds(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + let p = params(input, 1, 2)?; + let first = number(&p[0])?; + let count = p.get(1).map(number).transpose()?.unwrap_or(100); + if first == 0 || count > 1000 { + return Err(RpcError::InvalidParams( + "Round start must be positive and count at most 1000", + )); + } + let mut rounds = Vec::new(); + for offset in 0..count { + let id = first + .checked_add(offset) + .ok_or(RpcError::InvalidParams("Round range overflow"))?; + let Some(round) = ledger.round(id).map_err(|error| failure(&error))? else { + break; + }; + rounds.push(json!({"round": round.id, "block_hash": pool_ledger::to_hex(&round.block_hash), + "height": round.height, "state": match round.state { + pool_ledger::RoundState::Pending => "pending", + pool_ledger::RoundState::Immature => "immature", + pool_ledger::RoundState::Matured => "matured", + pool_ledger::RoundState::Orphaned => "orphaned", + }, + "reward_fixed": round.reward_fixed, "gross_sat": round.gross_sat, + "pool_fee_sat": round.pool_fee_sat, "net_sat": round.net_sat, + "window_work": round.window_work_total.to_string(), "solving_sequence": round.solving_seq})); + } + Ok(json!(rounds)) +} + +pub(crate) fn getpoolinfo(ctx: &Arc, input: &Value) -> Result { + let ledger = ledger(ctx)?; + params(input, 0, 0)?; + let audit = ledger.audit_tail(20).map_err(|error| failure(&error))?; + Ok(json!({"reward_model": "PPLNS", "external_signer": true, + "window_work": ledger.config().window_work.to_string(), + "share_count": ledger.share_count().map_err(|error| failure(&error))?, + "audit_tail": audit.iter().map(|record| json!({"sequence": record.seq, + "kind": match record.kind { + pool_ledger::AuditKind::SharesPruned => "shares_pruned", + pool_ledger::AuditKind::RoundRecorded => "round_recorded", + pool_ledger::AuditKind::RewardFixed => "reward_fixed", + pool_ledger::AuditKind::RoundMatured => "round_matured", + pool_ledger::AuditKind::RoundOrphaned => "round_orphaned", + pool_ledger::AuditKind::RoundRevived => "round_revived", + pool_ledger::AuditKind::PayoutScriptSet => "payout_script_set", + pool_ledger::AuditKind::BatchPrepared => "batch_prepared", + pool_ledger::AuditKind::BatchSigned => "batch_signed", + pool_ledger::AuditKind::BatchBroadcast => "batch_broadcast", + pool_ledger::AuditKind::BatchConfirmed => "batch_confirmed", + pool_ledger::AuditKind::BatchReverted => "batch_reverted", + pool_ledger::AuditKind::BatchCancelled => "batch_cancelled", + pool_ledger::AuditKind::BatchFailed => "batch_failed", + }, "subject": record.subject, + "hash": pool_ledger::to_hex(&record.hash), "hash2": pool_ledger::to_hex(&record.hash2), + "values": record.values, "chain": pool_ledger::to_hex(&record.chain)})).collect::>() })) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestOperator(Ledger); + impl crate::context::PoolOperator for TestOperator { + fn ledger(&self) -> &Ledger { + &self.0 + } + fn payout_policy(&self) -> PayoutPolicy { + PayoutPolicy { + min_payout_sat: 546, + max_outputs: 10, + } + } + } + + #[test] + fn enabled_handlers_use_the_real_durable_ledger() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store = Arc::new(bitcoin_rs_storage::FjallStore::open(dir.path())?); + let ledger = Ledger::create(store, pool_ledger::PoolConfig::new(100, vec![0x51]))?; + let context = Arc::new(Context::from_handles(crate::context::ContextHandles { + pool: crate::context::PoolHandles { + operator: Some(Arc::new(TestOperator(ledger))), + }, + ..crate::context::ContextHandles::default() + })); + let registered = registerpoolaccount(&context, &json!([7, "51"]))?; + assert_eq!(registered["script_pubkey"].as_str(), Some("51")); + let balance = getpoolbalances(&context, &json!([7]))?; + assert_eq!(balance["available_sat"].as_u64(), Some(0)); + assert_eq!(balance["script_pubkey"].as_str(), Some("51")); + assert!(registerpoolaccount(&context, &json!([7, "6a"])).is_err()); + assert!( + preparepayoutbatch(&context, &json!([pool_ledger::to_hex(&[1; 32]), 545, 10])).is_err() + ); + assert!( + preparepayoutbatch(&context, &json!([pool_ledger::to_hex(&[1; 32]), 546, 11])).is_err() + ); + let empty = preparepayoutbatch(&context, &json!([pool_ledger::to_hex(&[1; 32]), 546, 10]))?; + assert!(empty.is_null()); + let rounds = getpoolrounds(&context, &json!([1, 10]))?; + assert_eq!(rounds.as_array().map(sonic_rs::Array::len), Some(0)); + let info = getpoolinfo(&context, &json!([]))?; + assert_eq!(info["external_signer"].as_bool(), Some(true)); + assert_eq!( + info["audit_tail"].as_array().map(sonic_rs::Array::len), + Some(1) + ); + let unknown = json!([pool_ledger::to_hex(&[2; 32])]); + assert!(getpayoutbatch(&context, &unknown)?.is_null()); + assert!(getpayoutrequest(&context, &unknown).is_err()); + assert!( + cancelpayoutbatch( + &context, + &json!([pool_ledger::to_hex(&[2; 32]), "never_handed_to_signer"]) + ) + .is_err() + ); + let receipt = PayoutReceipt { + batch_id: [2; 32], + txid: [3; 32], + raw_tx: vec![0], + }; + assert!( + recordpayoutreceipt(&context, &json!([pool_ledger::to_hex(&receipt.encode()?)])) + .is_err() + ); + assert_eq!( + getpoolbalances(&context, &json!([7]))?["reserved_sat"].as_u64(), + Some(0) + ); + Ok(()) + } + + struct FundedChain; + impl pool_ledger::ChainView for FundedChain { + fn tip_height(&self) -> Result { + Ok(200) + } + fn block_location( + &self, + hash: &Hash32, + _: u32, + ) -> Result { + Ok(if *hash == [4; 32] { + pool_ledger::BlockLocation::Canonical { height: 100 } + } else { + pool_ledger::BlockLocation::Unknown + }) + } + fn coinbase_of( + &self, + _: &Hash32, + ) -> Result, pool_ledger::ChainViewError> { + Ok(Some(pool_ledger::ObservedCoinbase { + txid: [5; 32], + outputs: vec![pool_ledger::TxOut { + value_sat: 10_000, + script_pubkey: vec![0x51], + }], + })) + } + fn tx_status( + &self, + _: &Hash32, + ) -> Result { + Ok(pool_ledger::TxStatus::Unknown) + } + fn confirmed_spender( + &self, + _: &pool_ledger::OutPoint, + ) -> Result, pool_ledger::ChainViewError> { + Ok(None) + } + } + + // Same legacy transaction fixture structure as the ledger suite: one + // non-null input, empty scriptSig, one exact payout output. No keys or + // signature-validity claim: the ledger validates payout shape and txid. + fn legacy_payout_raw_tx() -> Vec { + let mut raw = 2i32.to_le_bytes().to_vec(); + raw.push(1); + raw.extend_from_slice(&[9; 32]); + raw.extend_from_slice(&0u32.to_le_bytes()); + raw.push(0); + raw.extend_from_slice(&0xffff_fffdu32.to_le_bytes()); + raw.push(1); + raw.extend_from_slice(&10_000u64.to_le_bytes()); + raw.extend_from_slice(&[1, 0x51]); + raw.extend_from_slice(&0u32.to_le_bytes()); + raw + } + + #[test] + fn funded_external_payout_round_trip_preserves_reservations_on_rejection() + -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store = Arc::new(bitcoin_rs_storage::FjallStore::open(dir.path())?); + let ledger = Ledger::create(store, pool_ledger::PoolConfig::new(100, vec![0x51]))?; + ledger.record_found_block(&pool_ledger::BlockFound { + share: pool_ledger::ShareCredit { + header_hash: [4; 32], + job: 1, + account: MinerId(7), + extranonce: vec![1], + nonce: 1, + work: 100, + }, + height: 100, + coinbase_txid: [5; 32], + claimed_coinbase_value_sat: 10_000, + })?; + ledger.reconcile_rounds(&FundedChain)?; + let context = Arc::new(Context::from_handles(crate::context::ContextHandles { + pool: crate::context::PoolHandles { + operator: Some(Arc::new(TestOperator(ledger))), + }, + ..crate::context::ContextHandles::default() + })); + registerpoolaccount(&context, &json!([7, "51"]))?; + assert_eq!( + getpoolbalances(&context, &json!([7]))?["available_sat"].as_u64(), + Some(10_000) + ); + let prepared = + preparepayoutbatch(&context, &json!([pool_ledger::to_hex(&[8; 32]), 546, 10]))?; + let request_hex = prepared["request"].as_str().ok_or("missing BRPR")?; + let request = pool_ledger::PayoutRequest::decode(&bytes(&json!(request_hex), 100_000)?)?; + assert_eq!(request.outputs.len(), 1); + assert_eq!(request.outputs[0].amount_sat, 10_000); + let batch_id = pool_ledger::to_hex(&request.batch_id); + assert_eq!( + getpayoutrequest(&context, &json!([batch_id]))?["request"].as_str(), + Some(request_hex) + ); + let reserved = getpoolbalances(&context, &json!([7]))?; + assert_eq!(reserved["available_sat"].as_u64(), Some(0)); + assert_eq!(reserved["reserved_sat"].as_u64(), Some(10_000)); + + let raw = legacy_payout_raw_tx(); + let mut receipt = PayoutReceipt { + batch_id: request.batch_id, + txid: [0; 32], + raw_tx: raw.clone(), + }; + let before_rejection = getpayoutbatch(&context, &json!([batch_id]))?; + let rejected = + recordpayoutreceipt(&context, &json!([pool_ledger::to_hex(&receipt.encode()?)])); + let error = match rejected { + Err(error) => error, + Ok(_) => panic!("mismatched txid must be rejected"), + }; + assert_eq!(error.code(), -8); + assert_eq!(error.to_string(), "receipt refused: TxidMismatch"); + assert_eq!( + getpayoutbatch(&context, &json!([batch_id]))?, + before_rejection + ); + assert_eq!(getpoolbalances(&context, &json!([7]))?, reserved); + assert_eq!( + getpayoutbatch(&context, &json!([batch_id]))?["state"].as_str(), + Some("prepared") + ); + receipt.txid = pool_ledger::sha256d(&raw); + let accepted = + recordpayoutreceipt(&context, &json!([pool_ledger::to_hex(&receipt.encode()?)]))?; + assert_eq!(accepted["state"].as_str(), Some("externally_signed")); + assert_eq!(getpoolbalances(&context, &json!([7]))?, reserved); + let refused = cancelpayoutbatch(&context, &json!([batch_id, "signer_revoked"])); + let error = match refused { + Err(error) => error, + Ok(_) => panic!("signed batch must not cancel"), + }; + assert_eq!(error.code(), -8); + assert_eq!( + error.to_string(), + format!("batch {batch_id} in state ExternallySigned cannot cancel") + ); + assert_eq!(getpayoutbatch(&context, &json!([batch_id]))?, accepted); + assert_eq!( + getpayoutbatch(&context, &json!([batch_id]))?["state"].as_str(), + Some("externally_signed") + ); + assert_eq!(getpoolbalances(&context, &json!([7]))?, reserved); + assert_eq!( + recordpayoutreceipt(&context, &json!([pool_ledger::to_hex(&receipt.encode()?)]))?, + accepted + ); + Ok(()) + } + + #[test] + fn disabled_pool_never_returns_fabricated_success() { + let context = Arc::new(Context::new()); + let input = json!([]); + for handler in [ + registerpoolaccount, + preparepayoutbatch, + getpayoutrequest, + recordpayoutreceipt, + getpayoutbatch, + cancelpayoutbatch, + getpoolbalances, + getpoolrounds, + getpoolinfo, + ] { + assert!(matches!( + handler(&context, &input), + Err(RpcError::MethodDisabled(_)) + )); + } + } + + #[test] + fn hexadecimal_payload_is_strict_and_bounded() { + assert_eq!(bytes(&json!("00aAfF"), 3).ok(), Some(vec![0, 170, 255])); + for input in [json!("0"), json!("gg"), json!("0000"), json!(12)] { + assert!(bytes(&input, 1).is_err()); + } + assert!(hash(&json!("00")).is_err()); + } + + #[test] + fn rejects_named_extra_and_negative_parameters() { + assert!(params(&json!({"account": 1}), 1, 1).is_err()); + assert!(params(&json!([1, 2]), 1, 1).is_err()); + assert!(number(&json!(-1)).is_err()); + assert!(number(&json!(1.5)).is_err()); + } + + #[test] + fn external_receipt_hex_preserves_canonical_brpt_and_rejects_trailing_bytes() { + let receipt = PayoutReceipt { + batch_id: [1; 32], + txid: [2; 32], + raw_tx: vec![3, 4], + }; + let Ok(encoded) = receipt.encode() else { + panic!("bounded receipt must encode"); + }; + assert_eq!(&encoded[..5], b"BRPT\x01"); + let encoded_hex = json!(pool_ledger::to_hex(&encoded)); + let Ok(decoded_hex) = bytes(&encoded_hex, pool_ledger::MAX_RAW_TX_BYTES + 128) else { + panic!("canonical hex must decode"); + }; + assert_eq!(PayoutReceipt::decode(&decoded_hex).ok(), Some(receipt)); + let mut trailing = decoded_hex; + trailing.push(0); + assert!(PayoutReceipt::decode(&trailing).is_err()); + } + + #[test] + fn receipt_uses_the_ledger_codec_not_raw_transaction_hex() { + assert!(PayoutReceipt::decode(&[1, 0, 0, 0]).is_err()); + } +} diff --git a/crates/rpc/src/registry.rs b/crates/rpc/src/registry.rs index 51600ffccb..0776608995 100644 --- a/crates/rpc/src/registry.rs +++ b/crates/rpc/src/registry.rs @@ -169,8 +169,31 @@ macro_rules! declare_rows { }; } +// Keep extension rows visible in the manifest without binding absent features. +#[cfg(feature = "pool")] +macro_rules! pool_handler { + ($name:ident) => { + Some(crate::handlers::pool::$name) + }; +} +#[cfg(not(feature = "pool"))] +macro_rules! pool_handler { + ($name:ident) => { + None + }; +} + declare_rows! { "getblocktweaks", SurfaceKind::Rpc, Status::Extension, "silent-payments", "", "Experimental keyless BIP352 extension; requires --silent-payments. One applied-chain height or hash per call, certified retained inputs, final publication check, one in-flight projection, and shared block-render permits. Source read budgets can refuse valid blocks; use another complete source for those blocks. No draft HTTP/gRPC compatibility or wallet discovery claim. See docs/silent-payments.md.", "0.14.0", BLOCK_TWEAKS_HANDLER; + "registerpoolaccount", SurfaceKind::Rpc, Status::Extension, "pool", "", "Authenticated management-only payout-script registration: [account_u64, script_hex]. Changes future batches only and enables public credit-routing login '[.rig]'; miner password is unused. Knowing a login permits contributing credit, not withdrawals or payout-script changes. No mining credential authentication or wallet custody. Requires enabled pool service.", "0.14.0", pool_handler!(registerpoolaccount); + "preparepayoutbatch", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [idempotency_key_hex32, min_payout_sat, max_outputs]. Caller minimum must meet configured payout minimum; output count must not exceed configured ceiling. Durably reserves eligible balances in immutable batch; returns batch status and canonical BRPR request hex, or null when none qualify. Never signs or broadcasts.", "0.14.0", pool_handler!(preparepayoutbatch); + "getpayoutrequest", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [batch_id_hex32]. Exports ledger BRPR v1 hex for external signing; terminal batches refuse export.", "0.14.0", pool_handler!(getpayoutrequest); + "recordpayoutreceipt", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [BRPT_hex]. Strict ledger BRPT v1 decoder and record_signed verify recomputed txid, exact payout outputs, allowed change and replacement conflicts. Records receipt only, never broadcasts; no signature-validity or chain-acceptance claim.", "0.14.0", pool_handler!(recordpayoutreceipt); + "getpayoutbatch", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [batch_id_hex32]. Returns real ledger batch state, recorded transaction ids and observed status; null for unknown batch. No signing, broadcasting or chain-success inference.", "0.14.0", pool_handler!(getpayoutbatch); + "cancelpayoutbatch", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [batch_id_hex32, never_handed_to_signer or signer_revoked]. Operator attests cancellation basis; ledger refuses cancellation after signing. No external signer revocation is performed.", "0.14.0", pool_handler!(cancelpayoutbatch); + "getpoolinfo", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: []. PPLNS configuration, durable share count and last twenty audit records read in one bounded snapshot. Inspect individual batches with getpayoutbatch; no unbounded batch enumeration. Does not verify the full audit chain. Disabled pool returns MethodDisabled, not synthetic status.", "0.14.0", pool_handler!(getpoolinfo); + "getpoolrounds", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [first_round_id, count=100]. Reads monotonic ledger rounds, up to 1000 per call. States reflect hash-keyed chain reconciliation, not submission acknowledgements.", "0.14.0", pool_handler!(getpoolrounds); + "getpoolbalances", SurfaceKind::Rpc, Status::Extension, "pool", "", "Management-only: [account_u64]. Reads one account's available, reserved, paid and deficit satoshis plus payout script. No wallet balance or custody.", "0.14.0", pool_handler!(getpoolbalances); // -- JSON-RPC: shipped methods (registration order) -------------- "getblockchaininfo", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Unavailable optional fields are omitted. Pruning mode/target and signet challenge are not reported (automatic_pruning, prune_target_size, signet_challenge); pruneheight reflects the backing prune service (crates/rpc/src/handlers/chain.rs).", "0.4.0", Some(chain::getblockchaininfo); "getdifficulty", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(chain::getdifficulty); diff --git a/crates/storage/src/column_families.rs b/crates/storage/src/column_families.rs index 0e905a39d0..a4d44d1769 100644 --- a/crates/storage/src/column_families.rs +++ b/crates/storage/src/column_families.rs @@ -29,6 +29,9 @@ pub enum ColumnFamily { /// transaction with live outputs, keyed by the full 256-bit txid and /// valued by the lossless canonical v5 record payload. CoinRecords = 11, + /// Durable pool-ledger rows: one byte-keyed keyspace holding the + /// ledger revision sentinel and its tagged logical tables. + PoolLedger = 12, } impl ColumnFamily { @@ -46,6 +49,7 @@ impl ColumnFamily { Self::UndoData, Self::ScriptLive, Self::CoinRecords, + Self::PoolLedger, ]; /// Stable backend column-family/table name. pub const fn name(self) -> &'static str { @@ -62,6 +66,7 @@ impl ColumnFamily { Self::UndoData => "undo_data", Self::ScriptLive => "script_live", Self::CoinRecords => "coin_records", + Self::PoolLedger => "pool_ledger", } } @@ -80,6 +85,7 @@ impl ColumnFamily { Self::UndoData => 9, Self::ScriptLive => 10, Self::CoinRecords => 11, + Self::PoolLedger => 12, } } } diff --git a/crates/storage/src/redb_impl.rs b/crates/storage/src/redb_impl.rs index b257eef70d..dcc6ff729b 100644 --- a/crates/storage/src/redb_impl.rs +++ b/crates/storage/src/redb_impl.rs @@ -913,6 +913,7 @@ const fn table_for(cf: ColumnFamily) -> ByteTable { ColumnFamily::UndoData => TableDefinition::new("undo_data"), ColumnFamily::ScriptLive => TableDefinition::new("script_live"), ColumnFamily::CoinRecords => TableDefinition::new("coin_records"), + ColumnFamily::PoolLedger => TableDefinition::new("pool_ledger"), } } diff --git a/crates/storage/tests/backend_equivalence.rs b/crates/storage/tests/backend_equivalence.rs index c6c0c346e3..c0121fb331 100644 --- a/crates/storage/tests/backend_equivalence.rs +++ b/crates/storage/tests/backend_equivalence.rs @@ -315,6 +315,90 @@ fn redb_flush_persists_deferred_write_after_reopen() -> TestResult<()> { Ok(()) } +/// Reopening a store must carry every pre-existing row across unchanged and +/// serve the newest column family without any reset or migration. +fn run_reopen_pool_ledger_suite( + open: impl Fn(&std::path::Path) -> Result, +) -> TestResult<()> { + let temp = tempfile::TempDir::new()?; + { + let store = open(temp.path())?; + let mut batch = store.new_batch(); + batch.put(ColumnFamily::BlockHeaders, b"reopen-anchor", b"anchor-v1"); + batch.put(ColumnFamily::PoolLedger, b"ledger-pre", b"pre-v1"); + store.write(batch)?; + store.flush()?; + } + + let reopened = open(temp.path())?; + assert_eq!( + reopened.get(ColumnFamily::BlockHeaders, b"reopen-anchor")?, + Some(b"anchor-v1".to_vec()) + ); + assert_eq!( + reopened.get(ColumnFamily::PoolLedger, b"ledger-pre")?, + Some(b"pre-v1".to_vec()) + ); + let mut batch = reopened.new_batch(); + batch.put(ColumnFamily::PoolLedger, b"ledger-post", b"post-v1"); + reopened.write(batch)?; + assert_eq!( + reopened.get(ColumnFamily::PoolLedger, b"ledger-post")?, + Some(b"post-v1".to_vec()) + ); + Ok(()) +} + +#[cfg(feature = "rocksdb")] +#[test] +fn rocksdb_reopen_preserves_rows_and_serves_pool_ledger() -> TestResult<()> { + run_reopen_pool_ledger_suite(|path| bitcoin_rs_storage::RocksDbStore::open(path)) +} + +#[cfg(feature = "fjall")] +#[test] +fn fjall_reopen_preserves_rows_and_serves_pool_ledger() -> TestResult<()> { + run_reopen_pool_ledger_suite(|path| bitcoin_rs_storage::FjallStore::open(path)) +} + +#[cfg(feature = "redb")] +#[test] +fn redb_reopen_preserves_rows_and_serves_pool_ledger() -> TestResult<()> { + run_reopen_pool_ledger_suite(|path| bitcoin_rs_storage::RedbStore::open(path)) +} + +/// Builds a raw redb database whose schema predates `PoolLedger` (only the +/// `block_headers` table exists) and proves opening it creates the missing +/// table while legacy rows survive untouched. +#[cfg(feature = "redb")] +#[test] +fn redb_open_creates_missing_pool_ledger_table_without_reset() -> TestResult<()> { + let temp = tempfile::TempDir::new()?; + let path = temp.path().join("redb.db"); + let legacy: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("block_headers"); + { + let db = redb::Database::create(&path)?; + let txn = db.begin_write()?; + { + let mut table = txn.open_table(legacy)?; + table.insert(b"legacy-header".as_slice(), b"legacy-value".as_slice())?; + } + txn.commit()?; + } + + let store = bitcoin_rs_storage::RedbStore::open(&path)?; + assert_eq!( + store.get(ColumnFamily::BlockHeaders, b"legacy-header")?, + Some(b"legacy-value".to_vec()) + ); + store.put(ColumnFamily::PoolLedger, b"ledger-first", b"row-v1")?; + assert_eq!( + store.get(ColumnFamily::PoolLedger, b"ledger-first")?, + Some(b"row-v1".to_vec()) + ); + Ok(()) +} + #[cfg(any(feature = "rocksdb", feature = "fjall", feature = "redb"))] #[test] fn portable_backends_have_identical_aggregate_hashes() -> TestResult<()> { diff --git a/crates/stratum-v2/Cargo.toml b/crates/stratum-v2/Cargo.toml new file mode 100644 index 0000000000..f2e6c5af5b --- /dev/null +++ b/crates/stratum-v2/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "bitcoin-rs-stratum-v2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false +description = "Sans-IO Stratum V2 mining-pool server adapter for bitcoin-rs" + +[lints] +workspace = true + +[lib] +name = "bitcoin_rs_stratum_v2" +path = "src/lib.rs" + +[dependencies] +# One job / share / stale / duplicate owner. This crate never assembles a +# template and never validates a share or a block itself. +bitcoin-rs-stratum = { workspace = true } +# The typed host boundary for submitting a found block before ledger record +# and SV2 acknowledgement. +bitcoin-rs-mining = { workspace = true } + +# Stratum Reference Implementation crates. These are semver-major-coupled; +# the wire layouts MUST remain pinned as a release family. +binary_sv2 = { workspace = true } +framing_sv2 = { workspace = true } +codec_sv2 = { workspace = true, features = ["noise_sv2"] } +noise_sv2 = { workspace = true } +mining_sv2 = { workspace = true } +common_messages_sv2 = { workspace = true } + +ruint = { workspace = true } +thiserror = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +bitcoin-rs-primitives = { workspace = true } +bitcoin-rs-consensus = { workspace = true, default-features = false } +bitcoin-rs-script = { workspace = true, default-features = false } +bitcoin-rs-chain = { workspace = true, default-features = false } +bitcoin-rs-mempool = { workspace = true } +parking_lot = { workspace = true } +sha2 = { workspace = true } +# noise_sv2 2.0.0 resolves secp256k1 0.28.2; this test-only key generator +# rides the same narrow, parent-approved duplicate-crypto exception. +secp256k1 = { version = "=0.28.2", default-features = false, features = ["std", "rand-std"] } +rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } diff --git a/crates/stratum-v2/src/lib.rs b/crates/stratum-v2/src/lib.rs new file mode 100644 index 0000000000..3c9ddb2a50 --- /dev/null +++ b/crates/stratum-v2/src/lib.rs @@ -0,0 +1,809 @@ +//! Native Stratum V2 mining-pool adapter. +//! +//! A pool-side Stratum V2 server for `bitcoin-rs`, built from three layers: +//! +//! 1. **Authenticated transport** ([`Connection`]): the `Noise_NX_Secp256k1+ +//! EllSwift_ChaChaPoly_SHA256` responder of the Stratum V2 specification, +//! run by the Stratum Reference Implementation's own `noise_sv2` and +//! `codec_sv2` (never home-grown crypto). The pool authority key crosses +//! this crate as 32 raw bytes ([`AuthorityKeypair`]), so no `secp256k1` +//! type is shared with the repository's `secp256k1 0.29` graph: +//! `noise_sv2 2.0.0` resolves its own `secp256k1 0.28.2` and the two meet +//! only at the byte boundary. +//! 2. **Protocol session** ([`ProtocolSession`]): `SetupConnection`, standard, +//! extended and group channels, `NewMiningJob` / `NewExtendedMiningJob` / +//! `SetNewPrevHash` / `SetTarget` / `SubmitShares*`, `UpdateChannel`, +//! `CloseChannel`, `Reconnect`, and the error and cancel semantics around +//! them, using the official message layouts from `mining_sv2 12.0.0` and +//! `common_messages_sv2 9.0.0`. +//! 3. **The one job and share owner**, [`bitcoin_rs_stratum::JobBook`]. This +//! crate never assembles a template, never builds a coinbase, and never +//! validates a share or a block: it passes the owner's `Job` to the wire +//! and maps the owner's `ShareError` to an official error code. +//! +//! # What is real, and what is not +//! +//! Implemented and exercised by `tests/protocol.rs` with real Noise +//! handshakes, real encrypted frames and real regtest candidates: the Mining +//! Protocol server role. [`ROLES`] states every other role explicitly; none of +//! them is advertised in `SetupConnection`: +//! +//! - Job Declaration server and work selection (`SetCustomMiningJob`, +//! `REQUIRES_WORK_SELECTION`) are not implemented: the owner exposes no way +//! to install a miner-declared job (`JobBook::install` takes a pool-built +//! `Candidate`), and adding one here would be a second template and +//! validation path. `SetupConnection` for the Job Declaration protocol is +//! refused with `unsupported-protocol`; the work-selection flag with +//! `unsupported-feature-flags`; a stray `SetCustomMiningJob` with +//! `jd-not-supported`. +//! - Template Distribution (serving or consuming) is not needed by a native +//! pool, whose templates come from `MiningControl`, and is not implemented. +//! - Extension negotiation is not implemented; frames with a non-zero +//! `extension_type` are discarded as §3.4.1 requires. +//! +//! Not run: a network daemon, a real key, a real funds path. Nothing in this +//! crate opens a socket: [`Connection`] is sans-IO (`feed` bytes in, +//! `take_transmit` bytes out). +//! +//! # Host loop +//! +//! ```text +//! let mut conn = Connection::accept(&authority, validity, config, &book)?; +//! loop { +//! // read up to conn.read_hint().max(4096) bytes from the socket, then +//! conn.feed(&bytes, &mut book)?; +//! socket.write_all(&conn.take_transmit()); +//! while let Some(event) = conn.poll_event() { +//! match event { +//! Event::ShareAccepted(e) => { +//! ledger.record(&e.outcome); +//! conn.confirm_share(e.ticket)?; +//! } +//! Event::BlockFound(pending) => { +//! let submitted = pending.submit(&*mining_control)?; +//! // The pool service maps pending.found_block() plus +//! // submitted.receipt() into its ledger BlockFound record here. +//! conn.confirm_block(&submitted)?; +//! } +//! ... +//! } +//! } +//! // when the node installs a job: conn.announce(&installed)?; +//! } +//! conn.release(&mut book); // always, on every exit path +//! ``` + +#![forbid(unsafe_code)] + +mod session; + +pub use session::{ + AdmitError, AdmitUser, BlockSubmission, ChannelInfo, ChannelKind, CloseReason, + ERROR_CODE_INVALID_SEQUENCE_NUMBER, ERROR_CODE_JOB_SHARE_CAPACITY, ERROR_CODE_POOL_BUSY, + ERROR_CODE_RECONNECT_REQUESTED, ERROR_CODE_SHARE_NOT_RECORDED, Event, + FLAG_REQUIRES_STANDARD_JOBS, FLAG_REQUIRES_VERSION_ROLLING, FLAG_REQUIRES_WORK_SELECTION, + Limits, PendingBlock, ProtocolSession, SUCCESS_FLAG_REQUIRES_EXTENDED_CHANNELS, + SUCCESS_FLAG_REQUIRES_FIXED_VERSION, ServerConfig, SessionError, ShareEvent, ShareTicket, + target_difficulty, +}; + +use std::fmt; +use std::time::Duration; + +use bitcoin_rs_stratum::{Installed, JobBook, ShareTarget}; +use codec_sv2::{ + Decoded, Decrypted, ENCRYPTED_SV2_FRAME_HEADER_SIZE, Handshake, NoiseDecoder, NoiseEncoder, + SV2_FRAME_PLAINTEXT_CHUNK_SIZE, SerializedFrame, TransportDecryptState, TransportEncryptState, +}; +use framing_sv2::SV2_FRAME_HEADER_SIZE; +use noise_sv2::{AEAD_MAC_LEN, ELLSWIFT_ENCODING_SIZE, Responder}; +use thiserror::Error; +use zeroize::Zeroizing; + +/// Stratum V2 specification revision this crate implements: the +/// `stratum-mining/sv2-spec` commit of 2026-10-15. +pub const SPEC_REVISION: &str = "025e2f76802d5942a10f6f3fea07941522b108ca"; +/// Stratum Reference Implementation release whose crates.io line is used +/// (`stratum-core 0.6.0`, 2026-09-17). +pub const SRI_RELEASE: &str = "v1.12.0"; +/// The only protocol version Stratum V2 defines (`SetupConnection` §3.6.1). +pub const PROTOCOL_VERSION: u16 = 2; +/// Largest value of the U24 `msg_length` frame field. +pub const U24_MAX: usize = 0x00ff_ffff; +/// Largest `B0_32` field: a standard channel's full extranonce must fit it. +pub const MAX_EXTRANONCE_WIRE_LEN: usize = 32; +/// Largest single `feed` the transport accepts; the pending input buffer is +/// therefore bounded by one decoder chunk plus this. +pub const MAX_FEED_LEN: usize = 1 << 17; + +/// One plaintext SV2 frame, decrypted (or about to be encrypted). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Frame { + /// `extension_type` without the channel bit; 0 for every core message. + pub extension_type: u16, + /// Message type. + pub msg_type: u8, + /// The `channel_msg` bit. + pub channel_msg: bool, + /// Message payload, without the 6-byte header. + pub payload: Vec, +} + +/// Whether one protocol role is implemented by this crate. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RoleStatus { + /// Implemented and covered by `tests/protocol.rs`. + Implemented, + /// Not implemented; never advertised. + NotImplemented, + /// Not needed by a native pool. + NotApplicable, +} + +/// One row of the role matrix. +#[derive(Clone, Copy, Debug)] +pub struct Role { + /// Role or capability. + pub name: &'static str, + /// Where the specification defines it. + pub spec: &'static str, + /// Whether this crate implements it. + pub status: RoleStatus, + /// Behaviour or the exact missing fact. + pub detail: &'static str, +} + +/// The full role matrix of this adapter. Anything not `Implemented` is a fact +/// about what is missing, not a claim of support. +pub const ROLES: &[Role] = &[ + Role { + name: "Noise_NX authenticated transport, responder", + spec: "04 §4.5", + status: RoleStatus::Implemented, + detail: "noise_sv2 2.0.0 / codec_sv2 7.0.0; certificate signed by the pool authority key", + }, + Role { + name: "SetupConnection (mining protocol), Success, Error", + spec: "03 §3.6.1-3.6.3, 05 §5.3.1", + status: RoleStatus::Implemented, + detail: "version 2 only; REQUIRES_STANDARD_JOBS honoured; REQUIRES_FIXED_VERSION set when the job owner has no version mask", + }, + Role { + name: "Standard channels", + spec: "05 §5.2.1", + status: RoleStatus::Implemented, + detail: "pool-fixed extranonce; merkle root from bitcoin_rs_stratum::Job::merkle_root", + }, + Role { + name: "Extended channels", + spec: "05 §5.2.2", + status: RoleStatus::Implemented, + detail: "extranonce_size is the owner's rolled length; min_extranonce_size larger than that is refused", + }, + Role { + name: "Group channels", + spec: "05 §5.2.3", + status: RoleStatus::Implemented, + detail: "one group per connection holding every channel; group-addressed NewExtendedMiningJob, SetNewPrevHash, SetTarget and CloseChannel; SetGroupChannel (regrouping) is never sent", + }, + Role { + name: "Job and share lifecycle", + spec: "05 §5.3.11-5.3.17, 5.3.21", + status: RoleStatus::Implemented, + detail: "NewMiningJob, NewExtendedMiningJob, SetNewPrevHash, SetTarget, SubmitShares*, UpdateChannel, CloseChannel; SubmitShares.Success is held until the host confirms the ledger record", + }, + Role { + name: "Reconnect", + spec: "03 §3.6.5", + status: RoleStatus::Implemented, + detail: "request_reconnect sends Reconnect and refuses new channels afterwards", + }, + Role { + name: "SetExtranoncePrefix", + spec: "05 §5.3.10", + status: RoleStatus::NotImplemented, + detail: "a session's extranonce prefix is fixed for its life; the owner reuses prefixes only after close_session", + }, + Role { + name: "ChannelEndpointChanged", + spec: "03 §3.6.4", + status: RoleStatus::NotApplicable, + detail: "proxy-only message; a pool is the channel endpoint", + }, + Role { + name: "Work selection and custom jobs (SetCustomMiningJob)", + spec: "05 §5.3.18-5.3.20", + status: RoleStatus::NotImplemented, + detail: "bitcoin_rs_stratum::JobBook::install accepts only a pool-built Candidate; a miner-declared job needs an owner-side install and validate path that does not exist, and a second one here would duplicate template and share validation", + }, + Role { + name: "Job Declaration server", + spec: "06", + status: RoleStatus::NotImplemented, + detail: "same missing fact as custom jobs, plus declared-transaction validation against the mempool; SetupConnection for protocol 1 is refused with unsupported-protocol", + }, + Role { + name: "Template Distribution server (Template Provider)", + spec: "07", + status: RoleStatus::NotImplemented, + detail: "serving templates to a remote job declarator is not part of a pool; SetupConnection for protocol 2 is refused with unsupported-protocol", + }, + Role { + name: "Template Distribution client", + spec: "07", + status: RoleStatus::NotApplicable, + detail: "templates come from MiningControl in process, not from a Template Provider", + }, + Role { + name: "Extensions negotiation", + spec: "03 §3.4, extensions/0x0001", + status: RoleStatus::NotImplemented, + detail: "frames with a non-zero extension_type are discarded and counted, as §3.4.1 requires for unknown extensions", + }, +]; + +/// Configuration refused before any connection state exists. +#[derive(Debug, Eq, Error, PartialEq)] +pub enum ConfigError { + /// A resource limit is out of range. + #[error("invalid limits: {0}")] + Limits(&'static str), + /// The pool authority key pair is not a valid matching pair. + #[error("authority key pair rejected: {0}")] + Authority(String), + /// Certificate validity is zero or does not fit a U32 of seconds. + #[error("certificate validity must be between 1 second and u32::MAX seconds")] + CertValidity, + /// The owner's extranonce layout cannot be carried by a `B0_32` field. + #[error("extranonce layout of {total} bytes exceeds the {max}-byte wire field")] + ExtranonceTooLong { + /// `session_len + rolled_len`. + total: usize, + /// Wire maximum. + max: usize, + }, +} + +/// Transport failures. All of them are fatal for the connection: after a +/// failed decryption the cipher nonce no longer matches the peer's. +#[derive(Debug, Error)] +pub enum TransportError { + /// The Noise handshake failed. + #[error("noise handshake failed: {0}")] + Handshake(String), + /// A frame failed authentication (bad MAC) or decoding. + #[error("frame failed authentication or decoding: {0}")] + Decrypt(String), + /// A frame declares more payload than the configured limit. + #[error("frame needs more than {limit} payload bytes")] + FrameTooLarge { + /// Configured payload limit. + limit: usize, + }, + /// A single `feed` call exceeded the bound. + #[error("read of {len} bytes exceeds the {max}-byte feed bound")] + FeedTooLarge { + /// Offered length. + len: usize, + /// Bound. + max: usize, + }, + /// The decoder asked for no input and produced no frame. + #[error("decoder made no progress")] + Stalled, + /// An outbound frame could not be encoded. + #[error("cannot encode outbound frame: {0}")] + Encode(String), + /// The peer is not draining output. + #[error("outbound byte queue would exceed {limit}")] + OutboxFull { + /// Bound. + limit: usize, + }, + /// An application frame before the handshake completed. + #[error("transport is not open")] + NotOpen, + /// The transport failed earlier. + #[error("transport failed earlier")] + Failed, +} + +/// Any failure of a [`Connection`] call. +#[derive(Debug, Error)] +pub enum Error { + /// The encrypted transport failed. + #[error(transparent)] + Transport(#[from] TransportError), + /// The peer violated the protocol, or a host call was invalid. + #[error(transparent)] + Session(#[from] SessionError), + /// The connection already failed; release it and drop the socket. + #[error("connection is closed")] + Closed, +} + +impl Error { + /// Whether the connection is unusable after this error. + /// + /// Transport failures and protocol violations are fatal. A host call that + /// named an unknown channel, asked for a target above the miner's maximum, + /// or passed a malformed error code is refused without touching the + /// connection. + #[must_use] + pub fn is_fatal(&self) -> bool { + match self { + Self::Transport(_) | Self::Closed => true, + Self::Session(error) => error.is_fatal(), + } + } +} + +/// The pool authority key pair, as raw 32-byte x-only keys. +/// +/// Raw bytes are the whole interface to `noise_sv2` on purpose: the secret +/// never becomes a `secp256k1` type of a version this crate does not control, +/// and it is wiped when dropped. +pub struct AuthorityKeypair { + public: [u8; 32], + secret: Zeroizing<[u8; 32]>, +} + +impl AuthorityKeypair { + /// Wraps the BIP340 x-only public key and an already-zeroizing 32-byte + /// secret. [`Connection::accept`] verifies that the pair matches. + /// + /// Requiring [`Zeroizing`] at this boundary ensures the caller's secret + /// buffer is wiped on drop rather than merely copying it into the + /// authority object. + #[must_use] + pub fn from_bytes(public: [u8; 32], secret: Zeroizing<[u8; 32]>) -> Self { + Self { public, secret } + } + + /// The public key miners pin. + #[must_use] + pub fn public(&self) -> &[u8; 32] { + &self.public + } +} + +impl fmt::Debug for AuthorityKeypair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthorityKeypair") + .field("public", &self.public) + .field("secret", &"") + .finish() + } +} + +enum Stage { + Handshake(Option>), + Open { + enc: TransportEncryptState, + dec: Option, + }, + Failed, +} + +/// Encrypted length of a frame whose plaintext payload is `payload` bytes: +/// the encrypted header, the payload, and one MAC per plaintext chunk. +fn encrypted_frame_len(payload: usize) -> usize { + ENCRYPTED_SV2_FRAME_HEADER_SIZE + + payload + + payload.div_ceil(SV2_FRAME_PLAINTEXT_CHUNK_SIZE) * AEAD_MAC_LEN +} + +fn frame_bytes(frame: &Frame) -> Result, TransportError> { + let len = frame.payload.len(); + let len32 = u32::try_from(len) + .ok() + .filter(|_| len <= U24_MAX) + .ok_or_else(|| TransportError::Encode(format!("payload of {len} bytes exceeds the U24")))?; + let mut extension = frame.extension_type & 0x7fff; + if frame.channel_msg { + extension |= 0x8000; + } + let mut bytes = Vec::with_capacity(SV2_FRAME_HEADER_SIZE + len); + bytes.extend_from_slice(&extension.to_le_bytes()); + bytes.push(frame.msg_type); + bytes.extend_from_slice(&len32.to_le_bytes()[..3]); + bytes.extend_from_slice(&frame.payload); + Ok(bytes) +} + +/// `Noise_NX` responder plus bounded `SV2` framing. +/// +/// The decoder is fed exactly the byte count it asks for, and the number of +/// encrypted bytes fed for the current frame is checked against the ceiling +/// the configured payload limit implies *before* each read, so an oversized +/// declaration is refused after the encrypted header, never buffered. +struct Transport { + decoder: NoiseDecoder, + encoder: NoiseEncoder, + stage: Stage, + inbuf: Vec, + fed: usize, + outbox: Vec, + max_payload: usize, + max_outbox: usize, +} + +impl Transport { + fn accept( + authority: &AuthorityKeypair, + validity: Duration, + limits: &Limits, + ) -> Result { + if validity.as_secs() == 0 || validity.as_secs() > u64::from(u32::MAX) { + return Err(ConfigError::CertValidity); + } + let responder = + Responder::from_authority_kp(&authority.public, &authority.secret, validity) + .map_err(|e| ConfigError::Authority(format!("{e:?}")))?; + Ok(Self { + decoder: NoiseDecoder::new(), + encoder: NoiseEncoder::new(), + stage: Stage::Handshake(Some(Handshake::responder(responder))), + inbuf: Vec::new(), + fed: 0, + outbox: Vec::new(), + max_payload: limits.max_frame_payload, + max_outbox: limits.max_outbound_bytes, + }) + } + + fn read_hint(&self) -> usize { + self.decoder + .writable_len() + .saturating_sub(self.inbuf.len()) + .max(1) + } + + fn ceiling(&self) -> usize { + match self.stage { + Stage::Handshake(_) => ELLSWIFT_ENCODING_SIZE, + _ => encrypted_frame_len(self.max_payload), + } + } + + fn feed(&mut self, data: &[u8]) -> Result, TransportError> { + if matches!(self.stage, Stage::Failed) { + return Err(TransportError::Failed); + } + if data.len() > MAX_FEED_LEN { + return Err(TransportError::FeedTooLarge { + len: data.len(), + max: MAX_FEED_LEN, + }); + } + self.inbuf.extend_from_slice(data); + match self.pump() { + Ok(frames) => Ok(frames), + Err(error) => { + self.stage = Stage::Failed; + self.inbuf.clear(); + Err(error) + } + } + } + + fn pump(&mut self) -> Result, TransportError> { + let mut frames = Vec::new(); + let mut idle = 0_u8; + loop { + let want = self.decoder.writable_len(); + if self.inbuf.len() < want { + break; + } + if want == 0 { + idle += 1; + if idle > 2 { + return Err(TransportError::Stalled); + } + } else { + idle = 0; + } + if self.fed.saturating_add(want) > self.ceiling() { + return Err(TransportError::FrameTooLarge { + limit: self.max_payload, + }); + } + if want > 0 { + let window = self.decoder.writable(); + window.copy_from_slice(&self.inbuf[..want]); + self.inbuf.drain(..want); + self.fed += want; + } + if matches!(self.stage, Stage::Handshake(_)) { + self.step_handshake()?; + } else { + self.step_transport(&mut frames)?; + } + } + Ok(frames) + } + + fn step_handshake(&mut self) -> Result<(), TransportError> { + let message = match self + .decoder + .next_handshake_frame::() + .map_err(|e| TransportError::Handshake(e.to_string()))? + { + Decoded::Incomplete(_) => return Ok(()), + Decoded::Frame(message) => message, + }; + let initiator_key: [u8; ELLSWIFT_ENCODING_SIZE] = message + .payload() + .try_into() + .map_err(|_| TransportError::Handshake("act 1 has the wrong length".to_owned()))?; + let handshake = match &mut self.stage { + Stage::Handshake(slot) => slot.take().ok_or(TransportError::Failed)?, + _ => return Err(TransportError::Failed), + }; + let (reply, transport) = handshake + .step_1(initiator_key) + .map_err(|e| TransportError::Handshake(e.to_string()))?; + let encoded = self.encoder.encode_handshake(reply); + let bytes: &[u8] = encoded.as_ref(); + if self.outbox.len() + bytes.len() > self.max_outbox { + return Err(TransportError::OutboxFull { + limit: self.max_outbox, + }); + } + self.outbox.extend_from_slice(bytes); + let (enc, dec) = transport.split(); + self.stage = Stage::Open { + enc, + dec: Some(dec), + }; + self.fed = 0; + Ok(()) + } + + fn step_transport(&mut self, frames: &mut Vec) -> Result<(), TransportError> { + let state = match &mut self.stage { + Stage::Open { dec, .. } => dec.take().ok_or(TransportError::Failed)?, + _ => return Err(TransportError::NotOpen), + }; + match self.decoder.next_transport_frame(state) { + Ok(Decrypted::Frame(mut frame, state)) => { + if let Stage::Open { dec, .. } = &mut self.stage { + *dec = Some(state); + } + self.fed = 0; + let header = frame.header(); + let payload = frame.payload().to_vec(); + if payload.len() > self.max_payload { + return Err(TransportError::FrameTooLarge { + limit: self.max_payload, + }); + } + frames.push(Frame { + extension_type: header.ext_type_without_channel_msg(), + msg_type: header.msg_type(), + channel_msg: header.channel_msg(), + payload, + }); + Ok(()) + } + Ok(Decrypted::Incomplete(_, state)) => { + if let Stage::Open { dec, .. } = &mut self.stage { + *dec = Some(state); + } + Ok(()) + } + // The state is consumed with the error: the connection is over. + Err(e) => Err(TransportError::Decrypt(e.to_string())), + } + } + + fn send(&mut self, frame: &Frame) -> Result<(), TransportError> { + let bytes = frame_bytes(frame)?; + // Refuse before encrypting: an encrypted frame that is then dropped + // would leave the peer's nonce behind ours. + let estimate = encrypted_frame_len(frame.payload.len()); + if self.outbox.len().saturating_add(estimate) > self.max_outbox { + return Err(TransportError::OutboxFull { + limit: self.max_outbox, + }); + } + let serialized = SerializedFrame::from_bytes(bytes) + .map_err(|hint| TransportError::Encode(hint.to_string()))?; + let result = match &mut self.stage { + Stage::Open { enc, .. } => self.encoder.encode_transport(serialized, enc), + _ => return Err(TransportError::NotOpen), + }; + let encrypted = match result { + Ok(encrypted) => encrypted, + Err(e) => { + self.stage = Stage::Failed; + return Err(TransportError::Encode(e.to_string())); + } + }; + let slice: &[u8] = encrypted.as_ref(); + self.outbox.extend_from_slice(slice); + Ok(()) + } +} + +/// One authenticated pool connection: Noise transport plus protocol session. +/// +/// Sans-IO. Every method that can queue output leaves it in +/// [`Connection::take_transmit`]. After any `Err` the connection is dead: +/// drain `take_transmit` if you want a last message out, call +/// [`Connection::release`], and drop the socket. +pub struct Connection { + transport: Transport, + session: ProtocolSession, + dead: bool, +} + +impl fmt::Debug for Connection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Connection") + .field("session", &self.session) + .field("dead", &self.dead) + .finish_non_exhaustive() + } +} + +impl Connection { + /// A server-side connection awaiting the miner's first handshake bytes. + /// + /// `book` is only read, to check that its extranonce layout fits the wire. + /// `cert_validity` is how long the certificate this connection presents + /// stays valid. + pub fn accept( + authority: &AuthorityKeypair, + cert_validity: Duration, + config: ServerConfig, + book: &JobBook, + ) -> Result { + let limits = config.limits; + let session = ProtocolSession::new(config, book)?; + let transport = Transport::accept(authority, cert_validity, &limits)?; + Ok(Self { + transport, + session, + dead: false, + }) + } + + /// How many more bytes would let the decoder make progress; read at least + /// this much (reading more is always safe). + #[must_use] + pub fn read_hint(&self) -> usize { + self.transport.read_hint() + } + + /// The protocol session, for inspection. + #[must_use] + pub fn session(&self) -> &ProtocolSession { + &self.session + } + + /// True once the connection must be flushed and closed (a refused + /// `SetupConnection`, a failure, or [`Connection::release`]). + #[must_use] + pub fn is_closing(&self) -> bool { + self.dead || self.session.is_closing() + } + + fn flush(&mut self) -> Result<(), Error> { + for frame in self.session.take_frames() { + self.transport.send(&frame)?; + } + Ok(()) + } + + fn guard(&mut self, op: impl FnOnce(&mut Self) -> Result) -> Result { + if self.dead { + return Err(Error::Closed); + } + let result = op(self); + if let Err(error) = &result + && error.is_fatal() + { + self.dead = true; + } + result + } + + /// Feeds bytes read from the socket (any length up to [`MAX_FEED_LEN`]). + /// + /// Decrypts and decodes every complete frame, runs the protocol session on + /// it against `book`, and queues the replies. + pub fn feed(&mut self, data: &[u8], book: &mut JobBook) -> Result<(), Error> { + self.guard(|conn| { + let frames = conn.transport.feed(data)?; + for frame in frames { + conn.session.handle(book, frame)?; + } + conn.flush() + }) + } + + /// Drains the encrypted bytes to write to the socket. + pub fn take_transmit(&mut self) -> Vec { + std::mem::take(&mut self.transport.outbox) + } + + /// Next host event, if any. + pub fn poll_event(&mut self) -> Option { + self.session.poll_event() + } + + /// Announces a job the owner just installed; see [`ProtocolSession::announce`]. + pub fn announce(&mut self, installed: &Installed) -> Result<(), Error> { + self.guard(|conn| { + conn.session.announce(installed)?; + conn.flush() + }) + } + + /// The host recorded the share: send `SubmitShares.Success`. + pub fn confirm_share(&mut self, ticket: ShareTicket) -> Result { + self.guard(|conn| { + let sent = conn.session.confirm_share(ticket)?; + conn.flush()?; + Ok(sent) + }) + } + + /// The host submitted and recorded a found block: send `SubmitShares.Success`. + /// + /// The token must come from [`PendingBlock::submit`] after the host + /// called its authoritative [`MiningControl`] and recorded the receipt in the + /// ledger. + pub fn confirm_block(&mut self, submission: &BlockSubmission) -> Result { + self.guard(|conn| { + let sent = conn.session.confirm_block(submission)?; + conn.flush()?; + Ok(sent) + }) + } + + /// The host could not record the share: send `SubmitShares.Error`. + pub fn reject_share(&mut self, ticket: ShareTicket, code: &'static str) -> Result { + self.guard(|conn| { + let sent = conn.session.reject_share(ticket, code)?; + conn.flush()?; + Ok(sent) + }) + } + + /// Retargets a channel or the group channel; see [`ProtocolSession::set_target`]. + pub fn set_target(&mut self, id: u32, target: ShareTarget) -> Result<(), Error> { + self.guard(|conn| { + conn.session.set_target(id, target)?; + conn.flush() + }) + } + + /// Closes a channel (or the whole group) and releases its owner sessions. + pub fn close_channel( + &mut self, + book: &mut JobBook, + channel_id: u32, + reason_code: &str, + ) -> Result<(), Error> { + self.guard(|conn| { + conn.session.close_channel(book, channel_id, reason_code)?; + conn.flush() + }) + } + + /// Sends `Reconnect`; see [`ProtocolSession::request_reconnect`]. + pub fn request_reconnect(&mut self, new_host: &str, new_port: u16) -> Result<(), Error> { + self.guard(|conn| { + conn.session.request_reconnect(new_host, new_port)?; + conn.flush() + }) + } + + /// Releases every owner session. Call on every exit path; idempotent. + pub fn release(&mut self, book: &mut JobBook) { + self.session.release(book); + self.dead = true; + } +} diff --git a/crates/stratum-v2/src/session.rs b/crates/stratum-v2/src/session.rs new file mode 100644 index 0000000000..91ba18ad1c --- /dev/null +++ b/crates/stratum-v2/src/session.rs @@ -0,0 +1,1978 @@ +//! Stratum V2 mining-protocol server state machine. +//! +//! [`ProtocolSession`] is one pool connection above the Noise transport: it +//! consumes plaintext [`Frame`]s and produces plaintext [`Frame`]s, so the +//! state machine is testable without a socket and the transport can be +//! swapped without touching it. +//! +//! The session is a *wire* adapter. It owns channel identity, the message +//! layouts, and the per-channel share target. Everything else is delegated to +//! the one owner, [`bitcoin_rs_stratum::JobBook`]: +//! +//! - job projection (coinbase split, merkle branch, header facts) comes from +//! [`bitcoin_rs_stratum::Job`]; +//! - extranonce uniqueness and session identity come from +//! [`JobBook::open_session`] / [`JobBook::close_session`]; +//! - every share verdict (stale, duplicate, version mask, time window, target, +//! network target) comes from [`JobBook::validate`], whose rejection is only +//! *mapped* here to an official SV2 error code; +//! - a block candidate is handed to the host as the owner's +//! [`bitcoin_rs_stratum::FoundBlock`]; the node's authoritative submit path is +//! called by the host through [`FoundBlock::submit`], never from here. +//! +//! One wire rule is checked here before delegating, because it is a rule about +//! the SV2 messages this session itself sent: §5.3.11 requires a share's +//! `ntime` to be at least the `ntime_start` the job announced, and the +//! announced value is `Job::ntime()`. +//! +//! Financial effects are not decided here. An accepted share becomes an +//! [`Event::ShareAccepted`] carrying the owner's accepted-share identity (its +//! header hash is the ledger's idempotency key). A network-target result is +//! instead emitted as [`Event::BlockFound`] and remains pending until the host +//! submits it through [`MiningControl`], records the receipt, and calls +//! [`ProtocolSession::confirm_block`]. `SubmitShares.Success` is held until +//! those host-owned durable steps complete. + +use std::collections::{BTreeMap, VecDeque}; +use std::fmt; +use std::sync::Arc; + +use binary_sv2::{B032, B064K, Decodable, Encodable, GetSize, Seq0255, Str0255, Sv2Option, U256}; +use bitcoin_rs_mining::{MiningControl, MiningControlError}; +use bitcoin_rs_stratum::{ + AcceptedShare, BlockReceipt, Extranonce, FoundBlock, Job, JobBook, JobError, JobId, Session, + SessionId, ShareError, ShareOutcome, ShareSubmission, ShareTarget, +}; +use common_messages_sv2::{ + CHANNEL_BIT_RECONNECT, CHANNEL_BIT_SETUP_CONNECTION, CHANNEL_BIT_SETUP_CONNECTION_ERROR, + CHANNEL_BIT_SETUP_CONNECTION_SUCCESS, ERROR_CODE_SETUP_CONNECTION_PROTOCOL_VERSION_MISMATCH, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_FEATURE_FLAGS, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_PROTOCOL, MESSAGE_TYPE_RECONNECT, + MESSAGE_TYPE_SETUP_CONNECTION, MESSAGE_TYPE_SETUP_CONNECTION_ERROR, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, Protocol, Reconnect, SetupConnection, + SetupConnectionError, SetupConnectionSuccess, has_requires_std_job, has_version_rolling, + has_work_selection, +}; +use mining_sv2::{ + CHANNEL_BIT_CLOSE_CHANNEL, CHANNEL_BIT_MINING_SET_NEW_PREV_HASH, + CHANNEL_BIT_NEW_EXTENDED_MINING_JOB, CHANNEL_BIT_NEW_MINING_JOB, + CHANNEL_BIT_OPEN_EXTENDED_MINING_CHANNEL, CHANNEL_BIT_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + CHANNEL_BIT_OPEN_MINING_CHANNEL_ERROR, CHANNEL_BIT_OPEN_STANDARD_MINING_CHANNEL, + CHANNEL_BIT_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, CHANNEL_BIT_SET_CUSTOM_MINING_JOB, + CHANNEL_BIT_SET_CUSTOM_MINING_JOB_ERROR, CHANNEL_BIT_SET_TARGET, + CHANNEL_BIT_SUBMIT_SHARES_ERROR, CHANNEL_BIT_SUBMIT_SHARES_EXTENDED, + CHANNEL_BIT_SUBMIT_SHARES_STANDARD, CHANNEL_BIT_SUBMIT_SHARES_SUCCESS, + CHANNEL_BIT_UPDATE_CHANNEL, CHANNEL_BIT_UPDATE_CHANNEL_ERROR, CloseChannel, + ERROR_CODE_OPEN_MINING_CHANNEL_CHANNEL_CAPACITY_EXHAUSTED, + ERROR_CODE_OPEN_MINING_CHANNEL_EXTENDED_CHANNELS_NOT_SUPPORTED_FOR_STANDARD_JOBS, + ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_NOMINAL_HASHRATE, + ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_USER_IDENTITY, + ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE, + ERROR_CODE_OPEN_MINING_CHANNEL_MIN_EXTRANONCE_SIZE_TOO_LARGE, + ERROR_CODE_OPEN_MINING_CHANNEL_UNKNOWN_USER, ERROR_CODE_SET_CUSTOM_MINING_JOB_JD_NOT_SUPPORTED, + ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE, ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW, + ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE, ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID, + ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID, + ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT, + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, + ERROR_CODE_UPDATE_CHANNEL_INVALID_CHANNEL_ID, + ERROR_CODE_UPDATE_CHANNEL_INVALID_NOMINAL_HASHRATE, + ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE, ERROR_CODE_VERSION_ROLLING_NOT_ALLOWED, + MESSAGE_TYPE_CLOSE_CHANNEL, MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, MESSAGE_TYPE_SET_CUSTOM_MINING_JOB, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_ERROR, MESSAGE_TYPE_SET_TARGET, + MESSAGE_TYPE_SUBMIT_SHARES_ERROR, MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + MESSAGE_TYPE_UPDATE_CHANNEL, MESSAGE_TYPE_UPDATE_CHANNEL_ERROR, NewExtendedMiningJob, + NewMiningJob, OpenExtendedMiningChannel, OpenExtendedMiningChannelSuccess, + OpenMiningChannelError, OpenStandardMiningChannel, OpenStandardMiningChannelSuccess, + SetCustomMiningJob, SetCustomMiningJobError, SetNewPrevHash, SetTarget, SubmitSharesError, + SubmitSharesExtended, SubmitSharesStandard, SubmitSharesSuccess, UpdateChannel, + UpdateChannelError, +}; +use ruint::Uint; +use thiserror::Error; + +use crate::{ConfigError, Frame, MAX_EXTRANONCE_WIRE_LEN, PROTOCOL_VERSION, U24_MAX}; + +/// `SetupConnection.flags` bit 0: the client can only process standard jobs. +pub const FLAG_REQUIRES_STANDARD_JOBS: u32 = 1 << 0; +/// `SetupConnection.flags` bit 1: the client will send `SetCustomMiningJob`. +pub const FLAG_REQUIRES_WORK_SELECTION: u32 = 1 << 1; +/// `SetupConnection.flags` bit 2: the client requires version rolling. +pub const FLAG_REQUIRES_VERSION_ROLLING: u32 = 1 << 2; +/// `SetupConnection.Success.flags` bit 0: the server accepts no version changes. +pub const SUCCESS_FLAG_REQUIRES_FIXED_VERSION: u32 = 1 << 0; +/// `SetupConnection.Success.flags` bit 1: the server accepts no standard channel. +pub const SUCCESS_FLAG_REQUIRES_EXTENDED_CHANNELS: u32 = 1 << 1; + +/// Local error code: `sequence_number` did not increase on its channel. +pub const ERROR_CODE_INVALID_SEQUENCE_NUMBER: &str = "invalid-sequence-number"; +/// Local error code: too many accepted shares await ledger confirmation. +pub const ERROR_CODE_POOL_BUSY: &str = "pool-busy"; +/// Local error code: the job holds its maximum number of accepted shares. +pub const ERROR_CODE_JOB_SHARE_CAPACITY: &str = "job-share-capacity"; +/// Local error code: the pool asked this connection to reconnect elsewhere. +pub const ERROR_CODE_RECONNECT_REQUESTED: &str = "reconnect-requested"; +/// Local error code: the host closed the channel because the share was not recorded. +pub const ERROR_CODE_SHARE_NOT_RECORDED: &str = "share-not-recorded"; + +/// Why the host admission hook refused a `user_identity`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AdmitError { + /// The identity is malformed for this pool (`invalid-user-identity`). + InvalidIdentity, + /// The identity is well formed but not registered (`unknown-user`). + UnknownUser, +} + +/// Host hook that admits or refuses a channel's `user_identity`. +/// +/// The adapter never interprets the identity: payout mapping and registration +/// belong to the accounting layer, which supplies this closure. +pub type AdmitUser = Arc Result<(), AdmitError> + Send + Sync>; + +/// Hard bounds on everything a peer can make this session hold. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Limits { + /// Largest accepted plaintext frame payload, at most the U24 wire maximum. + pub max_frame_payload: usize, + /// Open channels per connection. + pub max_channels: usize, + /// Wire job records kept per channel (older ones answer `invalid-job-id`). + pub max_jobs_per_channel: usize, + /// Accepted shares awaiting [`ProtocolSession::confirm_share`]. + pub max_pending_acks: usize, + /// Plaintext frames queued for the transport. + pub max_outbound_frames: usize, + /// Events queued for the host. + pub max_pending_events: usize, + /// Encrypted bytes queued for the socket. + pub max_outbound_bytes: usize, +} + +impl Limits { + /// Conservative defaults for a pool-facing listener. + pub const DEFAULT: Self = Self { + max_frame_payload: 8 * 1024, + max_channels: 64, + max_jobs_per_channel: 64, + max_pending_acks: 1024, + max_outbound_frames: 4096, + max_pending_events: 4096, + max_outbound_bytes: 4 * 1024 * 1024, + }; + + /// Rejects limits that are zero or exceed the wire format. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.max_frame_payload < 64 || self.max_frame_payload > U24_MAX { + return Err(ConfigError::Limits( + "max_frame_payload must be within 64..=0x00ff_ffff (SV2 msg_length is a U24)", + )); + } + if self.max_channels == 0 + || self.max_jobs_per_channel == 0 + || self.max_pending_acks == 0 + || self.max_outbound_frames == 0 + || self.max_pending_events == 0 + || self.max_outbound_bytes == 0 + { + return Err(ConfigError::Limits("every limit must be at least 1")); + } + Ok(()) + } +} + +impl Default for Limits { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Per-connection configuration. +#[derive(Clone)] +pub struct ServerConfig { + /// Resource bounds. + pub limits: Limits, + /// Starting share target of every channel; a channel never starts easier + /// than the miner's own `max_target`. + pub initial_target: ShareTarget, + /// Identity admission hook. + pub admit_user: AdmitUser, +} + +impl ServerConfig { + /// Default limits, and an admission hook that refuses only an empty identity. + #[must_use] + pub fn new(initial_target: ShareTarget) -> Self { + Self { + limits: Limits::DEFAULT, + initial_target, + admit_user: Arc::new(|identity: &[u8]| { + if identity.is_empty() { + Err(AdmitError::InvalidIdentity) + } else { + Ok(()) + } + }), + } + } +} + +impl fmt::Debug for ServerConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServerConfig") + .field("limits", &self.limits) + .field("initial_target", &self.initial_target) + .finish_non_exhaustive() + } +} + +/// Mining channel flavour. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelKind { + /// Header-only mining: the pool fixes the whole extranonce. + Standard, + /// The miner rolls `extranonce_size` bytes of the coinbase. + Extended, +} + +/// Ledger handle for one accepted share awaiting confirmation. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ShareTicket(pub u64); + +/// One accepted share, handed to the host to record before the miner is told. +/// +/// This event is emitted only for shares that do not meet the network target. +/// A block candidate is surfaced as [`Event::BlockFound`] instead, so the host +/// cannot accidentally acknowledge it without first submitting the block. +#[derive(Debug)] +pub struct ShareEvent { + /// Pass back to [`ProtocolSession::confirm_share`] or [`ProtocolSession::reject_share`]. + pub ticket: ShareTicket, + /// Channel the share arrived on. + pub channel_id: u32, + /// The miner's `sequence_number`. + pub sequence_number: u32, + /// The owner's accepted share. Block outcomes use [`PendingBlock`]. + pub outcome: AcceptedShare, +} + +/// A block candidate awaiting the host's authoritative [`MiningControl`] submission. +/// +/// The ticket remains pending until the host submits this block, records the +/// resulting [`BlockReceipt`] in its durable ledger, and calls +/// [`Connection::confirm_block`] with the [`BlockSubmission`] returned by +/// [`PendingBlock::submit`]. There is no conversion from a raw share ticket to +/// a block submission token. +#[derive(Debug)] +pub struct PendingBlock { + ticket: ShareTicket, + channel_id: u32, + sequence_number: u32, + found: Box, +} + +impl PendingBlock { + /// Ticket to use after the ledger has recorded this block. + #[must_use] + pub fn ticket(&self) -> ShareTicket { + self.ticket + } + + /// Channel carrying the pending block share. + #[must_use] + pub fn channel_id(&self) -> u32 { + self.channel_id + } + + /// Miner sequence number for the pending share. + #[must_use] + pub fn sequence_number(&self) -> u32 { + self.sequence_number + } + + /// The complete block candidate and its accepted-share identity. + #[must_use] + pub fn found_block(&self) -> &FoundBlock { + &self.found + } + + /// Submit this candidate through the node's authoritative [`MiningControl`]. + /// + /// The returned token is required by [`Connection::confirm_block`]. The + /// host records its receipt in the durable ledger before passing the token + /// to `confirm_block`. A submission error leaves this pending candidate + /// available for retry. + pub fn submit( + &self, + mining_control: &dyn MiningControl, + ) -> Result { + let receipt = self.found.submit(mining_control)?; + Ok(BlockSubmission { + ticket: self.ticket, + channel_id: self.channel_id, + sequence_number: self.sequence_number, + block_hash: *receipt.hash.as_bytes(), + receipt, + }) + } +} + +/// Proof that a pending block was submitted to [`MiningControl`]. +/// +/// This type is intentionally constructible only through +/// [`PendingBlock::submit`]. The host records [`Self::receipt`] in its ledger +/// before passing this token to [`Connection::confirm_block`]. +#[derive(Debug)] +pub struct BlockSubmission { + ticket: ShareTicket, + channel_id: u32, + sequence_number: u32, + block_hash: [u8; 32], + receipt: BlockReceipt, +} + +impl BlockSubmission { + /// Share ticket associated with the submitted block. + #[must_use] + pub fn ticket(&self) -> ShareTicket { + self.ticket + } + + /// [`MiningControl`]'s receipt, to pass to the ledger's block-record operation. + #[must_use] + pub fn receipt(&self) -> &BlockReceipt { + &self.receipt + } +} + +/// Why a channel went away. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloseReason { + /// The miner sent `CloseChannel`. + Peer, + /// The host closed it. + Host, +} + +/// Something the host must act on. +#[derive(Debug)] +pub enum Event { + /// `SetupConnection` was accepted. + SetupCompleted { + /// Flags the client declared. + flags: u32, + }, + /// `SetupConnection` was refused; the connection is closing. + SetupRejected { + /// Official error code that was sent. + code: &'static str, + }, + /// A channel is open and holds an extranonce session. + ChannelOpened { + /// New channel id. + channel_id: u32, + /// Group channel the channel belongs to. + group_channel_id: u32, + /// Standard or extended. + kind: ChannelKind, + /// The owner's session identity (unique extranonce prefix). + session: SessionId, + /// Opaque identity the miner declared. + user_identity: Vec, + /// Declared hashrate, h/s. + nominal_hash_rate: f32, + /// Initial share target. + target: ShareTarget, + }, + /// An open request was refused. + ChannelOpenRejected { + /// The request's `request_id`. + request_id: u32, + /// Error code that was sent. + code: &'static str, + }, + /// A channel closed and its session was released. + ChannelClosed { + /// Closed channel id. + channel_id: u32, + /// Who closed it. + reason: CloseReason, + }, + /// A share passed the owner's predicate; record it, then confirm. + ShareAccepted(ShareEvent), + /// A block passed the network target and awaits host submission. + BlockFound(PendingBlock), + /// A share was refused and the miner was told why. + ShareRejected { + /// Channel the share arrived on. + channel_id: u32, + /// The miner's `sequence_number`. + sequence_number: u32, + /// Error code that was sent. + code: &'static str, + /// The owner's verdict, absent for rules that live on the wire only. + cause: Option, + }, +} + +/// Read-only view of one open channel. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ChannelInfo { + /// Channel id. + pub channel_id: u32, + /// Standard or extended. + pub kind: ChannelKind, + /// The owner's session. + pub session: SessionId, + /// Opaque identity the miner declared. + pub user_identity: Vec, + /// Current share target. + pub target: ShareTarget, + /// Largest target the miner accepts. + pub max_target: ShareTarget, + /// Bytes the miner was told as `extranonce_prefix`. + pub extranonce_prefix: Vec, +} + +/// Fatal protocol violations; the connection must be dropped. +#[derive(Debug, Error)] +pub enum SessionError { + /// A message other than `SetupConnection` opened the connection. + #[error("message type {msg_type:#04x} before SetupConnection")] + SetupRequired { + /// Offending message type. + msg_type: u8, + }, + /// `SetupConnection` arrived on an established connection. + #[error("SetupConnection sent twice")] + DuplicateSetup, + /// The payload does not decode to exactly one message. + #[error("malformed message {msg_type:#04x}: {detail}")] + Malformed { + /// Offending message type. + msg_type: u8, + /// What failed. + detail: String, + }, + /// The `channel_msg` bit does not match the message definition. + #[error("message type {msg_type:#04x} requires channel_msg bit = {expected}")] + ChannelBit { + /// Offending message type. + msg_type: u8, + /// Bit value the definition requires. + expected: bool, + }, + /// A message type this server never accepts from a client. + #[error("unexpected message type {msg_type:#04x}")] + UnexpectedMessage { + /// Offending message type. + msg_type: u8, + }, + /// The peer is not draining output. + #[error("{queue} backlog exceeds {limit}")] + Backlog { + /// Which queue overflowed. + queue: &'static str, + /// Its bound. + limit: usize, + }, + /// A message this server built does not fit its wire type. + #[error("cannot encode message {msg_type:#04x}: {detail}")] + Encode { + /// Message type. + msg_type: u8, + /// What failed. + detail: String, + }, + /// The job owner refused a request that cannot be a miner's fault. + #[error("job owner: {0}")] + Job(JobError), + /// The job owner could not project a job for a channel. + #[error("job projection: {0}")] + Projection(ShareError), + /// A host call named a channel that is not open. + #[error("channel {0} is not open")] + UnknownChannel(u32), + /// A block ticket cannot be acknowledged as an ordinary share. + #[error("block ticket requires `MiningControl` submission before confirmation")] + BlockRequiresSubmission, + /// A host-requested target is above the miner's own maximum. + #[error("target exceeds the maximum target of channel {channel_id}")] + TargetAboveMaximum { + /// The channel whose bound would be violated. + channel_id: u32, + }, + /// An error code with bytes outside printable ASCII. + #[error("error code is not printable ASCII")] + BadErrorCode, +} + +impl SessionError { + /// False for host-call mistakes that leave the connection untouched. + #[must_use] + pub fn is_fatal(&self) -> bool { + !matches!( + self, + Self::UnknownChannel(_) + | Self::TargetAboveMaximum { .. } + | Self::BadErrorCode + | Self::BlockRequiresSubmission + ) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Phase { + AwaitSetup, + Open, + Closing, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Delivery { + /// `ntime_start` unset; activated by the `SetNewPrevHash` that follows. + Future, + /// `ntime_start` set; mined immediately on the last announced prev hash. + Active, +} + +struct ChannelJob { + /// Share target fixed when the job became active on the channel. + /// `None` until a future job is activated. + target: Option, +} + +struct Channel { + kind: ChannelKind, + session: SessionId, + /// The owner's session extranonce prefix. + session_prefix: Vec, + /// Standard channels: the pool-fixed rolled bytes. Extended: empty. + rolled: Vec, + user_identity: Vec, + nominal_hash_rate: f32, + max_target: ShareTarget, + target: ShareTarget, + last_sequence: Option, + jobs: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PendingKind { + Share, + Block, +} + +struct Pending { + channel_id: u32, + sequence_number: u32, + difficulty: u64, + block_hash: Option<[u8; 32]>, + kind: PendingKind, +} + +/// One Stratum V2 pool connection, above the transport. +pub struct ProtocolSession { + config: ServerConfig, + phase: Phase, + requires_standard_jobs: bool, + reconnecting: bool, + channels: BTreeMap, + group_id: Option, + next_id: u32, + pending: BTreeMap, + next_ticket: u64, + out: VecDeque, + events: VecDeque, + ignored_extension_frames: u64, +} + +impl fmt::Debug for ProtocolSession { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProtocolSession") + .field("phase", &self.phase) + .field("channels", &self.channels.len()) + .field("pending_acks", &self.pending.len()) + .finish_non_exhaustive() + } +} + +fn frame_of( + msg_type: u8, + channel_msg: bool, + msg: T, +) -> Result { + let payload = binary_sv2::to_bytes(msg).map_err(|e| SessionError::Encode { + msg_type, + detail: e.to_string(), + })?; + if payload.len() > U24_MAX { + return Err(SessionError::Encode { + msg_type, + detail: format!( + "payload of {} bytes exceeds the U24 msg_length", + payload.len() + ), + }); + } + Ok(Frame { + extension_type: 0, + msg_type, + channel_msg, + payload, + }) +} + +/// Decodes exactly one message; trailing bytes are a violation (no extension +/// TLV was negotiated, so none may follow). +fn decode<'a, T: Decodable<'a> + GetSize>( + msg_type: u8, + payload: &'a mut [u8], +) -> Result { + let len = payload.len(); + let msg: T = binary_sv2::from_bytes(payload).map_err(|e| SessionError::Malformed { + msg_type, + detail: e.to_string(), + })?; + let used = msg.get_size(); + if used != len { + return Err(SessionError::Malformed { + msg_type, + detail: format!("message uses {used} of {len} payload bytes"), + }); + } + Ok(msg) +} + +fn check_bit(msg_type: u8, got: bool, expected: bool) -> Result<(), SessionError> { + if got == expected { + Ok(()) + } else { + Err(SessionError::ChannelBit { msg_type, expected }) + } +} + +fn code_str(code: &str) -> Result, SessionError> { + if !code.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) { + return Err(SessionError::BadErrorCode); + } + Str0255::try_from(code).map_err(|_| SessionError::BadErrorCode) +} + +/// The integer pool difficulty (diff-1 basis, `0xffff << 208`) of `target`. +/// +/// Spec §5.3.13: `SubmitShares.Success.new_shares_sum` is the sum of the +/// *difficulty* of the jobs the shares were submitted against, NOT the raw +/// hash count ([`bitcoin_rs_stratum::ShareWork`]), which is scaled by 2^32. +/// Targets easier than difficulty 1 clamp to 1 so that acknowledged shares +/// always contribute at least 1 unit to the acknowledged shares sum. +pub fn target_difficulty(target: ShareTarget) -> u64 { + let target_u256 = Uint::<256, 4>::from_le_bytes(target.to_le_bytes()); + if target_u256.is_zero() { + return 0; + } + let diff_one = Uint::<256, 4>::from(0xffff_u32) << 208_usize; + let quotient = diff_one / target_u256; + let diff = u64::try_from(quotient).unwrap_or(u64::MAX); + diff.max(1) +} + +/// Maps the owner's rejection onto an official SV2 `SubmitShares.Error` code. +fn share_error_code(error: &ShareError) -> &'static str { + match error { + ShareError::UnknownSession(_) => ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID, + ShareError::UnknownJob(_) => ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID, + ShareError::Stale { .. } => ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, + ShareError::SessionPrefixLength { .. } | ShareError::ExtranonceLength { .. } => { + ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE + } + ShareError::VersionOutsideMask { mask, .. } => { + if *mask == 0 { + ERROR_CODE_VERSION_ROLLING_NOT_ALLOWED + } else { + ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT + } + } + ShareError::TimeTooEarly { .. } | ShareError::TimeTooLate { .. } => { + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE + } + ShareError::Duplicate { .. } => ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE, + ShareError::BelowTarget { .. } => ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW, + ShareError::JobShareCapacity { .. } => ERROR_CODE_JOB_SHARE_CAPACITY, + } +} + +/// `NewMiningJob` for a standard channel: the merkle root is the owner's. +fn standard_job_frame( + channel_id: u32, + job: &Job, + session_prefix: &[u8], + rolled: &[u8], + delivery: Delivery, +) -> Result { + let root = job + .merkle_root(Extranonce { + session: session_prefix, + rolled, + }) + .map_err(SessionError::Projection)?; + let root = *root.as_byte_array(); + let msg = NewMiningJob { + channel_id, + job_id: job.id().get(), + min_ntime: Sv2Option::new(match delivery { + Delivery::Future => None, + Delivery::Active => Some(job.ntime()), + }), + version: job.version(), + merkle_root: U256::from(&root), + }; + frame_of(MESSAGE_TYPE_NEW_MINING_JOB, CHANNEL_BIT_NEW_MINING_JOB, msg) +} + +/// `NewExtendedMiningJob` for an extended channel or, with the group id, for +/// the whole group: the coinbase halves and merkle path are the owner's. +fn extended_job_frame( + channel_id: u32, + job: &Job, + delivery: Delivery, +) -> Result { + let branch: Vec<[u8; 32]> = job + .merkle_branch() + .iter() + .map(|hash| *hash.as_byte_array()) + .collect(); + let path: Vec> = branch.iter().map(U256::from).collect(); + let merkle_path = Seq0255::new(path).map_err(|e| SessionError::Encode { + msg_type: MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + detail: e.to_string(), + })?; + let prefix = B064K::new(job.coinbase_prefix()).map_err(|e| SessionError::Encode { + msg_type: MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + detail: e.to_string(), + })?; + let suffix = B064K::new(job.coinbase_suffix()).map_err(|e| SessionError::Encode { + msg_type: MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + detail: e.to_string(), + })?; + let msg = NewExtendedMiningJob { + channel_id, + job_id: job.id().get(), + min_ntime: Sv2Option::new(match delivery { + Delivery::Future => None, + Delivery::Active => Some(job.ntime()), + }), + version: job.version(), + version_rolling_allowed: job.version_mask() != 0, + merkle_path, + coinbase_tx_prefix: prefix, + coinbase_tx_suffix: suffix, + }; + frame_of( + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + CHANNEL_BIT_NEW_EXTENDED_MINING_JOB, + msg, + ) +} + +fn prev_hash_frame(channel_id: u32, job: &Job) -> Result { + let prev = *job.previous_block_hash().as_bytes(); + let msg = SetNewPrevHash { + channel_id, + job_id: job.id().get(), + prev_hash: U256::from(&prev), + min_ntime: job.ntime(), + nbits: job.bits().to_consensus(), + }; + frame_of( + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + CHANNEL_BIT_MINING_SET_NEW_PREV_HASH, + msg, + ) +} + +struct OpenRequest { + request_id: u32, + user_identity: Vec, + nominal_hash_rate: f32, + max_target_le: [u8; 32], + min_extranonce_size: u16, +} + +fn decode_open_request( + mut payload: Vec, + kind: ChannelKind, +) -> Result { + match kind { + ChannelKind::Standard => { + let msg: OpenStandardMiningChannel<'_> = + decode(MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, &mut payload)?; + Ok(OpenRequest { + request_id: msg.request_id, + user_identity: msg.user_identity.as_bytes().to_vec(), + nominal_hash_rate: msg.nominal_hash_rate, + max_target_le: *msg.max_target.as_array(), + min_extranonce_size: 0, + }) + } + ChannelKind::Extended => { + let msg: OpenExtendedMiningChannel<'_> = + decode(MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, &mut payload)?; + Ok(OpenRequest { + request_id: msg.request_id, + user_identity: msg.user_identity.as_bytes().to_vec(), + nominal_hash_rate: msg.nominal_hash_rate, + max_target_le: *msg.max_target.as_array(), + min_extranonce_size: msg.min_extranonce_size, + }) + } + } +} + +struct OpenSuccess<'a> { + kind: ChannelKind, + request_id: u32, + channel_id: u32, + group_channel_id: u32, + target: ShareTarget, + session_prefix: &'a [u8], + rolled: &'a [u8], + extranonce_size: usize, +} + +fn open_success_frame(context: &OpenSuccess<'_>) -> Result { + let &OpenSuccess { + kind, + request_id, + channel_id, + group_channel_id, + target, + session_prefix, + rolled, + extranonce_size, + } = context; + let target_le = target.to_le_bytes(); + match kind { + ChannelKind::Standard => { + let mut full = session_prefix.to_vec(); + full.extend_from_slice(rolled); + let extranonce_prefix = B032::new(&full).map_err(|e| SessionError::Encode { + msg_type: MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + detail: e.to_string(), + })?; + frame_of( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + CHANNEL_BIT_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + OpenStandardMiningChannelSuccess { + request_id, + channel_id, + target: U256::from(&target_le), + extranonce_prefix, + group_channel_id, + }, + ) + } + ChannelKind::Extended => { + let extranonce_prefix = + B032::new(session_prefix).map_err(|e| SessionError::Encode { + msg_type: MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + detail: e.to_string(), + })?; + frame_of( + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + CHANNEL_BIT_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + OpenExtendedMiningChannelSuccess { + request_id, + channel_id, + target: U256::from(&target_le), + extranonce_size: u16::try_from(extranonce_size).unwrap_or(u16::MAX), + extranonce_prefix, + group_channel_id, + }, + ) + } + } +} + +impl ProtocolSession { + /// A session awaiting `SetupConnection`. `book` is checked for an + /// extranonce layout the SV2 wire can carry. + pub fn new(config: ServerConfig, book: &JobBook) -> Result { + config.limits.validate()?; + let layout = book.config().extranonce; + if layout.total_len() > MAX_EXTRANONCE_WIRE_LEN { + return Err(ConfigError::ExtranonceTooLong { + total: layout.total_len(), + max: MAX_EXTRANONCE_WIRE_LEN, + }); + } + Ok(Self { + config, + phase: Phase::AwaitSetup, + requires_standard_jobs: false, + reconnecting: false, + channels: BTreeMap::new(), + group_id: None, + next_id: 1, + pending: BTreeMap::new(), + next_ticket: 0, + out: VecDeque::new(), + events: VecDeque::new(), + ignored_extension_frames: 0, + }) + } + + /// True once the connection must be flushed and closed. + #[must_use] + pub fn is_closing(&self) -> bool { + self.phase == Phase::Closing + } + + /// Frames with a non-zero `extension_type` discarded so far (§3.4.1: no + /// extension is negotiated, so they are ignored). + #[must_use] + pub fn ignored_extension_frames(&self) -> u64 { + self.ignored_extension_frames + } + + /// Number of open channels. + #[must_use] + pub fn channel_count(&self) -> usize { + self.channels.len() + } + + /// Accepted shares awaiting [`Self::confirm_share`]. + #[must_use] + pub fn pending_acks(&self) -> usize { + self.pending.len() + } + + /// The group channel id, once a channel was opened. + #[must_use] + pub fn group_channel_id(&self) -> Option { + self.group_id + } + + /// Ids of the open channels, ascending. + #[must_use] + pub fn channel_ids(&self) -> Vec { + self.channels.keys().copied().collect() + } + + /// View of one open channel. + #[must_use] + pub fn channel(&self, channel_id: u32) -> Option { + let channel = self.channels.get(&channel_id)?; + let mut prefix = channel.session_prefix.clone(); + prefix.extend_from_slice(&channel.rolled); + Some(ChannelInfo { + channel_id, + kind: channel.kind, + session: channel.session, + user_identity: channel.user_identity.clone(), + target: channel.target, + max_target: channel.max_target, + extranonce_prefix: prefix, + }) + } + + /// Drains the outbound plaintext frames, in send order. + pub fn take_frames(&mut self) -> Vec { + self.out.drain(..).collect() + } + + /// Next host event, if any. + pub fn poll_event(&mut self) -> Option { + self.events.pop_front() + } + + fn push_event(&mut self, event: Event) -> Result<(), SessionError> { + if self.events.len() >= self.config.limits.max_pending_events { + return Err(SessionError::Backlog { + queue: "event", + limit: self.config.limits.max_pending_events, + }); + } + self.events.push_back(event); + Ok(()) + } + + fn push_frame(&mut self, frame: Frame) -> Result<(), SessionError> { + if self.out.len() >= self.config.limits.max_outbound_frames { + return Err(SessionError::Backlog { + queue: "outbound frame", + limit: self.config.limits.max_outbound_frames, + }); + } + self.out.push_back(frame); + Ok(()) + } + + fn queue( + &mut self, + msg_type: u8, + channel_msg: bool, + msg: T, + ) -> Result<(), SessionError> { + let frame = frame_of(msg_type, channel_msg, msg)?; + self.push_frame(frame) + } + + fn alloc_id(&mut self) -> Option { + let id = self.next_id; + self.next_id = id.checked_add(1)?; + Some(id) + } + + /// Consumes one plaintext frame from the peer. + /// + /// `Err` is a fatal violation: drop the connection and call + /// [`Self::release`]. Refusals that the protocol answers with an error + /// message (bad channel, stale share, ...) are `Ok` and visible as events. + pub fn handle(&mut self, book: &mut JobBook, frame: Frame) -> Result<(), SessionError> { + if self.phase == Phase::Closing { + return Ok(()); + } + if frame.extension_type != 0 { + self.ignored_extension_frames = self.ignored_extension_frames.saturating_add(1); + return Ok(()); + } + let Frame { + msg_type, + channel_msg, + payload, + .. + } = frame; + match self.phase { + Phase::AwaitSetup => { + if msg_type != MESSAGE_TYPE_SETUP_CONNECTION { + return Err(SessionError::SetupRequired { msg_type }); + } + check_bit(msg_type, channel_msg, CHANNEL_BIT_SETUP_CONNECTION)?; + self.setup(book, payload) + } + Phase::Open => self.dispatch(book, msg_type, channel_msg, payload), + Phase::Closing => Ok(()), + } + } + + fn reject_setup(&mut self, flags: u32, code: &'static str) -> Result<(), SessionError> { + let msg = SetupConnectionError { + flags, + error_code: code_str(code)?, + }; + self.queue( + MESSAGE_TYPE_SETUP_CONNECTION_ERROR, + CHANNEL_BIT_SETUP_CONNECTION_ERROR, + msg, + )?; + self.phase = Phase::Closing; + self.push_event(Event::SetupRejected { code }) + } + + fn setup(&mut self, book: &JobBook, mut payload: Vec) -> Result<(), SessionError> { + let (protocol, min_version, max_version, flags) = { + let msg: SetupConnection<'_> = decode(MESSAGE_TYPE_SETUP_CONNECTION, &mut payload)?; + (msg.protocol, msg.min_version, msg.max_version, msg.flags) + }; + // The set of flags this server cannot honor. Work selection needs + // custom jobs, which the job owner cannot install; version rolling + // needs a non-empty version mask on the job owner. + let rolling_available = book.config().version_mask != 0; + let mut unsupported = FLAG_REQUIRES_WORK_SELECTION; + if !rolling_available { + unsupported |= FLAG_REQUIRES_VERSION_ROLLING; + } + match protocol { + Protocol::MiningProtocol => {} + Protocol::JobDeclarationProtocol | Protocol::TemplateDistributionProtocol => { + return self.reject_setup(0, ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_PROTOCOL); + } + } + if min_version > PROTOCOL_VERSION || max_version < PROTOCOL_VERSION { + return self.reject_setup(0, ERROR_CODE_SETUP_CONNECTION_PROTOCOL_VERSION_MISMATCH); + } + let asks_work_selection = has_work_selection(flags); + let asks_rolling = has_version_rolling(flags); + if (asks_work_selection && unsupported & FLAG_REQUIRES_WORK_SELECTION != 0) + || (asks_rolling && unsupported & FLAG_REQUIRES_VERSION_ROLLING != 0) + { + return self.reject_setup( + unsupported, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_FEATURE_FLAGS, + ); + } + self.requires_standard_jobs = has_requires_std_job(flags); + let success_flags = if rolling_available { + 0 + } else { + SUCCESS_FLAG_REQUIRES_FIXED_VERSION + }; + self.queue( + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + CHANNEL_BIT_SETUP_CONNECTION_SUCCESS, + SetupConnectionSuccess { + used_version: PROTOCOL_VERSION, + flags: success_flags, + }, + )?; + self.phase = Phase::Open; + self.push_event(Event::SetupCompleted { flags }) + } + + fn dispatch( + &mut self, + book: &mut JobBook, + msg_type: u8, + channel_msg: bool, + payload: Vec, + ) -> Result<(), SessionError> { + match msg_type { + MESSAGE_TYPE_SETUP_CONNECTION => Err(SessionError::DuplicateSetup), + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL => { + check_bit( + msg_type, + channel_msg, + CHANNEL_BIT_OPEN_STANDARD_MINING_CHANNEL, + )?; + self.open_channel(book, payload, ChannelKind::Standard) + } + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL => { + check_bit( + msg_type, + channel_msg, + CHANNEL_BIT_OPEN_EXTENDED_MINING_CHANNEL, + )?; + self.open_channel(book, payload, ChannelKind::Extended) + } + MESSAGE_TYPE_UPDATE_CHANNEL => { + check_bit(msg_type, channel_msg, CHANNEL_BIT_UPDATE_CHANNEL)?; + self.update_channel(payload) + } + MESSAGE_TYPE_CLOSE_CHANNEL => { + check_bit(msg_type, channel_msg, CHANNEL_BIT_CLOSE_CHANNEL)?; + self.peer_close(book, payload) + } + MESSAGE_TYPE_SUBMIT_SHARES_STANDARD => { + check_bit(msg_type, channel_msg, CHANNEL_BIT_SUBMIT_SHARES_STANDARD)?; + self.submit_standard(book, payload) + } + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED => { + check_bit(msg_type, channel_msg, CHANNEL_BIT_SUBMIT_SHARES_EXTENDED)?; + self.submit_extended(book, payload) + } + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB => { + check_bit(msg_type, channel_msg, CHANNEL_BIT_SET_CUSTOM_MINING_JOB)?; + self.refuse_custom_job(payload) + } + _ => Err(SessionError::UnexpectedMessage { msg_type }), + } + } + + // ---- channel open ------------------------------------------------------ + + fn reject_open(&mut self, request_id: u32, code: &'static str) -> Result<(), SessionError> { + let msg = OpenMiningChannelError { + request_id, + error_code: code_str(code)?, + }; + self.queue( + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, + CHANNEL_BIT_OPEN_MINING_CHANNEL_ERROR, + msg, + )?; + self.push_event(Event::ChannelOpenRejected { request_id, code }) + } + + fn validate_open( + &self, + book: &JobBook, + kind: ChannelKind, + request: &OpenRequest, + ) -> Result { + if self.reconnecting { + return Err(ERROR_CODE_RECONNECT_REQUESTED); + } + if kind == ChannelKind::Extended && self.requires_standard_jobs { + return Err( + ERROR_CODE_OPEN_MINING_CHANNEL_EXTENDED_CHANNELS_NOT_SUPPORTED_FOR_STANDARD_JOBS, + ); + } + if self.channels.len() >= self.config.limits.max_channels { + return Err(ERROR_CODE_OPEN_MINING_CHANNEL_CHANNEL_CAPACITY_EXHAUSTED); + } + if !request.nominal_hash_rate.is_finite() || request.nominal_hash_rate < 0.0 { + return Err(ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_NOMINAL_HASHRATE); + } + if let Err(refusal) = (self.config.admit_user)(&request.user_identity) { + return Err(match refusal { + AdmitError::InvalidIdentity => ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_USER_IDENTITY, + AdmitError::UnknownUser => ERROR_CODE_OPEN_MINING_CHANNEL_UNKNOWN_USER, + }); + } + let max_target = ShareTarget::from_le_bytes(request.max_target_le) + .map_err(|_| ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE)?; + if usize::from(request.min_extranonce_size) > book.config().extranonce.rolled_len() { + return Err(ERROR_CODE_OPEN_MINING_CHANNEL_MIN_EXTRANONCE_SIZE_TOO_LARGE); + } + Ok(max_target) + } + + fn allocate_open( + &mut self, + book: &mut JobBook, + ) -> Result, SessionError> { + let group_id = self.group_id.or_else(|| { + let group = self.alloc_id(); + self.group_id = group; + group + }); + let (Some(group_channel_id), Some(channel_id)) = (group_id, self.alloc_id()) else { + return Ok(None); + }; + let session = match book.open_session() { + Ok(session) => session, + Err(JobError::SessionCapacity { .. } | JobError::SessionIdExhausted) => { + return Ok(None); + } + Err(other) => return Err(SessionError::Job(other)), + }; + Ok(Some((group_channel_id, channel_id, session))) + } + + fn open_channel( + &mut self, + book: &mut JobBook, + payload: Vec, + kind: ChannelKind, + ) -> Result<(), SessionError> { + let request = decode_open_request(payload, kind)?; + let max_target = match self.validate_open(book, kind, &request) { + Ok(max_target) => max_target, + Err(code) => return self.reject_open(request.request_id, code), + }; + let layout = book.config().extranonce; + let Some((group_channel_id, channel_id, session)) = self.allocate_open(book)? else { + return self.reject_open( + request.request_id, + ERROR_CODE_OPEN_MINING_CHANNEL_CHANNEL_CAPACITY_EXHAUSTED, + ); + }; + + let target = self.config.initial_target.min(max_target); + let session_prefix = session.extranonce_prefix().to_vec(); + let rolled = match kind { + ChannelKind::Standard => vec![0_u8; layout.rolled_len()], + ChannelKind::Extended => Vec::new(), + }; + let reply = open_success_frame(&OpenSuccess { + kind, + request_id: request.request_id, + channel_id, + group_channel_id, + target, + session_prefix: &session_prefix, + rolled: &rolled, + extranonce_size: layout.rolled_len(), + }); + let reply = match reply { + Ok(frame) => frame, + Err(error) => { + book.close_session(session.id()); + return Err(error); + } + }; + let session_id = session.id(); + if let Err(error) = self.push_frame(reply) { + book.close_session(session_id); + return Err(error); + } + self.channels.insert( + channel_id, + Channel { + kind, + session: session_id, + session_prefix, + rolled, + user_identity: request.user_identity.clone(), + nominal_hash_rate: request.nominal_hash_rate, + max_target, + target, + last_sequence: None, + jobs: BTreeMap::new(), + }, + ); + self.push_event(Event::ChannelOpened { + channel_id, + group_channel_id, + kind, + session: session_id, + user_identity: request.user_identity, + nominal_hash_rate: request.nominal_hash_rate, + target, + })?; + + // §5.3.15/5.3.16: the first message after a channel opens is a future + // job, followed by the `SetNewPrevHash` that activates it. + if let Some(current) = book.current().cloned() { + self.send_to_channel(channel_id, ¤t, Delivery::Future)?; + } + Ok(()) + } + + // ---- job distribution -------------------------------------------------- + + /// Announces a job the owner just installed to every channel. + /// + /// `installed.fresh == false` sends nothing. `clean_jobs` sends a future + /// job and the `SetNewPrevHash` that activates it (earlier jobs are stale + /// in the owner and invalid on the miner); otherwise the job is active + /// immediately on the last announced previous hash. Connections that did + /// not set `REQUIRES_STANDARD_JOBS` receive one group-addressed + /// `NewExtendedMiningJob`; the others, one job per channel. + pub fn announce( + &mut self, + installed: &bitcoin_rs_stratum::Installed, + ) -> Result<(), SessionError> { + if self.phase != Phase::Open || !installed.fresh { + return Ok(()); + } + let delivery = if installed.clean_jobs { + Delivery::Future + } else { + Delivery::Active + }; + self.distribute(&installed.job, delivery) + } + + fn distribute(&mut self, job: &Job, delivery: Delivery) -> Result<(), SessionError> { + let job_id = job.id().get(); + let missing: Vec = self + .channels + .iter() + .filter(|(_, channel)| !channel.jobs.contains_key(&job_id)) + .map(|(id, _)| *id) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let group = match self.group_id { + Some(group) if !self.requires_standard_jobs && missing.len() == self.channels.len() => { + Some(group) + } + _ => None, + }; + if let Some(group_id) = group { + let frame = extended_job_frame(group_id, job, delivery)?; + self.push_frame(frame)?; + let max = self.config.limits.max_jobs_per_channel; + for channel in self.channels.values_mut() { + let target = match delivery { + Delivery::Future => None, + Delivery::Active => Some(channel.target), + }; + record_job(channel, job_id, target, max); + } + if delivery == Delivery::Future { + let frame = prev_hash_frame(group_id, job)?; + self.push_frame(frame)?; + for channel in self.channels.values_mut() { + activate_job(channel, job_id); + } + } + return Ok(()); + } + for channel_id in missing { + self.send_to_channel(channel_id, job, delivery)?; + } + Ok(()) + } + + /// Channel-addressed delivery: the job, then (for a future job) the + /// `SetNewPrevHash` that activates it. + fn send_to_channel( + &mut self, + channel_id: u32, + job: &Job, + delivery: Delivery, + ) -> Result<(), SessionError> { + let (kind, session_prefix, rolled, target) = match self.channels.get(&channel_id) { + Some(channel) => ( + channel.kind, + channel.session_prefix.clone(), + channel.rolled.clone(), + channel.target, + ), + None => return Err(SessionError::UnknownChannel(channel_id)), + }; + let frame = match kind { + ChannelKind::Standard => { + standard_job_frame(channel_id, job, &session_prefix, &rolled, delivery)? + } + ChannelKind::Extended => extended_job_frame(channel_id, job, delivery)?, + }; + self.push_frame(frame)?; + let max = self.config.limits.max_jobs_per_channel; + let job_id = job.id().get(); + if let Some(channel) = self.channels.get_mut(&channel_id) { + record_job( + channel, + job_id, + match delivery { + Delivery::Future => None, + Delivery::Active => Some(target), + }, + max, + ); + } + if delivery == Delivery::Future { + let frame = prev_hash_frame(channel_id, job)?; + self.push_frame(frame)?; + if let Some(channel) = self.channels.get_mut(&channel_id) { + activate_job(channel, job_id); + } + } + Ok(()) + } + + // ---- targets ----------------------------------------------------------- + + fn queue_set_target(&mut self, id: u32, target: ShareTarget) -> Result<(), SessionError> { + let le = target.to_le_bytes(); + self.queue( + MESSAGE_TYPE_SET_TARGET, + CHANNEL_BIT_SET_TARGET, + SetTarget { + channel_id: id, + maximum_target: U256::from(&le), + }, + ) + } + + /// Host-driven retarget (vardiff policy lives in the host). + /// + /// `id` is a channel or the group channel. The target must not exceed the + /// miner's `max_target` (§5.3.21). Jobs already active keep the target they + /// were issued under; future jobs take it when they activate. + pub fn set_target(&mut self, id: u32, target: ShareTarget) -> Result<(), SessionError> { + let members: Vec = if self.group_id == Some(id) { + self.channels.keys().copied().collect() + } else if self.channels.contains_key(&id) { + vec![id] + } else { + return Err(SessionError::UnknownChannel(id)); + }; + for member in &members { + if let Some(channel) = self.channels.get(member) + && target > channel.max_target + { + return Err(SessionError::TargetAboveMaximum { + channel_id: *member, + }); + } + } + for member in &members { + if let Some(channel) = self.channels.get_mut(member) { + channel.target = target; + } + } + self.queue_set_target(id, target) + } + + fn update_channel(&mut self, mut payload: Vec) -> Result<(), SessionError> { + let (channel_id, hash_rate, max_le) = { + let msg: UpdateChannel<'_> = decode(MESSAGE_TYPE_UPDATE_CHANNEL, &mut payload)?; + ( + msg.channel_id, + msg.nominal_hash_rate, + *msg.maximum_target.as_array(), + ) + }; + let refusal = if !self.channels.contains_key(&channel_id) { + Some(ERROR_CODE_UPDATE_CHANNEL_INVALID_CHANNEL_ID) + } else if !hash_rate.is_finite() || hash_rate < 0.0 { + Some(ERROR_CODE_UPDATE_CHANNEL_INVALID_NOMINAL_HASHRATE) + } else if ShareTarget::from_le_bytes(max_le).is_err() { + Some(ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE) + } else { + None + }; + if let Some(code) = refusal { + let msg = UpdateChannelError { + channel_id, + error_code: code_str(code)?, + }; + return self.queue( + MESSAGE_TYPE_UPDATE_CHANNEL_ERROR, + CHANNEL_BIT_UPDATE_CHANNEL_ERROR, + msg, + ); + } + let Ok(max_target) = ShareTarget::from_le_bytes(max_le) else { + return Ok(()); + }; + let mut lowered = None; + if let Some(channel) = self.channels.get_mut(&channel_id) { + channel.nominal_hash_rate = hash_rate; + channel.max_target = max_target; + if channel.target > max_target { + channel.target = max_target; + lowered = Some(max_target); + } + } + // §5.3.7: a smaller max_target must be reflected by a SetTarget. + match lowered { + Some(target) => self.queue_set_target(channel_id, target), + None => Ok(()), + } + } + + // ---- shares ------------------------------------------------------------ + + fn submit_standard( + &mut self, + book: &mut JobBook, + mut payload: Vec, + ) -> Result<(), SessionError> { + let msg: SubmitSharesStandard = decode(MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, &mut payload)?; + self.submit( + book, + ChannelKind::Standard, + &Submit { + channel_id: msg.channel_id, + sequence_number: msg.sequence_number, + job_id: msg.job_id, + nonce: msg.nonce, + ntime: msg.ntime, + version: msg.version, + extranonce: None, + }, + ) + } + + fn submit_extended( + &mut self, + book: &mut JobBook, + mut payload: Vec, + ) -> Result<(), SessionError> { + let submit = { + let msg: SubmitSharesExtended<'_> = + decode(MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, &mut payload)?; + Submit { + channel_id: msg.channel_id, + sequence_number: msg.sequence_number, + job_id: msg.job_id, + nonce: msg.nonce, + ntime: msg.ntime, + version: msg.version, + extranonce: Some(msg.extranonce.as_bytes().to_vec()), + } + }; + self.submit(book, ChannelKind::Extended, &submit) + } + + fn refuse_share( + &mut self, + channel_id: u32, + sequence_number: u32, + code: &'static str, + cause: Option, + ) -> Result<(), SessionError> { + let msg = SubmitSharesError { + channel_id, + sequence_number, + error_code: code_str(code)?, + }; + self.queue( + MESSAGE_TYPE_SUBMIT_SHARES_ERROR, + CHANNEL_BIT_SUBMIT_SHARES_ERROR, + msg, + )?; + self.push_event(Event::ShareRejected { + channel_id, + sequence_number, + code, + cause, + }) + } + + fn prepare_share( + &mut self, + book: &JobBook, + message_kind: ChannelKind, + share: &Submit, + ) -> Result { + let (channel_id, sequence_number, job_id, ntime) = ( + share.channel_id, + share.sequence_number, + share.job_id, + share.ntime, + ); + let (session, rolled, issued_target, monotonic) = { + let Some(channel) = self.channels.get_mut(&channel_id) else { + return Err(ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID); + }; + if channel.kind != message_kind { + return Err(ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID); + } + let monotonic = channel + .last_sequence + .is_none_or(|last| sequence_number > last); + if monotonic { + channel.last_sequence = Some(sequence_number); + } + let rolled = match message_kind { + ChannelKind::Standard => channel.rolled.clone(), + ChannelKind::Extended => share.extranonce.clone().unwrap_or_default(), + }; + ( + channel.session, + rolled, + channel.jobs.get(&job_id).map(|record| record.target), + monotonic, + ) + }; + if !monotonic { + return Err(ERROR_CODE_INVALID_SEQUENCE_NUMBER); + } + if self.pending.len() >= self.config.limits.max_pending_acks { + return Err(ERROR_CODE_POOL_BUSY); + } + let target = match issued_target { + Some(Some(target)) => target, + Some(None) | None => return Err(ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID), + }; + let owner_job = JobId::new(job_id); + if let Some(job) = book.job(owner_job) + && ntime < job.ntime() + { + return Err(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE); + } + Ok(PreparedShare { + session, + job: owner_job, + rolled, + target, + }) + } + + fn submit( + &mut self, + book: &mut JobBook, + message_kind: ChannelKind, + share: &Submit, + ) -> Result<(), SessionError> { + let channel_id = share.channel_id; + let sequence_number = share.sequence_number; + let prepared = match self.prepare_share(book, message_kind, share) { + Ok(prepared) => prepared, + Err(code) => return self.refuse_share(channel_id, sequence_number, code, None), + }; + let &Submit { + nonce, + ntime, + version, + .. + } = share; + let submission = ShareSubmission { + session: prepared.session, + job: prepared.job, + rolled_extranonce: &prepared.rolled, + ntime, + nonce, + version, + }; + match book.validate(&submission, &prepared.target) { + Ok(outcome) => { + let difficulty = target_difficulty(prepared.target); + let ticket = ShareTicket(self.next_ticket); + self.next_ticket = self.next_ticket.wrapping_add(1); + let event = match outcome { + ShareOutcome::Share(accepted) => { + self.pending.insert( + ticket.0, + Pending { + channel_id, + sequence_number, + difficulty, + block_hash: None, + kind: PendingKind::Share, + }, + ); + Event::ShareAccepted(ShareEvent { + ticket, + channel_id, + sequence_number, + outcome: *accepted, + }) + } + ShareOutcome::Block(found) => { + self.pending.insert( + ticket.0, + Pending { + channel_id, + sequence_number, + difficulty, + block_hash: Some(*found.share.hash.as_bytes()), + kind: PendingKind::Block, + }, + ); + Event::BlockFound(PendingBlock { + ticket, + channel_id, + sequence_number, + found, + }) + } + }; + self.push_event(event) + } + Err(error) => { + let code = share_error_code(&error); + self.refuse_share(channel_id, sequence_number, code, Some(error)) + } + } + } + + /// The host recorded the share durably: tell the miner it counted. + /// + /// Returns `false` when the ticket is unknown or its channel closed + /// meanwhile (nothing is sent). + pub fn confirm_share(&mut self, ticket: ShareTicket) -> Result { + let Some(pending) = self.pending.get(&ticket.0) else { + return Ok(false); + }; + if matches!(pending.kind, PendingKind::Block) { + return Err(SessionError::BlockRequiresSubmission); + } + let Some(pending) = self.pending.remove(&ticket.0) else { + return Ok(false); + }; + if !self.channels.contains_key(&pending.channel_id) { + return Ok(false); + } + self.queue( + MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + CHANNEL_BIT_SUBMIT_SHARES_SUCCESS, + SubmitSharesSuccess { + channel_id: pending.channel_id, + last_sequence_number: pending.sequence_number, + new_submits_accepted_count: 1, + new_shares_sum: pending.difficulty, + }, + )?; + Ok(true) + } + + /// Records a submitted block and acknowledges its share. + /// + /// The caller must record [`BlockSubmission::receipt`] in the ledger + /// before invoking this method. Only the opaque token produced by + /// [`PendingBlock::submit`] can pass the ticket and channel checks. + pub fn confirm_block(&mut self, submission: &BlockSubmission) -> Result { + let Some(pending) = self.pending.get(&submission.ticket.0) else { + return Ok(false); + }; + if !matches!(pending.kind, PendingKind::Block) + || pending.channel_id != submission.channel_id + || pending.sequence_number != submission.sequence_number + || pending.block_hash != Some(submission.block_hash) + { + return Ok(false); + } + let Some(pending) = self.pending.remove(&submission.ticket.0) else { + return Ok(false); + }; + if !self.channels.contains_key(&pending.channel_id) { + return Ok(false); + } + self.queue( + MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + CHANNEL_BIT_SUBMIT_SHARES_SUCCESS, + SubmitSharesSuccess { + channel_id: pending.channel_id, + last_sequence_number: pending.sequence_number, + new_submits_accepted_count: 1, + new_shares_sum: pending.difficulty, + }, + )?; + Ok(true) + } + + /// The host could not record the share: tell the miner it did not count. + /// + /// `code` must be printable ASCII (for example [`ERROR_CODE_SHARE_NOT_RECORDED`]). + pub fn reject_share( + &mut self, + ticket: ShareTicket, + code: &'static str, + ) -> Result { + let Some(pending) = self.pending.get(&ticket.0) else { + return Ok(false); + }; + if matches!(pending.kind, PendingKind::Block) { + return Err(SessionError::BlockRequiresSubmission); + } + let Some(pending) = self.pending.remove(&ticket.0) else { + return Ok(false); + }; + if !self.channels.contains_key(&pending.channel_id) { + return Ok(false); + } + let msg = SubmitSharesError { + channel_id: pending.channel_id, + sequence_number: pending.sequence_number, + error_code: code_str(code)?, + }; + self.queue( + MESSAGE_TYPE_SUBMIT_SHARES_ERROR, + CHANNEL_BIT_SUBMIT_SHARES_ERROR, + msg, + )?; + Ok(true) + } + + // ---- close, cancel, reconnect ---------------------------------------- + + fn refuse_custom_job(&mut self, mut payload: Vec) -> Result<(), SessionError> { + let (channel_id, request_id) = { + let msg: SetCustomMiningJob<'_> = + decode(MESSAGE_TYPE_SET_CUSTOM_MINING_JOB, &mut payload)?; + (msg.channel_id, msg.request_id) + }; + // Work selection is not advertised (SetupConnection refuses + // REQUIRES_WORK_SELECTION), so a custom job is a client error that the + // protocol answers with the official code rather than a disconnect. + let msg = SetCustomMiningJobError { + channel_id, + request_id, + error_code: code_str(ERROR_CODE_SET_CUSTOM_MINING_JOB_JD_NOT_SUPPORTED)?, + }; + self.queue( + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_ERROR, + CHANNEL_BIT_SET_CUSTOM_MINING_JOB_ERROR, + msg, + ) + } + + /// Channels a close of `id` covers: the channel, or every member for the group. + fn close_scope(&self, id: u32) -> Vec { + if self.group_id == Some(id) { + self.channels.keys().copied().collect() + } else if self.channels.contains_key(&id) { + vec![id] + } else { + Vec::new() + } + } + + fn remove_channel( + &mut self, + book: &mut JobBook, + channel_id: u32, + reason: CloseReason, + ) -> Result<(), SessionError> { + let Some(channel) = self.channels.remove(&channel_id) else { + return Ok(()); + }; + book.close_session(channel.session); + // Cancel: shares of a closed channel are not acknowledged. + self.pending + .retain(|_, pending| pending.channel_id != channel_id); + self.push_event(Event::ChannelClosed { channel_id, reason }) + } + + fn peer_close(&mut self, book: &mut JobBook, mut payload: Vec) -> Result<(), SessionError> { + let channel_id = { + let msg: CloseChannel<'_> = decode(MESSAGE_TYPE_CLOSE_CHANNEL, &mut payload)?; + msg.channel_id + }; + for id in self.close_scope(channel_id) { + self.remove_channel(book, id, CloseReason::Peer)?; + } + Ok(()) + } + + /// Host-initiated close of a channel (or of the whole group). + pub fn close_channel( + &mut self, + book: &mut JobBook, + channel_id: u32, + reason_code: &str, + ) -> Result<(), SessionError> { + let scope = self.close_scope(channel_id); + if scope.is_empty() { + return Err(SessionError::UnknownChannel(channel_id)); + } + let msg = CloseChannel { + channel_id, + reason_code: code_str(reason_code)?, + }; + self.queue(MESSAGE_TYPE_CLOSE_CHANNEL, CHANNEL_BIT_CLOSE_CHANNEL, msg)?; + for id in scope { + self.remove_channel(book, id, CloseReason::Host)?; + } + Ok(()) + } + + /// Asks the miner to reconnect (`Reconnect`, §3.6.5). An empty host and + /// port 0 mean "same endpoint". Afterwards new channels are refused with + /// [`ERROR_CODE_RECONNECT_REQUESTED`]; the host closes the connection when + /// the miner leaves or after its own grace period. + pub fn request_reconnect(&mut self, new_host: &str, new_port: u16) -> Result<(), SessionError> { + let msg = Reconnect { + new_host: code_str(new_host)?, + new_port, + }; + self.queue(MESSAGE_TYPE_RECONNECT, CHANNEL_BIT_RECONNECT, msg)?; + self.reconnecting = true; + Ok(()) + } + + /// Releases every owner session this connection holds. The host MUST call + /// this when the connection ends, whatever the reason: the owner's session + /// table is bounded and a leaked session shrinks it. Idempotent. + pub fn release(&mut self, book: &mut JobBook) { + for (_, channel) in std::mem::take(&mut self.channels) { + book.close_session(channel.session); + } + self.pending.clear(); + self.phase = Phase::Closing; + } +} + +struct PreparedShare { + session: SessionId, + job: JobId, + rolled: Vec, + target: ShareTarget, +} + +struct Submit { + channel_id: u32, + sequence_number: u32, + job_id: u32, + nonce: u32, + ntime: u32, + version: u32, + extranonce: Option>, +} + +fn record_job(channel: &mut Channel, job_id: u32, target: Option, max: usize) { + channel.jobs.insert(job_id, ChannelJob { target }); + while channel.jobs.len() > max { + channel.jobs.pop_first(); + } +} + +fn activate_job(channel: &mut Channel, job_id: u32) { + let target = channel.target; + if let Some(record) = channel.jobs.get_mut(&job_id) { + record.target = Some(target); + } +} diff --git a/crates/stratum-v2/tests/protocol.rs b/crates/stratum-v2/tests/protocol.rs new file mode 100644 index 0000000000..0e8022ca63 --- /dev/null +++ b/crates/stratum-v2/tests/protocol.rs @@ -0,0 +1,2370 @@ +//! Stratum V2 pool adapter tests. +//! +//! Everything below the `host` is real: a fresh disposable authority key, a real +//! `Noise_NX` handshake run by `noise_sv2`/`codec_sv2` on both ends, real +//! encrypted frames, real regtest candidates from the node's own +//! `assemble_candidate` over a mempool filled through the public +//! `MempoolGateway::submit_transaction` path, and the real +//! `bitcoin_rs_stratum::JobBook` as the one job +//! and share predicate. The miner side is a minimal SV2 client built from the +//! same crates (a hand-written client would only test itself). +//! +//! No socket is opened and no network daemon runs: `Connection` is sans-IO and +//! the test moves its bytes. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use binary_sv2::{B064K, B0255, Decodable, Encodable, GetSize, Seq0255, Str0255, U256}; +use bitcoin_rs_chain::compact_is_met_by; +use bitcoin_rs_consensus::ValidationEngine; +use bitcoin_rs_mempool::{ + AdmissionChain, AdmissionOrigin, ChainAdmissionSnapshot, Mempool, MempoolGateway, + MempoolLimits, SubmitOutcome, +}; +use bitcoin_rs_mining::{Candidate, CandidateContext, CoinbaseBudget, assemble_candidate}; +use bitcoin_rs_primitives::{ + Amount, CompactTarget, Hash256, LockTime, Network, OutPoint, Script, Sequence, Tx, TxIn, TxOut, + Txid, Witness, encode::double_sha256, +}; +use bitcoin_rs_script::VerifyFlags; +use bitcoin_rs_stratum::{ + BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Installed, Job, JobBook, JobBookConfig, + JobId, ShareClass, ShareError, ShareTarget, +}; +use bitcoin_rs_stratum_v2::{ + AuthorityKeypair, CloseReason, Connection, Error, Event, FLAG_REQUIRES_STANDARD_JOBS, + FLAG_REQUIRES_VERSION_ROLLING, FLAG_REQUIRES_WORK_SELECTION, Limits, ROLES, RoleStatus, + SUCCESS_FLAG_REQUIRES_FIXED_VERSION, ServerConfig, SessionError, TransportError, + target_difficulty, +}; +use codec_sv2::{ + Decoded, Decrypted, Handshake, InitiatorSent, MessageFrame, NoiseDecoder, NoiseEncoder, + TransportDecryptState, TransportEncryptState, +}; +use common_messages_sv2::{ + MESSAGE_TYPE_RECONNECT, MESSAGE_TYPE_SETUP_CONNECTION, MESSAGE_TYPE_SETUP_CONNECTION_ERROR, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, Protocol, SetupConnection, SetupConnectionError, + SetupConnectionSuccess, +}; +use mining_sv2::{ + CloseChannel, MESSAGE_TYPE_CLOSE_CHANNEL, MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, MESSAGE_TYPE_SET_CUSTOM_MINING_JOB, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_ERROR, MESSAGE_TYPE_SET_TARGET, + MESSAGE_TYPE_SUBMIT_SHARES_ERROR, MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + MESSAGE_TYPE_UPDATE_CHANNEL, MESSAGE_TYPE_UPDATE_CHANNEL_ERROR, NewExtendedMiningJob, + NewMiningJob, OpenExtendedMiningChannel, OpenExtendedMiningChannelSuccess, + OpenMiningChannelError, OpenStandardMiningChannel, OpenStandardMiningChannelSuccess, + SetCustomMiningJob, SetCustomMiningJobError, SetNewPrevHash, SetTarget, SubmitSharesError, + SubmitSharesExtended, SubmitSharesStandard, SubmitSharesSuccess, UpdateChannel, + UpdateChannelError, +}; +use noise_sv2::{ELLSWIFT_ENCODING_SIZE, INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE, Initiator}; +use parking_lot::RwLock; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +const PAYOUT: &[u8] = &[0x51]; +const ALL_ONES: [u8; 32] = [0xff; 32]; +const WHOLE: usize = 1 << 20; + +#[derive(Debug)] +struct TestError(String); + +impl std::fmt::Display for TestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for TestError {} + +impl From for TestError { + fn from(error: String) -> Self { + Self(error) + } +} + +type TestResult = Result; + +// ---- fixtures: real regtest candidates -------------------------------------- + +fn context(height: u32, parent: u8) -> CandidateContext { + CandidateContext { + previous_block_hash: Hash256::from_le_bytes(&[parent; 32]), + height, + version: 0x2000_0000, + bits: CompactTarget::from_consensus(0x207f_ffff), + min_time: 1_700_000_001, + current_time: 1_700_000_600, + locktime_cutoff: 1_700_000_000, + staged_parent_mtp: 1_700_000_000, + network: Network::Regtest, + csv_active: true, + segwit_active: true, + max_weight: 4_000_000, + max_size: 4_000_000, + max_sigops: 80_000, + } +} + +const COIN_VALUE_SAT: u64 = 1_000_000; +const ADMISSION_TIME: u64 = 1_700_000_000; + +/// `OP_0 `: spent by the witness stack `[OP_TRUE]`, so the +/// spending transaction carries witness data and its wtxid differs from its +/// txid. +fn wsh_true_script() -> Vec { + let program: [u8; 32] = Sha256::digest([0x51_u8]).into(); + let mut script = vec![0x00, 0x20]; + script.extend_from_slice(&program); + script +} + +fn p2wpkh_script(label: u8) -> Vec { + let mut script = vec![0x00, 0x14]; + script.extend_from_slice(&[label; 20]); + script +} + +fn coin_outpoint(label: u8) -> OutPoint { + let mut bytes = [0xc0_u8; 32]; + bytes[0] = label; + OutPoint::new(Txid(Hash256::from_le_bytes(&bytes)), 0) +} + +/// One confirmed `P2WSH(OP_TRUE)` coin spent by one 1-in/1-out `P2WPKH` +/// transaction at `2 + label` `sat/vB`. +fn spend(label: u8) -> Tx { + let mut tx = Tx { + version: 2, + inputs: vec![TxIn { + previous_output: coin_outpoint(label), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::from_stack(vec![vec![0x51]]), + }], + outputs: vec![TxOut { + value: Amount::ZERO, + script_pubkey: Script::from_bytes(p2wpkh_script(label)), + }], + lock_time: LockTime::ZERO, + }; + let fee = tx.vsize() * (2 + u64::from(label)); + tx.outputs[0].value = Amount::from_sat(COIN_VALUE_SAT - fee); + tx +} + +/// A fixed confirmed coin set at a fixed tip, in the shape the node's chain +/// adapter answers admission with. +struct Coins(HashMap); + +impl AdmissionChain for Coins { + fn snapshot(&self, tx: &Tx) -> Option { + let prevouts = tx + .inputs + .iter() + .filter_map(|input| { + self.0 + .get(&input.previous_output) + .map(|output| (input.previous_output, output.clone())) + }) + .collect(); + Some(ChainAdmissionSnapshot { + prevouts, + height: 1, + locktime_cutoff: 0, + csv_active: false, + next_block_script_flags: VerifyFlags::STANDARD, + confirmed: false, + ..ChainAdmissionSnapshot::default() + }) + } +} + +/// A real gateway holding `count` transactions admitted through the public +/// `submit_transaction` path (script verification and policy included). +fn admitted_gateway(count: u8) -> MempoolGateway { + let gateway = MempoolGateway::new( + Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))), + None, + ValidationEngine::Native, + None, + ); + let script = wsh_true_script(); + let coins = Coins( + (1..=count) + .map(|label| { + ( + coin_outpoint(label), + TxOut { + value: Amount::from_sat(COIN_VALUE_SAT), + script_pubkey: Script::from_bytes(script.clone()), + }, + ) + }) + .collect(), + ); + for label in 1..=count { + let outcome = gateway + .submit_transaction( + Arc::new(spend(label)), + AdmissionOrigin::Rpc, + None, + ADMISSION_TIME, + &coins, + ) + .unwrap_or_else(|error| panic!("gateway accepts the fixture submission: {error:?}")); + assert!( + matches!(outcome, SubmitOutcome::Committed(_)), + "fixture transaction {label} was not committed: {outcome:?}" + ); + } + gateway +} + +fn candidate(height: u32, parent: u8, txs: u8) -> Arc { + let gateway = admitted_gateway(txs); + let candidate = assemble_candidate(&context(height, parent), &gateway.read(), PAYOUT) + .unwrap_or_else(|error| panic!("candidate assembles: {error:?}")); + assert_eq!(candidate.transactions.len(), usize::from(txs)); + assert!( + candidate + .transactions + .iter() + .all(|entry| Hash256::from(entry.txid) != Hash256::from(entry.wtxid)), + "fixture transactions carry witness data" + ); + Arc::new(candidate) +} + +fn book_config(version_mask: u32) -> JobBookConfig { + JobBookConfig { + budget: CoinbaseBudget { + additional_size: 200, + additional_sigops: 100, + }, + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/pool/".to_vec(), + version_mask, + ntime_forward_secs: 600, + max_jobs: 8, + max_sessions: 16, + max_shares_per_job: 64, + } +} + +fn server_config() -> ServerConfig { + ServerConfig::new( + ShareTarget::from_le_bytes(ALL_ONES) + .unwrap_or_else(|error| panic!("nonzero target: {error:?}")), + ) +} + +/// A fresh disposable authority key pair; nothing here is ever a real key. +fn fresh_authority() -> AuthorityKeypair { + let secp = secp256k1::Secp256k1::new(); + let keypair = secp256k1::Keypair::new(&secp, &mut rand::thread_rng()); + AuthorityKeypair::from_bytes( + keypair.x_only_public_key().0.serialize(), + Zeroizing::new(keypair.secret_bytes()), + ) +} + +// ---- the miner side ---------------------------------------------------------- + +#[derive(Debug)] +struct Received { + msg_type: u8, + channel_msg: bool, + payload: Vec, +} + +fn parse<'a, T: Decodable<'a>>(received: &'a mut Received) -> T { + binary_sv2::from_bytes(&mut received.payload) + .unwrap_or_else(|error| panic!("message decodes: {error:?}")) +} + +fn take_type(frames: &mut Vec, msg_type: u8) -> Received { + let at = frames + .iter() + .position(|frame| frame.msg_type == msg_type) + .unwrap_or_else(|| panic!("frame of that type present")); + frames.remove(at) +} + +fn types(frames: &[Received]) -> Vec { + frames.iter().map(|frame| frame.msg_type).collect() +} + +/// Miner end of an established Noise transport. +struct Client { + enc: TransportEncryptState, + dec: Option, + encoder: NoiseEncoder, + decoder: NoiseDecoder, + inbuf: Vec, +} + +impl Client { + fn seal(&mut self, msg_type: u8, channel_msg: bool, msg: T) -> Vec { + self.seal_ext(msg_type, 0, channel_msg, msg) + } + + fn seal_ext( + &mut self, + msg_type: u8, + extension_type: u16, + channel_msg: bool, + msg: T, + ) -> Vec { + let frame = MessageFrame::from_message(msg, msg_type, extension_type, channel_msg) + .unwrap_or_else(|error| panic!("frame fits: {error:?}")); + let encrypted = self + .encoder + .encode_transport(frame, &mut self.enc) + .unwrap_or_else(|error| panic!("client encrypts: {error:?}")); + let bytes: &[u8] = encrypted.as_ref(); + bytes.to_vec() + } + + fn open(&mut self, bytes: &[u8]) -> Vec { + self.inbuf.extend_from_slice(bytes); + let mut out = Vec::new(); + loop { + let want = self.decoder.writable_len(); + if self.inbuf.len() < want { + break; + } + if want > 0 { + let window = self.decoder.writable(); + window.copy_from_slice(&self.inbuf[..want]); + self.inbuf.drain(..want); + } + let state = self + .dec + .take() + .unwrap_or_else(|| panic!("decrypt state present")); + match self + .decoder + .next_transport_frame(state) + .unwrap_or_else(|error| panic!("server frames authenticate and decode: {error:?}")) + { + Decrypted::Frame(mut frame, state) => { + self.dec = Some(state); + let header = frame.header(); + out.push(Received { + msg_type: header.msg_type(), + channel_msg: header.channel_msg(), + payload: frame.payload().to_vec(), + }); + } + Decrypted::Incomplete(_, state) => self.dec = Some(state), + } + } + out + } +} + +/// Runs the miner's half of the handshake against `server`, feeding the +/// server `chunk` bytes at a time. `pinned` is the authority key the miner +/// trusts. +fn handshake( + server: &mut Connection, + book: &mut JobBook, + pinned: [u8; 32], + chunk: usize, +) -> Result { + let initiator = Handshake::initiator( + Initiator::from_raw_k(pinned).map_err(|e| format!("authority key: {e:?}"))?, + ); + let (act1, initiator) = initiator.step_0().map_err(|e| format!("step 0: {e:?}"))?; + let mut encoder = NoiseEncoder::new(); + let wire = encoder.encode_handshake(act1); + let wire: &[u8] = wire.as_ref(); + assert_eq!(wire.len(), ELLSWIFT_ENCODING_SIZE); + for piece in wire.chunks(chunk) { + server + .feed(piece, book) + .map_err(|e| format!("server feed: {e:?}"))?; + } + let reply = server.take_transmit(); + assert_eq!(reply.len(), INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE); + + let mut decoder = NoiseDecoder::new(); + let mut offset = 0; + let act2 = loop { + let window = decoder.writable(); + let len = window.len(); + window.copy_from_slice(&reply[offset..offset + len]); + offset += len; + match decoder + .next_handshake_frame::() + .map_err(|e| format!("decode handshake: {e:?}"))? + { + Decoded::Frame(frame) => break frame, + Decoded::Incomplete(_) => {} + } + }; + let act2: [u8; INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE] = act2 + .payload() + .try_into() + .map_err(|_| "act 2 length".to_owned())?; + let transport = initiator + .step_2(act2) + .map_err(|e| format!("step 2: {e:?}"))?; + let (enc, dec) = transport.split(); + Ok(Client { + enc, + dec: Some(dec), + encoder, + decoder: NoiseDecoder::new(), + inbuf: Vec::new(), + }) +} + +/// The header fields a miner chooses for one share. +#[derive(Clone, Copy)] +struct ShareWork { + job_id: u32, + nonce: u32, + ntime: u32, + version: u32, +} + +/// Server, owner book, and miner wired together. +struct Wire { + conn: Connection, + book: JobBook, + client: Client, + chunk: usize, +} + +impl Wire { + fn with(chunk: usize, config: ServerConfig, version_mask: u32) -> Self { + let authority = fresh_authority(); + let mut book = JobBook::new(book_config(version_mask)) + .unwrap_or_else(|error| panic!("book config: {error:?}")); + let mut conn = Connection::accept(&authority, Duration::from_secs(3_600), config, &book) + .unwrap_or_else(|error| panic!("connection config: {error:?}")); + let client = handshake(&mut conn, &mut book, *authority.public(), chunk) + .unwrap_or_else(|error| panic!("handshake with the pinned authority: {error:?}")); + Self { + conn, + book, + client, + chunk, + } + } + + fn new() -> Self { + Self::with(WHOLE, server_config(), BIP320_VERSION_MASK) + } + + fn try_exchange( + &mut self, + msg_type: u8, + channel_msg: bool, + msg: T, + ) -> Result, Error> { + let bytes = self.client.seal(msg_type, channel_msg, msg); + for piece in bytes.chunks(self.chunk) { + self.conn.feed(piece, &mut self.book)?; + } + Ok(self.drain()) + } + + fn exchange( + &mut self, + msg_type: u8, + channel_msg: bool, + msg: T, + ) -> Vec { + self.try_exchange(msg_type, channel_msg, msg) + .unwrap_or_else(|error| panic!("server accepts the frame: {error:?}")) + } + + fn drain(&mut self) -> Vec { + let bytes = self.conn.take_transmit(); + self.client.open(&bytes) + } + + fn events(&mut self) -> Vec { + let mut out = Vec::new(); + while let Some(event) = self.conn.poll_event() { + out.push(event); + } + out + } + + fn setup(&mut self, protocol: Protocol, min: u16, max: u16, flags: u32) -> Vec { + let msg = SetupConnection { + protocol, + min_version: min, + max_version: max, + flags, + endpoint_host: Str0255::try_from("pool.invalid") + .unwrap_or_else(|error| panic!("short: {error:?}")), + endpoint_port: 3333, + vendor: Str0255::try_from("test-miner") + .unwrap_or_else(|error| panic!("short: {error:?}")), + hardware_version: Str0255::try_from("sim-1") + .unwrap_or_else(|error| panic!("short: {error:?}")), + firmware: Str0255::try_from("fw-0").unwrap_or_else(|error| panic!("short: {error:?}")), + device_id: Str0255::try_from("").unwrap_or_else(|error| panic!("empty: {error:?}")), + }; + self.exchange(MESSAGE_TYPE_SETUP_CONNECTION, false, msg) + } + + /// `SetupConnection` that must succeed; returns the success flags. + fn setup_mining(&mut self, flags: u32) -> u32 { + let mut frames = self.setup(Protocol::MiningProtocol, 2, 2, flags); + assert_eq!(types(&frames), vec![MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS]); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS); + assert!(!frame.channel_msg); + let success: SetupConnectionSuccess = parse(&mut frame); + assert_eq!(success.used_version, 2); + success.flags + } + + fn open_standard( + &mut self, + request_id: u32, + user: &str, + max_target: [u8; 32], + ) -> Vec { + let msg = OpenStandardMiningChannel { + request_id, + user_identity: Str0255::try_from(user) + .unwrap_or_else(|error| panic!("short: {error:?}")), + nominal_hash_rate: 1.0e12, + max_target: U256::from(&max_target), + }; + self.exchange(MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, false, msg) + } + + fn open_extended( + &mut self, + request_id: u32, + user: &str, + max_target: [u8; 32], + min_extranonce_size: u16, + ) -> Vec { + let msg = OpenExtendedMiningChannel { + request_id, + user_identity: Str0255::try_from(user) + .unwrap_or_else(|error| panic!("short: {error:?}")), + nominal_hash_rate: 5.0e12, + max_target: U256::from(&max_target), + min_extranonce_size, + }; + self.exchange(MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, false, msg) + } + + fn submit_standard( + &mut self, + channel_id: u32, + sequence_number: u32, + job_id: u32, + nonce: u32, + ntime: u32, + version: u32, + ) -> Vec { + let msg = SubmitSharesStandard { + channel_id, + sequence_number, + job_id, + nonce, + ntime, + version, + }; + self.exchange(MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, true, msg) + } + + fn submit_extended( + &mut self, + channel_id: u32, + sequence_number: u32, + work: ShareWork, + extranonce: &[u8], + ) -> Vec { + let msg = SubmitSharesExtended { + channel_id, + sequence_number, + job_id: work.job_id, + nonce: work.nonce, + ntime: work.ntime, + version: work.version, + extranonce: binary_sv2::B032::new(extranonce) + .unwrap_or_else(|error| panic!("at most 32 bytes: {error:?}")), + }; + self.exchange(MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, true, msg) + } + + fn job(&self, job_id: u32) -> Arc { + Arc::clone( + self.book + .job(JobId::new(job_id)) + .unwrap_or_else(|| panic!("owner retains the job")), + ) + } +} + +// ---- reading what the server sent --------------------------------------------- + +struct Opened { + channel_id: u32, + group_id: u32, + prefix: Vec, + target: [u8; 32], + extranonce_size: Option, +} + +fn read_open_standard(frames: &mut Vec) -> Opened { + let mut frame = take_type(frames, MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS); + assert!(!frame.channel_msg); + let ok: OpenStandardMiningChannelSuccess = parse(&mut frame); + Opened { + channel_id: ok.channel_id, + group_id: ok.group_channel_id, + prefix: ok.extranonce_prefix.as_bytes().to_vec(), + target: *ok.target.as_array(), + extranonce_size: None, + } +} + +fn read_open_extended(frames: &mut Vec) -> Opened { + let mut frame = take_type(frames, MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS); + assert!(!frame.channel_msg); + let ok: OpenExtendedMiningChannelSuccess = parse(&mut frame); + Opened { + channel_id: ok.channel_id, + group_id: ok.group_channel_id, + prefix: ok.extranonce_prefix.as_bytes().to_vec(), + target: *ok.target.as_array(), + extranonce_size: Some(ok.extranonce_size), + } +} + +struct StdJob { + channel_id: u32, + job_id: u32, + min_ntime: Option, + version: u32, + merkle_root: [u8; 32], +} + +fn read_std_job(frames: &mut Vec) -> StdJob { + let mut frame = take_type(frames, MESSAGE_TYPE_NEW_MINING_JOB); + assert!(frame.channel_msg); + let msg: NewMiningJob = parse(&mut frame); + StdJob { + channel_id: msg.channel_id, + job_id: msg.job_id, + version: msg.version, + merkle_root: *msg.merkle_root.as_array(), + min_ntime: msg.min_ntime.into_inner(), + } +} + +struct ExtJob { + channel_id: u32, + job_id: u32, + min_ntime: Option, + version: u32, + rolling_allowed: bool, + path: Vec<[u8; 32]>, + prefix: Vec, + suffix: Vec, +} + +fn read_ext_job(frames: &mut Vec) -> ExtJob { + let mut frame = take_type(frames, MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB); + assert!(frame.channel_msg); + let msg: NewExtendedMiningJob = parse(&mut frame); + ExtJob { + channel_id: msg.channel_id, + job_id: msg.job_id, + version: msg.version, + rolling_allowed: msg.version_rolling_allowed, + path: msg + .merkle_path + .into_inner() + .iter() + .map(|hash| *hash.as_array()) + .collect(), + prefix: msg.coinbase_tx_prefix.as_bytes().to_vec(), + suffix: msg.coinbase_tx_suffix.as_bytes().to_vec(), + min_ntime: msg.min_ntime.into_inner(), + } +} + +struct PrevHash { + channel_id: u32, + job_id: u32, + prev: [u8; 32], + min_ntime: u32, + nbits: u32, +} + +fn read_prev_hash(frames: &mut Vec) -> PrevHash { + let mut frame = take_type(frames, MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH); + assert!(frame.channel_msg); + let msg: SetNewPrevHash = parse(&mut frame); + PrevHash { + channel_id: msg.channel_id, + job_id: msg.job_id, + prev: *msg.prev_hash.as_array(), + min_ntime: msg.min_ntime, + nbits: msg.nbits, + } +} + +/// The error code of the single `SubmitShares.Error` in `frames`. +fn share_error(frames: &mut Vec) -> (u32, u32, String) { + let mut frame = take_type(frames, MESSAGE_TYPE_SUBMIT_SHARES_ERROR); + assert!(frame.channel_msg); + let msg: SubmitSharesError = parse(&mut frame); + ( + msg.channel_id, + msg.sequence_number, + msg.error_code.as_utf8_or_hex(), + ) +} + +fn accepted(events: Vec) -> bitcoin_rs_stratum_v2::ShareEvent { + events + .into_iter() + .find_map(|event| match event { + Event::ShareAccepted(share) => Some(share), + _ => None, + }) + .unwrap_or_else(|| panic!("a share was accepted")) +} + +/// First nonce whose header (at the job's `ntime`, under `version`) falls in `class`. +fn find_nonce(job: &Job, extranonce_prefix: &[u8], version: u32, class: ShareClass) -> u32 { + let (session, rolled) = extranonce_prefix.split_at(job.extranonce_layout().session_len()); + (0..u32::MAX) + .find(|nonce| { + let header = job + .header(Extranonce { session, rolled }, job.ntime(), version, *nonce) + .unwrap_or_else(|error| panic!("header builds: {error:?}")); + let is_block = compact_is_met_by(job.bits(), Hash256::from(header.compute_hash())); + is_block == (class == ShareClass::Block) + }) + .unwrap_or_else(|| panic!("a nonce of the wanted class exists")) +} + +/// A nonce whose header meets the share target but not the network target. +fn share_nonce(job: &Job, extranonce_prefix: &[u8], version: u32) -> u32 { + find_nonce(job, extranonce_prefix, version, ShareClass::Share) +} + +/// A nonce whose header meets the network target. +fn block_nonce(job: &Job, extranonce_prefix: &[u8], version: u32) -> u32 { + find_nonce(job, extranonce_prefix, version, ShareClass::Block) +} + +/// One open standard channel with candidate A installed and announced. +struct Standard { + wire: Wire, + opened: Opened, + job: Arc, +} + +fn standard_with_job() -> Standard { + let mut wire = Wire::new(); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + // Standard jobs only: every later job is delivered per channel. + wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + let mut frames = wire.open_standard(7, "alice.rig1", ALL_ONES); + let opened = read_open_standard(&mut frames); + // The channel got the current job as its first message, then its prev hash. + let job = read_std_job(&mut frames); + assert_eq!(job.job_id, installed.job.id().get()); + read_prev_hash(&mut frames); + wire.events(); + Standard { + wire, + opened, + job: installed.job, + } +} + +// ---- transport ----------------------------------------------------------------- + +#[test] +fn noise_handshake_authenticates_the_pool_to_a_pinned_miner() -> TestResult { + let authority = fresh_authority(); + let mut book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .map_err(|error| format!("book: {error:?}"))?; + let mut conn = Connection::accept(&authority, Duration::from_secs(60), server_config(), &book) + .map_err(|error| format!("config: {error:?}"))?; + handshake(&mut conn, &mut book, *authority.public(), WHOLE)?; + Ok(()) +} + +#[test] +fn a_miner_pinning_another_authority_rejects_the_server_certificate() -> TestResult { + let authority = fresh_authority(); + let impostor = fresh_authority(); + let mut book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .map_err(|error| format!("book: {error:?}"))?; + let mut conn = Connection::accept(&authority, Duration::from_secs(60), server_config(), &book) + .map_err(|error| format!("config: {error:?}"))?; + let outcome = handshake(&mut conn, &mut book, *impostor.public(), WHOLE); + assert!( + outcome.is_err(), + "certificate signed by another authority must not verify" + ); + Ok(()) +} + +#[test] +fn a_mismatched_authority_key_pair_is_refused_at_accept() -> TestResult { + let authority = fresh_authority(); + let other = fresh_authority(); + let mismatched = AuthorityKeypair::from_bytes(*authority.public(), Zeroizing::new([0x42; 32])); + let book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .map_err(|error| format!("book: {error:?}"))?; + assert!( + Connection::accept(&mismatched, Duration::from_secs(60), server_config(), &book).is_err() + ); + assert!(Connection::accept(&other, Duration::ZERO, server_config(), &book).is_err()); + Ok(()) +} + +#[test] +fn byte_at_a_time_delivery_is_decoded_like_whole_reads() -> TestResult { + let mut wire = Wire::with(1, server_config(), BIP320_VERSION_MASK); + wire.book + .install(candidate(100, 0xab, 2), Vec::new()) + .map_err(|error| format!("install: {error:?}"))?; + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "carol", ALL_ONES); + assert_eq!( + types(&frames), + vec![ + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH + ] + ); + let opened = read_open_standard(&mut frames); + assert_eq!(opened.prefix.len(), 8); + Ok(()) +} + +#[test] +fn a_flipped_ciphertext_bit_fails_the_mac_and_kills_the_connection() -> TestResult { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut bytes = wire.client.seal( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + false, + OpenStandardMiningChannel { + request_id: 1, + user_identity: Str0255::try_from("mallory") + .map_err(|error| format!("short: {error:?}"))?, + nominal_hash_rate: 1.0, + max_target: U256::from(&ALL_ONES), + }, + ); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + let outcome = wire.conn.feed(&bytes, &mut wire.book); + assert!( + matches!(outcome, Err(Error::Transport(TransportError::Decrypt(_)))), + "tampered frame must fail authentication: {outcome:?}" + ); + assert!(wire.conn.is_closing()); + assert!(matches!( + wire.conn.feed(&[0], &mut wire.book), + Err(Error::Closed) + )); + assert_eq!(wire.conn.session().channel_count(), 0); + Ok(()) +} + +#[test] +fn a_flipped_header_bit_also_fails_authentication() -> TestResult { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut bytes = wire.client.seal( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + false, + OpenStandardMiningChannel { + request_id: 1, + user_identity: Str0255::try_from("mallory") + .map_err(|error| format!("short: {error:?}"))?, + nominal_hash_rate: 1.0, + max_target: U256::from(&ALL_ONES), + }, + ); + bytes[3] ^= 0x80; + let outcome = wire.conn.feed(&bytes, &mut wire.book); + assert!(matches!( + outcome, + Err(Error::Transport(TransportError::Decrypt(_))) + )); + Ok(()) +} + +#[test] +fn a_replayed_ciphertext_does_not_authenticate_under_the_next_nonce() -> TestResult { + let mut wire = Wire::new(); + wire.setup_mining(0); + let bytes = wire.client.seal( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + false, + OpenStandardMiningChannel { + request_id: 1, + user_identity: Str0255::try_from("replay") + .map_err(|error| format!("short: {error:?}"))?, + nominal_hash_rate: 1.0, + max_target: U256::from(&ALL_ONES), + }, + ); + wire.conn + .feed(&bytes, &mut wire.book) + .map_err(|error| format!("first delivery: {error:?}"))?; + assert_eq!(wire.conn.session().channel_count(), 1); + let replay = wire.conn.feed(&bytes, &mut wire.book); + assert!(matches!( + replay, + Err(Error::Transport(TransportError::Decrypt(_))) + )); + Ok(()) +} + +#[test] +fn an_oversize_declaration_is_refused_before_it_is_buffered() -> TestResult { + let mut config = server_config(); + config.limits = Limits { + max_frame_payload: 128, + ..Limits::DEFAULT + }; + let mut wire = Wire::with(WHOLE, config, BIP320_VERSION_MASK); + let long = "a".repeat(100); + let oversize = SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, + endpoint_host: Str0255::try_from(long.as_str()) + .map_err(|error| format!("100 bytes: {error:?}"))?, + endpoint_port: 1, + vendor: Str0255::try_from(long.as_str()) + .map_err(|error| format!("100 bytes: {error:?}"))?, + hardware_version: Str0255::try_from(long.as_str()) + .map_err(|error| format!("100 bytes: {error:?}"))?, + firmware: Str0255::try_from(long.as_str()) + .map_err(|error| format!("100 bytes: {error:?}"))?, + device_id: Str0255::try_from(long.as_str()) + .map_err(|error| format!("100 bytes: {error:?}"))?, + }; + let outcome = wire.try_exchange(MESSAGE_TYPE_SETUP_CONNECTION, false, oversize); + assert!( + matches!( + outcome, + Err(Error::Transport(TransportError::FrameTooLarge { + limit: 128 + })) + ), + "{outcome:?}" + ); + Ok(()) +} + +#[test] +fn limits_beyond_the_u24_wire_field_are_refused() { + let mut config = server_config(); + config.limits = Limits { + max_frame_payload: 0x0100_0000, + ..Limits::DEFAULT + }; + let authority = fresh_authority(); + let book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .unwrap_or_else(|error| panic!("book: {error:?}")); + assert!( + Connection::accept(&authority, Duration::from_secs(60), config, &book).is_err(), + "msg_length is a U24" + ); +} + +// ---- SetupConnection ----------------------------------------------------------- + +#[test] +fn setup_succeeds_and_advertises_only_what_it_supports() { + let mut wire = Wire::new(); + let flags = wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + assert_eq!(flags, 0, "rolling is available, so no fixed-version flag"); + let events = wire.events(); + assert!(matches!( + events.as_slice(), + [Event::SetupCompleted { + flags: FLAG_REQUIRES_STANDARD_JOBS + }] + )); + + let mut fixed = Wire::with(WHOLE, server_config(), 0); + let flags = fixed.setup_mining(0); + assert_eq!( + flags, SUCCESS_FLAG_REQUIRES_FIXED_VERSION, + "an owner without a version mask tells miners the version is fixed" + ); +} + +#[test] +fn unsupported_flags_report_the_full_unsupported_set_and_close() { + let mut wire = Wire::new(); + let mut frames = wire.setup( + Protocol::MiningProtocol, + 2, + 2, + FLAG_REQUIRES_WORK_SELECTION | FLAG_REQUIRES_STANDARD_JOBS, + ); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); + let error: SetupConnectionError = parse(&mut frame); + assert_eq!(error.flags, FLAG_REQUIRES_WORK_SELECTION); + assert_eq!( + error.error_code.as_utf8_or_hex(), + "unsupported-feature-flags" + ); + assert!(wire.conn.is_closing()); + assert!(matches!( + wire.events().as_slice(), + [Event::SetupRejected { + code: "unsupported-feature-flags" + }] + )); + + let mut fixed = Wire::with(WHOLE, server_config(), 0); + let mut frames = fixed.setup( + Protocol::MiningProtocol, + 2, + 2, + FLAG_REQUIRES_VERSION_ROLLING, + ); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); + let error: SetupConnectionError = parse(&mut frame); + assert_eq!( + error.flags, + FLAG_REQUIRES_WORK_SELECTION | FLAG_REQUIRES_VERSION_ROLLING + ); +} + +#[test] +fn job_declaration_and_template_distribution_are_not_served() { + for protocol in [ + Protocol::JobDeclarationProtocol, + Protocol::TemplateDistributionProtocol, + ] { + let mut wire = Wire::new(); + let mut frames = wire.setup(protocol, 2, 2, 0); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); + let error: SetupConnectionError = parse(&mut frame); + assert_eq!(error.error_code.as_utf8_or_hex(), "unsupported-protocol"); + assert!(wire.conn.is_closing()); + } +} + +#[test] +fn a_version_range_without_two_is_refused() { + let mut wire = Wire::new(); + let mut frames = wire.setup(Protocol::MiningProtocol, 3, 4, 0); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); + let error: SetupConnectionError = parse(&mut frame); + assert_eq!( + error.error_code.as_utf8_or_hex(), + "protocol-version-mismatch" + ); +} + +#[test] +fn nothing_but_setup_may_open_a_connection() { + let mut wire = Wire::new(); + let outcome = wire.try_exchange( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + false, + OpenStandardMiningChannel { + request_id: 1, + user_identity: Str0255::try_from("early") + .unwrap_or_else(|error| panic!("short: {error:?}")), + nominal_hash_rate: 1.0, + max_target: U256::from(&ALL_ONES), + }, + ); + assert!(matches!( + outcome, + Err(Error::Session(SessionError::SetupRequired { .. })) + )); +} + +#[test] +fn a_second_setup_and_a_wrong_channel_bit_are_protocol_violations() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let again = wire.try_exchange( + MESSAGE_TYPE_SETUP_CONNECTION, + false, + SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, + endpoint_host: Str0255::try_from("h") + .unwrap_or_else(|error| panic!("short: {error:?}")), + endpoint_port: 1, + vendor: Str0255::try_from("v").unwrap_or_else(|error| panic!("short: {error:?}")), + hardware_version: Str0255::try_from("h") + .unwrap_or_else(|error| panic!("short: {error:?}")), + firmware: Str0255::try_from("f").unwrap_or_else(|error| panic!("short: {error:?}")), + device_id: Str0255::try_from("").unwrap_or_else(|error| panic!("empty: {error:?}")), + }, + ); + assert!(matches!( + again, + Err(Error::Session(SessionError::DuplicateSetup)) + )); + + let mut wire = Wire::new(); + wire.setup_mining(0); + let wrong_bit = wire.try_exchange( + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + true, + OpenStandardMiningChannel { + request_id: 1, + user_identity: Str0255::try_from("bit") + .unwrap_or_else(|error| panic!("short: {error:?}")), + nominal_hash_rate: 1.0, + max_target: U256::from(&ALL_ONES), + }, + ); + assert!(matches!( + wrong_bit, + Err(Error::Session(SessionError::ChannelBit { .. })) + )); +} + +#[test] +fn frames_of_an_unnegotiated_extension_are_discarded() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let bytes = wire.client.seal_ext( + MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, + 0x4001, + true, + SubmitSharesStandard { + channel_id: 1, + sequence_number: 1, + job_id: 1, + nonce: 0, + ntime: 0, + version: 0, + }, + ); + wire.conn + .feed(&bytes, &mut wire.book) + .unwrap_or_else(|error| panic!("ignored, not fatal: {error:?}")); + assert!(wire.drain().is_empty()); + assert_eq!(wire.conn.session().ignored_extension_frames(), 1); +} + +// ---- channels ------------------------------------------------------------------ + +#[test] +fn standard_channel_gets_a_future_job_then_the_prev_hash_that_activates_it() { + let mut wire = Wire::new(); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_standard(7, "alice.rig1", ALL_ONES); + assert_eq!( + types(&frames), + vec![ + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH + ], + "the first message after open is a future job, then its prev hash" + ); + let opened = read_open_standard(&mut frames); + let job = read_std_job(&mut frames); + let prev = read_prev_hash(&mut frames); + let owned = &installed.job; + + assert_ne!(opened.group_id, opened.channel_id); + assert_eq!(opened.target, ALL_ONES); + assert_eq!( + opened.prefix.len(), + 8, + "session prefix plus pool-fixed rolled bytes" + ); + assert_eq!(job.channel_id, opened.channel_id); + assert_eq!(job.job_id, owned.id().get()); + assert_eq!(job.min_ntime, None, "future job: ntime_start unset"); + assert_eq!(job.version, owned.version()); + let (session, rolled) = opened.prefix.split_at(4); + let root = owned + .merkle_root(Extranonce { session, rolled }) + .unwrap_or_else(|error| panic!("owner projects the merkle root: {error:?}")); + assert_eq!(job.merkle_root, *root.as_byte_array()); + + assert_eq!(prev.channel_id, opened.channel_id); + assert_eq!(prev.job_id, owned.id().get()); + assert_eq!(prev.prev, *owned.previous_block_hash().as_bytes()); + assert_eq!(prev.nbits, 0x207f_ffff); + assert_eq!(prev.min_ntime, owned.ntime()); + + let events = wire.events(); + assert!(events.iter().any(|event| matches!( + event, + Event::ChannelOpened { channel_id, .. } if *channel_id == opened.channel_id + ))); + assert_eq!(wire.book.session_count(), 1); +} + +#[test] +fn extended_channel_job_rebuilds_the_owners_merkle_root_from_the_wire_fields() { + let mut wire = Wire::new(); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_extended(9, "bob.farm", ALL_ONES, 4); + let opened = read_open_extended(&mut frames); + assert_eq!(opened.extranonce_size, Some(4)); + assert_eq!(opened.prefix.len(), 4); + let job = read_ext_job(&mut frames); + let prev = read_prev_hash(&mut frames); + assert_eq!(job.channel_id, opened.channel_id); + assert_eq!(job.min_ntime, None); + assert_eq!(job.version, installed.job.version()); + assert!(job.rolling_allowed, "the owner has a version mask"); + assert_eq!(job.path.len(), 2, "coinbase plus two transactions"); + assert_eq!(prev.job_id, job.job_id); + + // Exactly what a miner does with the wire fields. + let rolled = [9_u8, 9, 9, 9]; + let mut coinbase = job.prefix.clone(); + coinbase.extend_from_slice(&opened.prefix); + coinbase.extend_from_slice(&rolled); + coinbase.extend_from_slice(&job.suffix); + let mut root = double_sha256(&coinbase); + for leaf in &job.path { + let mut joined = root.as_byte_array().to_vec(); + joined.extend_from_slice(leaf); + root = double_sha256(&joined); + } + let owned = installed + .job + .merkle_root(Extranonce { + session: &opened.prefix, + rolled: &rolled, + }) + .unwrap_or_else(|error| panic!("owner root: {error:?}")); + assert_eq!( + root, owned, + "wire coinbase halves and path reproduce the owner's root" + ); +} + +#[test] +fn extended_share_is_validated_by_the_owner_and_extranonce_length_is_enforced() { + let mut wire = Wire::new(); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_extended(9, "bob.farm", ALL_ONES, 4); + let opened = read_open_extended(&mut frames); + let job = installed.job; + let rolled = [9_u8, 9, 9, 9]; + let mut full = opened.prefix.clone(); + full.extend_from_slice(&rolled); + let nonce = share_nonce(&job, &full, job.version()); + + let mut frames = wire.submit_extended( + opened.channel_id, + 1, + ShareWork { + job_id: job.id().get(), + nonce, + ntime: job.ntime(), + version: job.version(), + }, + &rolled, + ); + assert!(frames.is_empty(), "no Success before the ledger confirms"); + let share = accepted(wire.events()); + assert_eq!(share.channel_id, opened.channel_id); + assert_eq!(share.outcome.extranonce, full); + + let mut short = wire.submit_extended( + opened.channel_id, + 2, + ShareWork { + job_id: job.id().get(), + nonce, + ntime: job.ntime(), + version: job.version(), + }, + &rolled[..3], + ); + let (_, sequence, code) = share_error(&mut short); + assert_eq!((sequence, code.as_str()), (2, "bad-extranonce-size")); + frames.clear(); +} + +#[test] +fn min_extranonce_size_beyond_the_owners_rolled_length_is_refused() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut frames = wire.open_extended(3, "greedy", ALL_ONES, 5); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!(error.request_id, 3); + assert_eq!( + error.error_code.as_utf8_or_hex(), + "min-extranonce-size-too-large" + ); + assert_eq!( + wire.book.session_count(), + 0, + "a refused open holds no session" + ); +} + +#[test] +fn open_refusals_use_official_codes_and_hold_no_session() { + let mut wire = Wire::new(); + wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + let mut frames = wire.open_extended(1, "ext", ALL_ONES, 0); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!( + error.error_code.as_utf8_or_hex(), + "extended-channels-not-supported-for-standard-jobs" + ); + + let mut frames = wire.open_standard(2, "", ALL_ONES); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!(error.error_code.as_utf8_or_hex(), "invalid-user-identity"); + + let mut frames = wire.open_standard(3, "zero", [0; 32]); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!(error.error_code.as_utf8_or_hex(), "max-target-out-of-range"); + assert_eq!(wire.book.session_count(), 0); +} + +#[test] +fn channel_capacity_is_bounded_by_the_connection_and_by_the_owner() { + let mut config = server_config(); + config.limits = Limits { + max_channels: 2, + ..Limits::DEFAULT + }; + let mut wire = Wire::with(WHOLE, config, BIP320_VERSION_MASK); + wire.setup_mining(0); + for request_id in 0..2 { + let mut frames = wire.open_standard(request_id, "w", ALL_ONES); + read_open_standard(&mut frames); + } + let mut frames = wire.open_standard(9, "w", ALL_ONES); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!( + error.error_code.as_utf8_or_hex(), + "channel-capacity-exhausted" + ); + assert_eq!(wire.book.session_count(), 2); +} + +#[test] +fn channel_ids_are_unique_and_distinct_from_the_group_id() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut seen = Vec::new(); + let mut group = None; + for request_id in 0..4 { + let mut frames = wire.open_standard(request_id, "w", ALL_ONES); + let opened = read_open_standard(&mut frames); + assert!(!seen.contains(&opened.channel_id)); + assert_ne!(opened.channel_id, opened.group_id); + assert_eq!(*group.get_or_insert(opened.group_id), opened.group_id); + seen.push(opened.channel_id); + } + assert!(group.is_some_and(|id| !seen.contains(&id))); + assert_eq!(wire.conn.session().channel_count(), 4); + assert_eq!(wire.book.session_count(), 4); +} + +// ---- shares --------------------------------------------------------------------- + +#[test] +fn accepted_share_is_held_until_the_ledger_confirms_then_acknowledged() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + let frames = s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + assert!( + frames.is_empty(), + "no Success before the ledger records the share" + ); + let share = accepted(s.wire.events()); + let hash = share.outcome.hash; + assert_eq!(share.outcome.class, ShareClass::Share); + assert_eq!(share.sequence_number, 1); + assert_eq!(s.wire.conn.session().pending_acks(), 1); + let work = share.outcome.work.as_u128(); + assert_eq!(work, Some(1), "an all-ones target is one hash per share"); + + assert!( + s.wire + .conn + .confirm_share(share.ticket) + .unwrap_or_else(|error| panic!("flushes: {error:?}")) + ); + assert_eq!(s.wire.conn.session().pending_acks(), 0); + let mut frames = s.wire.drain(); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS); + assert!(frame.channel_msg); + let ok: SubmitSharesSuccess = parse(&mut frame); + assert_eq!(ok.channel_id, s.opened.channel_id); + assert_eq!(ok.last_sequence_number, 1); + assert_eq!(ok.new_submits_accepted_count, 1); + assert_eq!( + ok.new_shares_sum, 1, + "new_shares_sum carries assigned difficulty (clamped to 1 for all-ones), NOT raw work (scaled by 2^32)" + ); + assert_eq!( + hash, + share.outcome.header.compute_hash(), + "the header hash is the ledger idempotency key" + ); + + // A ticket is single-use. + assert!( + !s.wire + .conn + .confirm_share(share.ticket) + .unwrap_or_else(|error| panic!("no-op: {error:?}")) + ); + assert!(s.wire.drain().is_empty()); +} + +#[test] +fn target_difficulty_converts_share_targets_to_integer_difficulty_without_raw_hash_scaling() +-> TestResult { + fn target( + result: Result, + context: &str, + ) -> TestResult { + result.map_err(|error| TestError(format!("{context}: {error:?}"))) + } + assert_eq!( + target_difficulty(target(ShareTarget::from_pool_difficulty(1), "diff 1")?), + 1 + ); + assert_eq!( + target_difficulty(target(ShareTarget::from_pool_difficulty(42), "diff 42")?), + 42 + ); + assert_eq!( + target_difficulty(target( + ShareTarget::from_pool_difficulty(16_384), + "diff 16384" + )?), + 16_384 + ); + assert_eq!( + target_difficulty(target( + ShareTarget::from_pool_difficulty(1_000_000), + "diff 1M" + )?), + 1_000_000 + ); + assert_eq!( + target_difficulty(target(ShareTarget::from_le_bytes(ALL_ONES), "all ones")?), + 1, + "sub-1 target difficulty floors at 1 unit of acknowledged work" + ); + Ok(()) +} + +#[test] +fn the_host_can_refuse_to_credit_a_share() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + let share = accepted(s.wire.events()); + assert!( + s.wire + .conn + .reject_share( + share.ticket, + bitcoin_rs_stratum_v2::ERROR_CODE_SHARE_NOT_RECORDED + ) + .unwrap_or_else(|error| panic!("flushes: {error:?}")) + ); + let mut frames = s.wire.drain(); + let (channel, sequence, code) = share_error(&mut frames); + assert_eq!( + (channel, sequence, code.as_str()), + (s.opened.channel_id, 1, "share-not-recorded") + ); +} + +#[test] +fn a_block_candidate_carries_the_owners_full_block_for_the_node_submit_path() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = block_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + let pending = s + .wire + .events() + .into_iter() + .find_map(|event| match event { + Event::BlockFound(pending) => Some(pending), + _ => None, + }) + .unwrap_or_else(|| panic!("a block is surfaced as pending host work")); + let found = pending.found_block(); + assert_eq!(found.share.class, ShareClass::Block); + assert_eq!(found.share.hash, found.block.header.compute_hash()); + assert_eq!(found.block.header.nonce, nonce); + assert!(found.raw.len() > 80, "serialized block, not a bare header"); + assert_eq!(found.coinbase_value, s.job.coinbase_value()); + assert!(matches!( + s.wire.conn.confirm_share(pending.ticket()), + Err(Error::Session(SessionError::BlockRequiresSubmission)) + )); + assert!(s.wire.drain().is_empty()); +} + +#[test] +fn duplicate_share_is_refused_with_the_official_code() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + let job_id = s.job.id().get(); + s.wire.submit_standard( + s.opened.channel_id, + 1, + job_id, + nonce, + s.job.ntime(), + version, + ); + accepted(s.wire.events()); + let mut frames = s.wire.submit_standard( + s.opened.channel_id, + 2, + job_id, + nonce, + s.job.ntime(), + version, + ); + let (_, sequence, code) = share_error(&mut frames); + assert_eq!((sequence, code.as_str()), (2, "duplicate-share")); + let events = s.wire.events(); + assert!(matches!( + events.as_slice(), + [Event::ShareRejected { + code: "duplicate-share", + cause: Some(ShareError::Duplicate { .. }), + .. + }] + )); +} + +#[test] +fn a_share_above_the_channel_target_is_difficulty_too_low() { + let mut wire = Wire::new(); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut tight = [0_u8; 32]; + tight[8] = 1; // 2^64: no random header meets it + let mut frames = wire.open_standard(1, "tight", tight); + let opened = read_open_standard(&mut frames); + assert_eq!( + opened.target, tight, + "the channel never starts easier than max_target" + ); + let job = installed.job; + let nonce = share_nonce(&job, &opened.prefix, job.version()); + let mut frames = wire.submit_standard( + opened.channel_id, + 1, + job.id().get(), + nonce, + job.ntime(), + job.version(), + ); + let (_, _, code) = share_error(&mut frames); + assert_eq!(code, "difficulty-too-low"); +} + +#[test] +fn stale_jobs_unknown_jobs_and_closed_channels_use_official_codes() { + let mut s = standard_with_job(); + let job_id = s.job.id().get(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + + // A job id this channel was never sent. + let mut frames = + s.wire + .submit_standard(s.opened.channel_id, 1, 999, nonce, s.job.ntime(), version); + assert_eq!(share_error(&mut frames).2, "invalid-job-id"); + + // A channel that does not exist. + let mut frames = s + .wire + .submit_standard(4242, 1, job_id, nonce, s.job.ntime(), version); + assert_eq!(share_error(&mut frames).2, "invalid-channel-id"); + + // A new parent makes the job stale. + let installed = s + .wire + .book + .install(candidate(101, 0xcd, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(installed.fresh && installed.clean_jobs); + assert!(installed.invalidated.contains(&s.job.id())); + s.wire + .conn + .announce(&installed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut frames = s.wire.drain(); + assert_eq!( + types(&frames), + vec![ + MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH + ] + ); + let mut frames_stale = s.wire.submit_standard( + s.opened.channel_id, + 2, + job_id, + nonce, + s.job.ntime(), + version, + ); + let (_, _, code) = share_error(&mut frames_stale); + assert_eq!(code, "stale-share"); + frames.clear(); +} + +#[test] +fn clean_jobs_send_a_future_job_and_a_prev_hash_but_a_refresh_does_not() { + let mut s = standard_with_job(); + let old_job = s.job.id().get(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + + // Same parent, new mempool content: an active job, no prev hash. + let refresh_candidate = candidate(100, 0xab, 1); + let refreshed = s + .wire + .book + .install(Arc::clone(&refresh_candidate), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(refreshed.fresh); + assert!(!refreshed.clean_jobs); + s.wire + .conn + .announce(&refreshed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut frames = s.wire.drain(); + assert_eq!(types(&frames), vec![MESSAGE_TYPE_NEW_MINING_JOB]); + let job = read_std_job(&mut frames); + assert_eq!(job.job_id, refreshed.job.id().get()); + assert_eq!( + job.min_ntime, + Some(refreshed.job.ntime()), + "active: ntime_start set" + ); + + // The older job is still valid on the same parent: its blocks are still valid. + s.wire.submit_standard( + s.opened.channel_id, + 1, + old_job, + nonce, + s.job.ntime(), + version, + ); + accepted(s.wire.events()); + + // Announcing the same installed job again sends nothing. + s.wire + .conn + .announce(&refreshed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + assert!(s.wire.drain().is_empty()); + + // A job the owner reports as unchanged is not re-sent either. + let unchanged = s + .wire + .book + .install(refresh_candidate, Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(!unchanged.fresh); + s.wire + .conn + .announce(&unchanged) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + assert!(s.wire.drain().is_empty()); +} + +#[test] +fn sequence_numbers_must_increase_per_channel() { + let mut s = standard_with_job(); + let ntime = s.job.ntime(); + let version = s.job.version(); + let channel = s.opened.channel_id; + + let mut frames = s.wire.submit_standard(channel, 5, 999, 0, ntime, version); + assert_eq!(share_error(&mut frames).2, "invalid-job-id"); + for stale_sequence in [5, 4, 0] { + let mut frames = s + .wire + .submit_standard(channel, stale_sequence, 999, 0, ntime, version); + let (_, sequence, code) = share_error(&mut frames); + assert_eq!( + (sequence, code.as_str()), + (stale_sequence, "invalid-sequence-number") + ); + } + let mut frames = s.wire.submit_standard(channel, 6, 999, 0, ntime, version); + assert_eq!(share_error(&mut frames).2, "invalid-job-id"); +} + +#[test] +fn ntime_below_the_announced_start_is_refused() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + let mut frames = s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime() - 1, + version, + ); + assert_eq!(share_error(&mut frames).2, "invalid-share"); +} + +#[test] +fn version_rolling_is_accepted_inside_the_mask_and_refused_outside_it() { + let mut s = standard_with_job(); + let job_id = s.job.id().get(); + let rolled_version = s.job.version() | 0x0000_2000; // BIP320 bit 13 + assert_eq!(BIP320_VERSION_MASK & 0x2000, 0x2000); + let nonce = share_nonce(&s.job, &s.opened.prefix, rolled_version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + job_id, + nonce, + s.job.ntime(), + rolled_version, + ); + let share = accepted(s.wire.events()); + assert_eq!( + share.outcome.header.version, + i32::from_le_bytes(rolled_version.to_le_bytes()) + ); + + let outside = s.job.version() | 0x0000_0001; + let mut frames = s.wire.submit_standard( + s.opened.channel_id, + 2, + job_id, + nonce, + s.job.ntime(), + outside, + ); + assert_eq!( + share_error(&mut frames).2, + "invalid-non-rollable-version-bit" + ); +} + +#[test] +fn a_fixed_version_owner_refuses_any_version_change() { + let mut wire = Wire::with(WHOLE, server_config(), 0); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "fixed", ALL_ONES); + let opened = read_open_standard(&mut frames); + let job = installed.job; + let mut frames = wire.submit_standard( + opened.channel_id, + 1, + job.id().get(), + 0, + job.ntime(), + job.version() | 0x2000, + ); + assert_eq!(share_error(&mut frames).2, "version-rolling-not-allowed"); +} + +#[test] +fn unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded() { + let mut config = server_config(); + config.limits = Limits { + max_pending_acks: 1, + ..Limits::DEFAULT + }; + let mut wire = Wire::with(WHOLE, config, BIP320_VERSION_MASK); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "busy", ALL_ONES); + let opened = read_open_standard(&mut frames); + wire.events(); + let job = installed.job; + let version = job.version(); + let first = share_nonce(&job, &opened.prefix, version); + let (session, rolled) = opened.prefix.split_at(4); + let second = (first + 1..u32::MAX) + .find(|nonce| { + let header = job + .header(Extranonce { session, rolled }, job.ntime(), version, *nonce) + .unwrap_or_else(|error| panic!("header: {error:?}")); + !compact_is_met_by(job.bits(), Hash256::from(header.compute_hash())) + }) + .unwrap_or_else(|| panic!("second nonce")); + + wire.submit_standard( + opened.channel_id, + 1, + job.id().get(), + first, + job.ntime(), + version, + ); + let share = accepted(wire.events()); + let mut frames = wire.submit_standard( + opened.channel_id, + 2, + job.id().get(), + second, + job.ntime(), + version, + ); + assert_eq!(share_error(&mut frames).2, "pool-busy"); + + wire.conn + .confirm_share(share.ticket) + .unwrap_or_else(|error| panic!("confirm: {error:?}")); + wire.drain(); + // Not a duplicate: the busy refusal never reached the owner's duplicate set. + wire.submit_standard( + opened.channel_id, + 3, + job.id().get(), + second, + job.ntime(), + version, + ); + accepted(wire.events()); +} + +// ---- close, cancel, retarget, reconnect -------------------------------------------- + +#[test] +fn peer_close_releases_the_session_and_cancels_pending_acknowledgements() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + let share = accepted(s.wire.events()); + assert_eq!(s.wire.book.session_count(), 1); + + let frames = s.wire.exchange( + MESSAGE_TYPE_CLOSE_CHANNEL, + true, + CloseChannel { + channel_id: s.opened.channel_id, + reason_code: Str0255::try_from("shutdown") + .unwrap_or_else(|error| panic!("short: {error:?}")), + }, + ); + assert!(frames.is_empty()); + assert_eq!( + s.wire.book.session_count(), + 0, + "the owner's session is released" + ); + assert_eq!(s.wire.conn.session().channel_count(), 0); + assert_eq!(s.wire.conn.session().pending_acks(), 0); + assert!(matches!( + s.wire.events().as_slice(), + [Event::ChannelClosed { + reason: CloseReason::Peer, + .. + }] + )); + + // Cancelled: the ticket no longer acknowledges anything. + assert!( + !s.wire + .conn + .confirm_share(share.ticket) + .unwrap_or_else(|error| panic!("no-op: {error:?}")) + ); + assert!(s.wire.drain().is_empty()); + // And a later share on the closed channel is an invalid channel. + let mut frames = s.wire.submit_standard( + s.opened.channel_id, + 2, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + assert_eq!(share_error(&mut frames).2, "invalid-channel-id"); +} + +#[test] +fn closing_the_group_channel_closes_every_member() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "a", ALL_ONES); + let first = read_open_standard(&mut frames); + let mut frames = wire.open_extended(2, "b", ALL_ONES, 4); + let second = read_open_extended(&mut frames); + assert_eq!(first.group_id, second.group_id); + assert_eq!(wire.book.session_count(), 2); + + wire.exchange( + MESSAGE_TYPE_CLOSE_CHANNEL, + true, + CloseChannel { + channel_id: first.group_id, + reason_code: Str0255::try_from("group-down") + .unwrap_or_else(|error| panic!("short: {error:?}")), + }, + ); + assert_eq!(wire.conn.session().channel_count(), 0); + assert_eq!(wire.book.session_count(), 0); +} + +#[test] +fn host_close_tells_the_miner_and_release_is_idempotent() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "a", ALL_ONES); + let opened = read_open_standard(&mut frames); + wire.conn + .close_channel(&mut wire.book, opened.channel_id, "pool-maintenance") + .unwrap_or_else(|error| panic!("close: {error:?}")); + let mut frames = wire.drain(); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_CLOSE_CHANNEL); + let close: CloseChannel = parse(&mut frame); + assert_eq!(close.channel_id, opened.channel_id); + assert_eq!(close.reason_code.as_utf8_or_hex(), "pool-maintenance"); + assert_eq!(wire.book.session_count(), 0); + + wire.conn.release(&mut wire.book); + wire.conn.release(&mut wire.book); + assert_eq!(wire.book.session_count(), 0); +} + +#[test] +fn a_dropped_connection_releases_every_owner_session() { + let mut wire = Wire::new(); + wire.setup_mining(0); + for request_id in 0..3 { + wire.open_standard(request_id, "w", ALL_ONES); + } + assert_eq!(wire.book.session_count(), 3); + wire.conn.release(&mut wire.book); + assert_eq!(wire.book.session_count(), 0); +} + +#[test] +fn update_channel_reflects_a_smaller_max_target_in_a_set_target() { + let mut s = standard_with_job(); + let mut smaller = [0_u8; 32]; + smaller[10] = 1; + let frames = s.wire.exchange( + MESSAGE_TYPE_UPDATE_CHANNEL, + true, + UpdateChannel { + channel_id: s.opened.channel_id, + nominal_hash_rate: 2.0e12, + maximum_target: U256::from(&smaller), + }, + ); + let mut frames = frames; + assert_eq!(types(&frames), vec![MESSAGE_TYPE_SET_TARGET]); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SET_TARGET); + assert!(frame.channel_msg); + let target: SetTarget = parse(&mut frame); + assert_eq!(target.channel_id, s.opened.channel_id); + assert_eq!(*target.maximum_target.as_array(), smaller); + + // An update the channel already satisfies sends nothing. + let frames = s.wire.exchange( + MESSAGE_TYPE_UPDATE_CHANNEL, + true, + UpdateChannel { + channel_id: s.opened.channel_id, + nominal_hash_rate: 2.0e12, + maximum_target: U256::from(&ALL_ONES), + }, + ); + assert!(frames.is_empty()); + + let mut frames = s.wire.exchange( + MESSAGE_TYPE_UPDATE_CHANNEL, + true, + UpdateChannel { + channel_id: 31_337, + nominal_hash_rate: 1.0, + maximum_target: U256::from(&ALL_ONES), + }, + ); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_UPDATE_CHANNEL_ERROR); + let error: UpdateChannelError = parse(&mut frame); + assert_eq!(error.error_code.as_utf8_or_hex(), "invalid-channel-id"); +} + +#[test] +fn a_host_retarget_is_sent_and_never_exceeds_the_miners_max_target() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut max_target = [0_u8; 32]; + max_target[9] = 0x10; + let mut frames = wire.open_standard(1, "vardiff", max_target); + let opened = read_open_standard(&mut frames); + assert_eq!(opened.target, max_target); + + // Above the miner's own maximum: refused. + let easier = + ShareTarget::from_le_bytes(ALL_ONES).unwrap_or_else(|error| panic!("nonzero: {error:?}")); + assert!(matches!( + wire.conn.set_target(opened.channel_id, easier), + Err(Error::Session(SessionError::TargetAboveMaximum { .. })) + )); + + // The refusal did not kill the connection: a harder target is accepted. + let mut harder = [0_u8; 32]; + harder[2] = 1; + wire.conn + .set_target( + opened.channel_id, + ShareTarget::from_le_bytes(harder).unwrap_or_else(|error| panic!("nonzero: {error:?}")), + ) + .unwrap_or_else(|error| panic!("harder target accepted: {error:?}")); + let mut frames = wire.drain(); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SET_TARGET); + assert!(frame.channel_msg); + let msg: SetTarget = parse(&mut frame); + assert_eq!(msg.channel_id, opened.channel_id); + assert_eq!(*msg.maximum_target.as_array(), harder); + let info = wire + .conn + .session() + .channel(opened.channel_id) + .unwrap_or_else(|| panic!("channel open")); + assert_eq!(info.target.to_le_bytes(), harder); + assert_eq!(info.max_target.to_le_bytes(), max_target); + + // Unknown channels are a host error, not a dead connection either. + assert!(matches!( + wire.conn.set_target(9_999, easier), + Err(Error::Session(SessionError::UnknownChannel(9_999))) + )); + assert!(!wire.conn.is_closing()); +} + +#[test] +fn reconnect_is_sent_and_new_channels_are_refused_afterwards() { + let mut wire = Wire::new(); + wire.setup_mining(0); + wire.conn + .request_reconnect("pool2.invalid", 3334) + .unwrap_or_else(|error| panic!("reconnect: {error:?}")); + let mut frames = wire.drain(); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_RECONNECT); + assert!(!frame.channel_msg); + let reconnect: common_messages_sv2::Reconnect = parse(&mut frame); + assert_eq!(reconnect.new_host.as_utf8_or_hex(), "pool2.invalid"); + assert_eq!(reconnect.new_port, 3334); + + let mut frames = wire.open_standard(1, "late", ALL_ONES); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR); + let error: OpenMiningChannelError = parse(&mut frame); + assert_eq!(error.error_code.as_utf8_or_hex(), "reconnect-requested"); +} + +// ---- group distribution ------------------------------------------------------------ + +#[test] +fn a_connection_without_standard_jobs_gets_one_group_addressed_job() { + let mut wire = Wire::new(); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "std", ALL_ONES); + let standard = read_open_standard(&mut frames); + let mut frames = wire.open_extended(2, "ext", ALL_ONES, 4); + let extended = read_open_extended(&mut frames); + assert_eq!(standard.group_id, extended.group_id); + let group = standard.group_id; + + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.conn + .announce(&installed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut frames = wire.drain(); + assert_eq!( + types(&frames), + vec![ + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH + ], + "one broadcast for the whole group, not one per channel" + ); + let job = read_ext_job(&mut frames); + let prev = read_prev_hash(&mut frames); + assert_eq!(job.channel_id, group); + assert_eq!(prev.channel_id, group); + assert_eq!(job.job_id, prev.job_id); + assert_eq!(job.min_ntime, None); + + // A standard channel works the group job with its own prefix. + let owned = wire.job(job.job_id); + let nonce = share_nonce(&owned, &standard.prefix, owned.version()); + wire.submit_standard( + standard.channel_id, + 1, + job.job_id, + nonce, + owned.ntime(), + owned.version(), + ); + assert_eq!(accepted(wire.events()).channel_id, standard.channel_id); + + // And so does the extended channel, rolling its own bytes. + let rolled = [1_u8, 2, 3, 4]; + let mut full = extended.prefix.clone(); + full.extend_from_slice(&rolled); + let nonce = share_nonce(&owned, &full, owned.version()); + wire.submit_extended( + extended.channel_id, + 1, + ShareWork { + job_id: job.job_id, + nonce, + ntime: owned.ntime(), + version: owned.version(), + }, + &rolled, + ); + assert_eq!(accepted(wire.events()).channel_id, extended.channel_id); + + // A same-parent refresh is a group-addressed active job without a prev hash. + let refreshed = wire + .book + .install(candidate(100, 0xab, 1), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.conn + .announce(&refreshed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut frames = wire.drain(); + assert_eq!(types(&frames), vec![MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB]); + let job = read_ext_job(&mut frames); + assert_eq!(job.channel_id, group); + assert_eq!(job.min_ntime, Some(refreshed.job.ntime())); +} + +#[test] +fn a_connection_requiring_standard_jobs_gets_one_job_per_channel() { + let mut wire = Wire::new(); + wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + let mut frames = wire.open_standard(1, "a", ALL_ONES); + let a = read_open_standard(&mut frames); + let mut frames = wire.open_standard(2, "b", ALL_ONES); + let b = read_open_standard(&mut frames); + assert_ne!( + a.prefix, b.prefix, + "every channel has a unique extranonce prefix" + ); + + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.conn + .announce(&installed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut frames = wire.drain(); + assert_eq!( + types(&frames), + vec![ + MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + MESSAGE_TYPE_NEW_MINING_JOB, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH + ] + ); + let first = read_std_job(&mut frames); + let second = read_std_job(&mut frames); + assert_eq!(first.job_id, second.job_id); + assert_ne!(first.channel_id, second.channel_id); + assert_ne!( + first.merkle_root, second.merkle_root, + "different extranonce, different merkle root" + ); + assert_eq!(first.version, second.version); +} + +// ---- roles --------------------------------------------------------------------------- + +#[test] +fn custom_jobs_are_answered_with_the_official_code_not_pretended() { + let mut s = standard_with_job(); + let mut frames = s.wire.exchange( + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB, + true, + SetCustomMiningJob { + channel_id: s.opened.channel_id, + request_id: 5, + token: B0255::new(&[1, 2, 3]).unwrap_or_else(|error| panic!("short: {error:?}")), + version: 0x2000_0000, + prev_hash: U256::from(&ALL_ONES), + min_ntime: 1, + nbits: 0x207f_ffff, + coinbase_tx_version: 2, + coinbase_prefix: B0255::new(&[0x03, 0x64, 0x00, 0x00]) + .unwrap_or_else(|error| panic!("short: {error:?}")), + coinbase_tx_input_n_sequence: u32::MAX, + coinbase_tx_outputs: B064K::new(&[0]) + .unwrap_or_else(|error| panic!("short: {error:?}")), + coinbase_tx_locktime: 0, + merkle_path: Seq0255::new(Vec::new()) + .unwrap_or_else(|error| panic!("empty: {error:?}")), + }, + ); + let mut frame = take_type(&mut frames, MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_ERROR); + let error: SetCustomMiningJobError = parse(&mut frame); + assert_eq!(error.channel_id, s.opened.channel_id); + assert_eq!(error.request_id, 5); + assert_eq!(error.error_code.as_utf8_or_hex(), "jd-not-supported"); +} + +#[test] +fn the_role_matrix_does_not_claim_what_is_not_built() { + let status = |name: &str| { + ROLES + .iter() + .find(|role| role.name.starts_with(name)) + .map_or_else(|| panic!("role listed"), |role| role.status) + }; + assert_eq!(status("Standard channels"), RoleStatus::Implemented); + assert_eq!(status("Extended channels"), RoleStatus::Implemented); + assert_eq!(status("Group channels"), RoleStatus::Implemented); + assert_eq!(status("Noise_NX"), RoleStatus::Implemented); + assert_eq!(status("Job Declaration server"), RoleStatus::NotImplemented); + assert_eq!( + status("Work selection"), + RoleStatus::NotImplemented, + "no owner-side install path for miner-declared jobs" + ); + assert_eq!( + status("Template Distribution server"), + RoleStatus::NotImplemented + ); + assert_eq!(status("Extensions negotiation"), RoleStatus::NotImplemented); + assert!( + ROLES + .iter() + .filter(|role| role.status == RoleStatus::NotImplemented) + .all(|role| !role.detail.is_empty()) + ); +} + +#[test] +fn the_installed_fact_set_is_what_the_adapter_consumes() { + // Compile-time check of the contract used above: `Installed` exposes the + // job, `fresh`, `clean_jobs` and `invalidated`. + fn contract(installed: &Installed) -> (bool, bool, usize, JobId) { + ( + installed.fresh, + installed.clean_jobs, + installed.invalidated.len(), + installed.job.id(), + ) + } + let mut book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .unwrap_or_else(|error| panic!("book: {error:?}")); + let installed = book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + let (fresh, clean, invalidated, id) = contract(&installed); + assert!(fresh && clean && invalidated == 0); + assert!(book.current().is_some_and(|job| job.id() == id)); +} diff --git a/crates/stratum/Cargo.toml b/crates/stratum/Cargo.toml new file mode 100644 index 0000000000..08316b307e --- /dev/null +++ b/crates/stratum/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "bitcoin-rs-stratum" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +version.workspace = true +authors.workspace = true +repository.workspace = true +description = "bitcoin-rs :: Stratum V1 pool protocol and native job/share owner" + +[lints] +workspace = true + +[dependencies] +bitcoin-rs-primitives.workspace = true +bitcoin-rs-consensus.workspace = true +bitcoin-rs-script.workspace = true +bitcoin-rs-chain.workspace = true +bitcoin-rs-mining.workspace = true +compact_str.workspace = true +ruint.workspace = true +sonic-rs.workspace = true +thiserror.workspace = true + +[features] +default = [] + +[dev-dependencies] +bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } +bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } +parking_lot.workspace = true +sha2.workspace = true diff --git a/crates/stratum/src/job.rs b/crates/stratum/src/job.rs new file mode 100644 index 0000000000..d5e3da4755 --- /dev/null +++ b/crates/stratum/src/job.rs @@ -0,0 +1,1555 @@ +//! Job projection, extranonce allocation, and the single share predicate. +//! +//! One [`JobBook`] owns every fact a Stratum V1 or V2 adapter needs about +//! work it handed to miners: the coinbase split around the extranonce, the +//! merkle branch, the header template, bounded job retention, bounded session +//! identity, stale state, and duplicate state. The template itself is built by +//! `MiningControl`; a block is validated by `MiningControl`. Money, keys, and +//! difficulty policy are inputs, never state, here. +//! +//! Invariants: +//! - `coinbase_prefix || session || rolled || coinbase_suffix` is the exact +//! non-witness (txid) serialization of the coinbase the block will carry. +//! - A job never grows its candidate's coinbase beyond the declared +//! [`CoinbaseBudget`]: scriptSig growth stays under the 100-byte consensus +//! limit, extra outputs stay inside the budget's size and sigop allowance, +//! and `Candidate::admits` (the owner's rule) passes for the budget. +//! - A stale job is never revived. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use bitcoin_rs_chain::{ChainWork, compact_is_met_by}; +use bitcoin_rs_consensus::transaction_sigop_cost; +use bitcoin_rs_mining::{ + BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, + BlockValidationResult, Candidate, CoinbaseBudget, MiningControl, MiningControlError, + MiningError, +}; +use bitcoin_rs_primitives::{ + Amount, Block, BlockHash, BlockPayload, CompactTarget, Hash256, Header, Script, Tx, TxOut, + Txid, Witness, + encode::{consensus_bytes, double_sha256}, + varint, +}; +use bitcoin_rs_script::VerifyFlags; +use compact_str::CompactString; +use ruint::Uint; +use thiserror::Error; + +/// Consensus limit on a coinbase scriptSig length (`bad-cb-length`). +pub const MAX_COINBASE_SCRIPT_SIG_LEN: usize = 100; +/// BIP320 general-purpose version-rolling bits. +pub const BIP320_VERSION_MASK: u32 = 0x1fff_e000; +/// Core's `MAX_FUTURE_BLOCK_TIME`: the largest forward `ntime` window a job may grant. +pub const MAX_NTIME_FORWARD_SECS: u32 = 7_200; + +/// Smallest scriptSig the node builds (`bad-cb-length` lower bound). +const MIN_COINBASE_SCRIPT_SIG_LEN: usize = 2; +/// Offset of the scriptSig length byte in a one-input coinbase's txid serialization: +/// version (4) + input count (1) + outpoint (36). +const SCRIPT_SIG_LEN_OFFSET: usize = 41; +/// Offset of the first scriptSig byte in the same serialization. +const SCRIPT_SIG_OFFSET: usize = 42; + +/// Wire-neutral job identity, unique and increasing within one [`JobBook`]. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct JobId(u32); + +impl JobId { + /// Wraps a raw wire value. + #[must_use] + pub const fn new(raw: u32) -> Self { + Self(raw) + } + + /// Returns the raw wire value. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// Session identity, unique within one [`JobBook`] lifetime. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SessionId(u32); + +impl SessionId { + /// Wraps a raw wire value. + #[must_use] + pub const fn new(raw: u32) -> Self { + Self(raw) + } + + /// Returns the raw wire value. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// Split of the coinbase extranonce between the pool and the miner. +/// +/// `session_len` bytes are assigned by the pool and unique per open session +/// (SV1 `extranonce1`, SV2 extended-channel prefix). `rolled_len` bytes are +/// chosen by the miner (SV1 `extranonce2`, SV2 remaining extranonce). +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ExtranonceLayout { + session_len: u8, + rolled_len: u8, +} + +impl ExtranonceLayout { + /// Builds a layout of `session_len` pool bytes and `rolled_len` miner bytes. + #[must_use] + pub const fn new(session_len: u8, rolled_len: u8) -> Self { + Self { + session_len, + rolled_len, + } + } + + /// Pool-assigned bytes. + #[must_use] + pub fn session_len(self) -> usize { + usize::from(self.session_len) + } + + /// Miner-chosen bytes. + #[must_use] + pub fn rolled_len(self) -> usize { + usize::from(self.rolled_len) + } + + /// Total extranonce bytes inside the coinbase scriptSig. + #[must_use] + pub fn total_len(self) -> usize { + self.session_len() + self.rolled_len() + } + + /// Number of distinct pool prefixes this layout can name. + fn session_capacity(self) -> u64 { + match self.session_len { + 0 => 1, + len @ 1..=7 => 1_u64 << (8 * u32::from(len)), + _ => u64::MAX, + } + } +} + +/// Extranonce bytes of one share: pool prefix plus miner-chosen bytes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Extranonce<'a> { + /// Pool-assigned prefix; length `ExtranonceLayout::session_len`. + pub session: &'a [u8], + /// Miner-chosen bytes; length `ExtranonceLayout::rolled_len`. + pub rolled: &'a [u8], +} + +/// One open miner session and its unique extranonce prefix. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Session { + id: SessionId, + prefix: Vec, + slot: u64, +} + +impl Session { + /// Session identity. + #[must_use] + pub const fn id(&self) -> SessionId { + self.id + } + + /// Pool-assigned extranonce prefix, `ExtranonceLayout::session_len` bytes. + #[must_use] + pub fn extranonce_prefix(&self) -> &[u8] { + &self.prefix + } +} + +/// Bounded session table: unique prefixes, oldest-freed prefix reused first. +#[derive(Debug)] +struct SessionTable { + layout: ExtranonceLayout, + capacity: usize, + next_slot: u64, + free: VecDeque, + next_id: u32, + live: HashMap, +} + +impl SessionTable { + fn new(layout: ExtranonceLayout, capacity: usize) -> Self { + Self { + layout, + capacity, + next_slot: 0, + free: VecDeque::new(), + next_id: 1, + live: HashMap::new(), + } + } + + fn open(&mut self) -> Result { + if self.live.len() >= self.capacity { + return Err(JobError::SessionCapacity { + capacity: self.capacity, + }); + } + let following = self + .next_id + .checked_add(1) + .ok_or(JobError::SessionIdExhausted)?; + let slot = if let Some(slot) = self.free.pop_front() { + slot + } else { + let slot = self.next_slot; + self.next_slot = slot.saturating_add(1); + slot + }; + let session = Session { + id: SessionId(self.next_id), + prefix: slot_prefix(slot, self.layout.session_len()), + slot, + }; + self.next_id = following; + self.live.insert(session.id, session.clone()); + Ok(session) + } + + fn close(&mut self, id: SessionId) -> bool { + match self.live.remove(&id) { + Some(session) => { + self.free.push_back(session.slot); + true + } + None => false, + } + } + + fn get(&self, id: SessionId) -> Option<&Session> { + self.live.get(&id) + } +} + +/// Encodes `slot` little-endian into exactly `len` bytes, zero padded. +fn slot_prefix(slot: u64, len: usize) -> Vec { + let le = slot.to_le_bytes(); + let mut prefix = vec![0_u8; len]; + let shared = len.min(le.len()); + prefix[..shared].copy_from_slice(&le[..shared]); + prefix +} + +/// Setup and projection failures. Nothing here is a share verdict. +#[derive(Debug, Eq, Error, PartialEq)] +pub enum JobError { + /// A configuration field is unusable. + #[error("invalid job book configuration: {0}")] + InvalidConfig(&'static str), + /// The owner's rule: the candidate cannot admit the declared coinbase budget. + #[error("candidate cannot admit the declared coinbase budget: {0}")] + Budget(MiningError), + /// The candidate coinbase is not a single-input coinbase with an output. + #[error("candidate coinbase is not a one-input coinbase with at least one output")] + CoinbaseShape, + /// Tag plus extranonce do not fit under the scriptSig consensus limit. + #[error( + "coinbase tag and extranonce need {needed} scriptSig bytes but only {room} remain under the consensus limit" + )] + ExtranonceExceedsScriptSig { + /// Bytes available after the node's height prefix. + room: usize, + /// Bytes the tag and extranonce need. + needed: usize, + }, + /// Extra outputs serialize to more bytes than the budget declares. + #[error("extra coinbase outputs serialize to {used} bytes, budget allows {budget}")] + ExtraOutputsSize { + /// Serialized bytes of the extra outputs. + used: u64, + /// Declared `additional_size`. + budget: u32, + }, + /// Extra outputs cost more sigops than the budget declares. + #[error("extra coinbase outputs cost {used} sigops, budget allows {budget}")] + ExtraOutputsSigops { + /// BIP141 sigop cost of the extra outputs. + used: u32, + /// Declared `additional_sigops`. + budget: u16, + }, + /// Extra outputs pay more than the node's coinbase output holds. + #[error( + "extra coinbase outputs pay {extra} sat but the node payout output holds {available} sat" + )] + ExtraOutputsValue { + /// Sum of the extra output values. + extra: u64, + /// Value of the node payout output. + available: u64, + }, + /// The coinbase split failed its txid round-trip. + #[error("coinbase split does not reproduce the coinbase txid serialization")] + CoinbaseSplit, + /// Every job id has been issued. + #[error("job ids are exhausted")] + JobIdExhausted, + /// Every session id has been issued. + #[error("session ids are exhausted")] + SessionIdExhausted, + /// All session slots are in use. + #[error("all {capacity} session slots are in use")] + SessionCapacity { + /// Configured session capacity. + capacity: usize, + }, + /// A share target or difficulty of zero names no work. + #[error("share target and pool difficulty must be nonzero")] + ZeroTarget, + /// Pool difficulty text is not a bounded plain decimal. + #[error("pool difficulty is not a plain decimal of at most 19 digits")] + InvalidDifficulty, +} + +/// Why a job no longer accepts shares. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum StaleReason { + /// A job on a different parent was installed after it. + PreviousBlockChanged, + /// [`JobBook::invalidate_active`] retired it. + Invalidated, + /// Evicted by job retention; only the id is remembered. + Retired, +} + +/// Share verdicts that reject the share. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ShareError { + /// The session is not open. + #[error("unknown session {0:?}")] + UnknownSession(SessionId), + /// The job id was never issued. + #[error("unknown job {0:?}")] + UnknownJob(JobId), + /// The job exists or existed but accepts no more shares. + #[error("stale share for job {job:?}: {reason:?}")] + Stale { + /// Job the share named. + job: JobId, + /// Why it is stale. + reason: StaleReason, + }, + /// The session prefix does not match the layout. + #[error("session extranonce prefix is {actual} bytes, layout needs {expected}")] + SessionPrefixLength { + /// Layout `session_len`. + expected: usize, + /// Supplied length. + actual: usize, + }, + /// The miner-chosen extranonce length does not match the layout. + #[error("rolled extranonce is {actual} bytes, layout needs {expected}")] + ExtranonceLength { + /// Layout `rolled_len`. + expected: usize, + /// Supplied length. + actual: usize, + }, + /// The version changes bits outside the job's rolling mask. + #[error( + "version {version:#010x} differs from job version {job_version:#010x} outside mask {mask:#010x}" + )] + VersionOutsideMask { + /// Submitted full version. + version: u32, + /// Job version. + job_version: u32, + /// Job rolling mask. + mask: u32, + }, + /// `ntime` is below the job's minimum legal timestamp. + #[error("ntime {ntime} is before the job minimum {min_ntime}")] + TimeTooEarly { + /// Submitted time. + ntime: u32, + /// Job minimum. + min_ntime: u32, + }, + /// `ntime` is beyond the job's forward window. + #[error("ntime {ntime} is after the job maximum {max_ntime}")] + TimeTooLate { + /// Submitted time. + ntime: u32, + /// Job maximum. + max_ntime: u32, + }, + /// The same header was already accepted. + #[error("duplicate share {hash}, first accepted under job {first_job:?}")] + Duplicate { + /// Header hash. + hash: BlockHash, + /// Job it was first accepted under. + first_job: JobId, + }, + /// The hash meets neither the network target nor the share target. + #[error("share hash {hash} does not meet the share target")] + BelowTarget { + /// Header hash. + hash: BlockHash, + }, + /// The job holds its maximum number of accepted shares. + #[error("job {job:?} already holds {capacity} accepted shares")] + JobShareCapacity { + /// Job the share named. + job: JobId, + /// Configured per-job capacity. + capacity: usize, + }, +} + +/// A nonzero 256-bit share target. A hash meets it when it is less than or equal. +/// +/// The value is the numeric hash order Bitcoin uses: `to_le_bytes` is the +/// internal byte order of [`Hash256`], `to_be_bytes` the display order and +/// the SV1 / SV2 `U256` target byte order after the adapter's own swap. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ShareTarget(ChainWork); + +impl ShareTarget { + /// Builds a target from little-endian bytes; zero is rejected. + pub fn from_le_bytes(bytes: [u8; 32]) -> Result { + let value = ChainWork::from_le_bytes(bytes); + if value == ChainWork::ZERO { + return Err(JobError::ZeroTarget); + } + Ok(Self(value)) + } + + /// Builds a target from big-endian bytes; zero is rejected. + pub fn from_be_bytes(mut bytes: [u8; 32]) -> Result { + bytes.reverse(); + Self::from_le_bytes(bytes) + } + + /// Builds the target of integer pool difficulty `difficulty`; see + /// [`Self::from_pool_difficulty_ratio`]. + pub fn from_pool_difficulty(difficulty: u64) -> Result { + Self::from_pool_difficulty_ratio(difficulty, 1) + } + + /// Builds the target of pool difficulty `numerator / denominator` exactly: + /// `floor(0xffff * 2^208 * denominator / numerator)`. Difficulty 1 is + /// compact `0x1d00ffff`. A difficulty so small that the target exceeds + /// `2^256 - 1` saturates at `2^256 - 1`, which every hash meets. + /// + /// This is the one owner of difficulty-to-target arithmetic. Which + /// difficulty a session gets (vardiff) stays the adapter's policy. + pub fn from_pool_difficulty_ratio(numerator: u64, denominator: u64) -> Result { + if numerator == 0 || denominator == 0 { + return Err(JobError::ZeroTarget); + } + let difficulty_one = Uint::<512, 8>::from(0xffff_u32) << 208_usize; + let scaled = difficulty_one * Uint::<512, 8>::from(denominator); + let quotient = scaled / Uint::<512, 8>::from(numerator); + let wide: [u8; 64] = quotient.to_le_bytes(); + let (low, high) = wide.split_at(32); + let mut bytes = [0xff_u8; 32]; + if high.iter().all(|byte| *byte == 0) { + bytes.copy_from_slice(low); + } + Self::from_le_bytes(bytes) + } + + /// Builds the target of a pool difficulty written as a plain decimal such + /// as `"16384"` or `"0.001"` (SV1 `mining.set_difficulty`), without floats. + /// + /// Accepted: ASCII digits, an optional `.` followed by digits, at most 19 + /// digits in total. Exponents, signs, and a missing integer part are + /// rejected with [`JobError::InvalidDifficulty`]; zero with + /// [`JobError::ZeroTarget`]. + pub fn from_pool_difficulty_decimal(text: &str) -> Result { + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let well_formed = !whole.is_empty() + && whole.len() + fraction.len() <= 19 + && whole + .bytes() + .chain(fraction.bytes()) + .all(|byte| byte.is_ascii_digit()); + if !well_formed { + return Err(JobError::InvalidDifficulty); + } + let numerator = whole + .bytes() + .chain(fraction.bytes()) + .fold(0_u64, |value, byte| value * 10 + u64::from(byte - b'0')); + let scale = u32::try_from(fraction.len()).map_err(|_| JobError::InvalidDifficulty)?; + let denominator = 10_u64 + .checked_pow(scale) + .ok_or(JobError::InvalidDifficulty)?; + Self::from_pool_difficulty_ratio(numerator, denominator) + } + + /// Little-endian bytes (internal [`Hash256`] order). + #[must_use] + pub fn to_le_bytes(self) -> [u8; 32] { + self.0.to_le_bytes() + } + + /// Big-endian bytes (display order). + #[must_use] + pub fn to_be_bytes(self) -> [u8; 32] { + let mut bytes = self.to_le_bytes(); + bytes.reverse(); + bytes + } + + /// Whether `hash`, read as a little-endian 256-bit integer, is at most the target. + #[must_use] + pub fn meets(self, hash: Hash256) -> bool { + ChainWork::from_le_bytes(hash.to_le_bytes()) <= self.0 + } + + /// Expected hashes behind one share at this target: `floor(2^256 / (target + 1))`. + #[must_use] + pub fn work(self) -> ShareWork { + let one = ChainWork::from(1_u32); + match self.0.checked_add(one) { + // target = 2^256 - 1: every hash meets it, one hash per share. + None => ShareWork(one), + Some(denominator) => ShareWork((!self.0) / denominator + one), + } + } +} + +/// Exact integer work of a share target, the unit PPLNS-style accounting sums. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ShareWork(ChainWork); + +impl ShareWork { + /// Little-endian bytes of the 256-bit work value. + #[must_use] + pub fn to_le_bytes(self) -> [u8; 32] { + self.0.to_le_bytes() + } + + /// Big-endian bytes of the 256-bit work value. + #[must_use] + pub fn to_be_bytes(self) -> [u8; 32] { + let mut bytes = self.to_le_bytes(); + bytes.reverse(); + bytes + } + + /// The work as `u128`, or `None` when it needs more than 128 bits. + #[must_use] + pub fn as_u128(self) -> Option { + u128::try_from(&self.0).ok() + } + + /// Sum of two work values, or `None` on 256-bit overflow. + #[must_use] + pub fn checked_add(self, other: Self) -> Option { + self.0.checked_add(other.0).map(Self) + } +} + +/// One share as submitted, with every field explicit. +#[derive(Clone, Copy, Debug)] +pub struct ShareSubmission<'a> { + /// Submitting session. + pub session: SessionId, + /// Job the miner worked on. + pub job: JobId, + /// Miner-chosen extranonce bytes. + pub rolled_extranonce: &'a [u8], + /// Header time. + pub ntime: u32, + /// Header nonce. + pub nonce: u32, + /// Complete header version as hashed (rolled bits applied). + pub version: u32, +} + +/// What an accepted share is worth to the pool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShareClass { + /// Meets the share target only. + Share, + /// Meets the network target: a block candidate. + Block, +} + +/// An accepted share: identity and work, with no financial meaning attached. +#[derive(Clone, Debug)] +pub struct AcceptedShare { + /// Submitting session. + pub session: SessionId, + /// Job the share belongs to. + pub job: JobId, + /// Header hash. Unique per accepted share: the idempotency key a durable + /// ledger must index uniquely. + pub hash: BlockHash, + /// The exact header hashed. + pub header: Header, + /// Candidate block height. + pub height: u32, + /// Parent block hash of the job. + pub previous_block_hash: BlockHash, + /// Session prefix followed by the miner-chosen bytes. + pub extranonce: Vec, + /// Share target the share was judged against. + pub target: ShareTarget, + /// Work of `target`, not of the hash found. + pub work: ShareWork, + /// Share or block candidate. + pub class: ShareClass, +} + +/// A share that meets the network target, with the full block to submit. +#[derive(Clone, Debug)] +pub struct FoundBlock { + /// The accepted share that found it; `share.class` is [`ShareClass::Block`]. + pub share: AcceptedShare, + /// The complete block: reassembled coinbase, then the candidate's transactions. + pub block: Block, + /// Exact consensus serialization of `block`. + pub raw: Vec, + /// Txid of the block's coinbase. + pub coinbase_txid: Txid, + /// Total coinbase output value (subsidy plus fees). + pub coinbase_value: u64, +} + +impl FoundBlock { + /// Hands the block to the node's authoritative submit path. + /// + /// `&self` so an `Err` leaves the block available for a retry. An `Err` + /// or an [`BlockStatus::Undetermined`] receipt means the chain outcome is + /// unknown: the caller must not infer acceptance or rejection. + pub fn submit(&self, control: &dyn MiningControl) -> Result { + let payload = BlockPayload::from_raw(self.raw.clone().into()) + .map_err(|error| MiningControlError::Failed(CompactString::from(error.to_string())))?; + let result = control.submit_block(payload)?; + Ok(BlockReceipt { + hash: self.share.hash, + height: self.share.height, + previous_block_hash: self.share.previous_block_hash, + coinbase_txid: self.coinbase_txid, + coinbase_value: self.coinbase_value, + session: self.share.session, + job: self.share.job, + result, + }) + } +} + +/// Chain outcome class of a submitted block, from the node's verdict alone. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BlockStatus { + /// Accepted, or its body was already connected. + Applied, + /// Rejected, or a duplicate of a known-invalid block. + Rejected, + /// The node could not conclude; reconcile from chain observation. + Undetermined, +} + +/// The node's answer to one block submission. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BlockReceipt { + /// Block hash. + pub hash: BlockHash, + /// Block height. + pub height: u32, + /// Parent block hash. + pub previous_block_hash: BlockHash, + /// Coinbase txid. + pub coinbase_txid: Txid, + /// Total coinbase output value. + pub coinbase_value: u64, + /// Session that found the block. + pub session: SessionId, + /// Job the block was built from. + pub job: JobId, + /// The node's BIP22 verdict. + pub result: BlockValidationResult, +} + +impl BlockReceipt { + /// Maps the node verdict onto the three outcomes a ledger can act on. + #[must_use] + pub fn status(&self) -> BlockStatus { + match &self.result { + BlockValidationResult::Accepted | BlockValidationResult::Duplicate => { + BlockStatus::Applied + } + BlockValidationResult::DuplicateInvalid | BlockValidationResult::Rejected(_) => { + BlockStatus::Rejected + } + BlockValidationResult::Inconclusive | BlockValidationResult::DuplicateInconclusive => { + BlockStatus::Undetermined + } + } + } +} + +/// Result of a share that passed every check. +#[derive(Clone, Debug)] +pub enum ShareOutcome { + /// Meets the share target only. + Share(Box), + /// Meets the network target. + Block(Box), +} + +impl ShareOutcome { + /// The accepted share, whichever class it is. + #[must_use] + pub fn share(&self) -> &AcceptedShare { + match self { + Self::Share(share) => share, + Self::Block(found) => &found.share, + } + } +} + +/// One immutable job: a candidate projected onto the Stratum work shape. +#[derive(Debug)] +pub struct Job { + id: JobId, + candidate: Arc, + extranonce: ExtranonceLayout, + version_mask: u32, + version: u32, + ntime: u32, + max_ntime: u32, + script_sig_head: Vec, + coinbase_template: Tx, + coinbase_prefix: Vec, + coinbase_suffix: Vec, + merkle_branch: Vec, + extra_outputs: Vec, +} + +fn validate_extra_outputs( + extra_outputs: Vec, + available: u64, + budget: CoinbaseBudget, +) -> Result<(Vec, u64), JobError> { + let mut extra_size = 0_u64; + let mut extra_value = 0_u64; + for output in &extra_outputs { + let script_len = u64::try_from(output.script_pubkey.len()).unwrap_or(u64::MAX); + let prefix_len = u64::try_from(varint::encoded_len(script_len)).unwrap_or(u64::MAX); + extra_size = extra_size + .saturating_add(8) + .saturating_add(prefix_len) + .saturating_add(script_len); + extra_value = extra_value.saturating_add(output.value.to_sat()); + } + if extra_size > u64::from(budget.additional_size) { + return Err(JobError::ExtraOutputsSize { + used: extra_size, + budget: budget.additional_size, + }); + } + let probe = Tx { + outputs: extra_outputs, + ..Tx::default() + }; + let extra_sigops = transaction_sigop_cost(&probe, &[], VerifyFlags::NONE); + if extra_sigops > u32::from(budget.additional_sigops) { + return Err(JobError::ExtraOutputsSigops { + used: extra_sigops, + budget: budget.additional_sigops, + }); + } + if extra_value > available { + return Err(JobError::ExtraOutputsValue { + extra: extra_value, + available, + }); + } + Ok((probe.outputs, extra_value)) +} + +impl Job { + fn project( + id: JobId, + candidate: Arc, + config: &JobBookConfig, + extra_outputs: Vec, + ) -> Result { + candidate.admits(config.budget).map_err(JobError::Budget)?; + + let (Some(input), Some(payout)) = ( + candidate.coinbase.inputs.first(), + candidate.coinbase.outputs.first(), + ) else { + return Err(JobError::CoinbaseShape); + }; + if !candidate.coinbase.is_coinbase() { + return Err(JobError::CoinbaseShape); + } + let available = payout.value.to_sat(); + let base = input.script_sig.as_bytes(); + let total = config.extranonce.total_len(); + let room = MAX_COINBASE_SCRIPT_SIG_LEN.saturating_sub(base.len()); + let needed = config.coinbase_tag.len().saturating_add(total); + if needed > room { + return Err(JobError::ExtranonceExceedsScriptSig { room, needed }); + } + + let (extra_outputs, extra_value) = + validate_extra_outputs(extra_outputs, available, config.budget)?; + + let mut head = Vec::with_capacity(base.len() + config.coinbase_tag.len()); + head.extend_from_slice(base); + head.extend_from_slice(&config.coinbase_tag); + let mut placeholder = head.clone(); + placeholder.resize(head.len() + total, 0); + + let mut template = candidate.coinbase.clone(); + if let Some(first_input) = template.inputs.first_mut() { + first_input.script_sig = Script::from_bytes(placeholder); + } + let mut outputs = Vec::with_capacity(template.outputs.len() + extra_outputs.len()); + let mut original = std::mem::take(&mut template.outputs).into_iter(); + if let Some(mut first) = original.next() { + first.value = Amount::from_sat(available - extra_value); + outputs.push(first); + } + outputs.extend(extra_outputs.iter().cloned()); + outputs.extend(original); + template.outputs = outputs; + + let mut legacy = template.clone(); + for legacy_input in &mut legacy.inputs { + legacy_input.witness = Witness::new(); + } + let bytes = consensus_bytes(&legacy); + let start = SCRIPT_SIG_OFFSET + head.len(); + let end = start + total; + let declared = u8::try_from(head.len() + total).map_err(|_| JobError::CoinbaseSplit)?; + let round_trips = bytes.get(4) == Some(&1) + && bytes.get(SCRIPT_SIG_LEN_OFFSET) == Some(&declared) + && end <= bytes.len() + && double_sha256(&bytes) == Hash256::from(template.txid()); + if !round_trips { + return Err(JobError::CoinbaseSplit); + } + let coinbase_prefix = bytes[..start].to_vec(); + let coinbase_suffix = bytes[end..].to_vec(); + + let txids: Vec = candidate + .transactions + .iter() + .map(|entry| entry.txid) + .collect(); + let merkle_branch = coinbase_merkle_branch(&txids); + let ntime = candidate.current_time.max(candidate.min_time); + + Ok(Self { + id, + extranonce: config.extranonce, + version_mask: config.version_mask, + version: u32::from_le_bytes(candidate.version.to_le_bytes()), + ntime, + max_ntime: ntime.saturating_add(config.ntime_forward_secs), + script_sig_head: head, + coinbase_template: template, + coinbase_prefix, + coinbase_suffix, + merkle_branch, + extra_outputs, + candidate, + }) + } + + /// Job identity. + #[must_use] + pub const fn id(&self) -> JobId { + self.id + } + + /// The immutable candidate this job projects. + #[must_use] + pub fn candidate(&self) -> &Arc { + &self.candidate + } + + /// Candidate block height. + #[must_use] + pub fn height(&self) -> u32 { + self.candidate.height + } + + /// Parent block hash (internal byte order). + #[must_use] + pub fn previous_block_hash(&self) -> BlockHash { + BlockHash::from(self.candidate.previous_block_hash) + } + + /// Header version as the unsigned wire value. + #[must_use] + pub const fn version(&self) -> u32 { + self.version + } + + /// Network compact target bits. + #[must_use] + pub fn bits(&self) -> CompactTarget { + self.candidate.bits + } + + /// Header time the job was issued with. + #[must_use] + pub const fn ntime(&self) -> u32 { + self.ntime + } + + /// Smallest legal `ntime` (`Candidate::min_time`). + #[must_use] + pub fn min_ntime(&self) -> u32 { + self.candidate.min_time + } + + /// Largest `ntime` the job accepts. + #[must_use] + pub const fn max_ntime(&self) -> u32 { + self.max_ntime + } + + /// Version bits a miner may roll. + #[must_use] + pub const fn version_mask(&self) -> u32 { + self.version_mask + } + + /// Extranonce split of this job. + #[must_use] + pub const fn extranonce_layout(&self) -> ExtranonceLayout { + self.extranonce + } + + /// Coinbase txid serialization up to the extranonce (SV1 `coinb1`). + #[must_use] + pub fn coinbase_prefix(&self) -> &[u8] { + &self.coinbase_prefix + } + + /// Coinbase txid serialization after the extranonce (SV1 `coinb2`). + #[must_use] + pub fn coinbase_suffix(&self) -> &[u8] { + &self.coinbase_suffix + } + + /// Merkle branch of the coinbase path, internal byte order, leaf level first. + #[must_use] + pub fn merkle_branch(&self) -> &[Hash256] { + &self.merkle_branch + } + + /// Total coinbase output value (subsidy plus fees). + #[must_use] + pub fn coinbase_value(&self) -> u64 { + self.candidate.coinbase_value + } + + /// Pool outputs this job inserted into the coinbase. + #[must_use] + pub fn extra_outputs(&self) -> &[TxOut] { + &self.extra_outputs + } + + /// Maps SV1 `version_bits` onto the full header version (BIP310): rolled + /// bits must lie inside the job mask. + pub fn version_from_rolled_bits(&self, rolled: u32) -> Result { + let version = (self.version & !self.version_mask) | rolled; + if rolled & !self.version_mask != 0 { + return Err(ShareError::VersionOutsideMask { + version, + job_version: self.version, + mask: self.version_mask, + }); + } + Ok(version) + } + + /// Txid of the coinbase carrying `extranonce`. + pub fn coinbase_txid(&self, extranonce: Extranonce<'_>) -> Result { + let mut scratch = Vec::new(); + self.coinbase_hash_with(extranonce, &mut scratch) + .map(Txid::from) + } + + /// Header merkle root for the coinbase carrying `extranonce`. + pub fn merkle_root(&self, extranonce: Extranonce<'_>) -> Result { + let mut scratch = Vec::new(); + self.merkle_root_with(extranonce, &mut scratch) + } + + /// The header a miner hashes for these fields. No window checks: this is a + /// projection, [`JobBook::validate`] is the predicate. + pub fn header( + &self, + extranonce: Extranonce<'_>, + ntime: u32, + version: u32, + nonce: u32, + ) -> Result { + let root = self.merkle_root(extranonce)?; + Ok(self.header_from(root, ntime, version, nonce)) + } + + fn header_from(&self, merkle_root: Hash256, ntime: u32, version: u32, nonce: u32) -> Header { + Header { + version: i32::from_le_bytes(version.to_le_bytes()), + prev_blockhash: BlockHash::from(self.candidate.previous_block_hash), + merkle_root, + time: ntime, + bits: self.candidate.bits, + nonce, + } + } + + fn check_extranonce(&self, extranonce: Extranonce<'_>) -> Result<(), ShareError> { + if extranonce.session.len() != self.extranonce.session_len() { + return Err(ShareError::SessionPrefixLength { + expected: self.extranonce.session_len(), + actual: extranonce.session.len(), + }); + } + if extranonce.rolled.len() != self.extranonce.rolled_len() { + return Err(ShareError::ExtranonceLength { + expected: self.extranonce.rolled_len(), + actual: extranonce.rolled.len(), + }); + } + Ok(()) + } + + fn coinbase_hash_with( + &self, + extranonce: Extranonce<'_>, + scratch: &mut Vec, + ) -> Result { + self.check_extranonce(extranonce)?; + scratch.clear(); + scratch.extend_from_slice(&self.coinbase_prefix); + scratch.extend_from_slice(extranonce.session); + scratch.extend_from_slice(extranonce.rolled); + scratch.extend_from_slice(&self.coinbase_suffix); + Ok(double_sha256(scratch.as_slice())) + } + + fn merkle_root_with( + &self, + extranonce: Extranonce<'_>, + scratch: &mut Vec, + ) -> Result { + let mut node = self.coinbase_hash_with(extranonce, scratch)?; + let mut pair = [0_u8; 64]; + for sibling in &self.merkle_branch { + pair[..32].copy_from_slice(node.as_byte_array()); + pair[32..].copy_from_slice(sibling.as_byte_array()); + node = double_sha256(&pair); + } + Ok(node) + } + + /// Builds the complete block for a share that met the network target. + fn found_block(&self, share: AcceptedShare, extranonce: Extranonce<'_>) -> FoundBlock { + let mut coinbase = self.coinbase_template.clone(); + let mut script = + Vec::with_capacity(self.script_sig_head.len() + self.extranonce.total_len()); + script.extend_from_slice(&self.script_sig_head); + script.extend_from_slice(extranonce.session); + script.extend_from_slice(extranonce.rolled); + if let Some(input) = coinbase.inputs.first_mut() { + input.script_sig = Script::from_bytes(script); + } + let coinbase_txid = coinbase.txid(); + let mut txs = Vec::with_capacity(self.candidate.transactions.len() + 1); + txs.push(coinbase); + txs.extend( + self.candidate + .transactions + .iter() + .map(|entry| Tx::clone(&entry.tx)), + ); + let block = Block { + header: share.header, + txs, + }; + let raw = consensus_bytes(&block); + FoundBlock { + share, + block, + raw, + coinbase_txid, + coinbase_value: self.candidate.coinbase_value, + } + } +} + +/// Merkle branch of the coinbase path: the sibling hash at every tree level, +/// leaf level first. `txids` are the non-coinbase transactions in block order. +fn coinbase_merkle_branch(txids: &[Txid]) -> Vec { + let mut rest: Vec = txids.iter().map(|txid| Hash256::from(*txid)).collect(); + let mut branch = Vec::new(); + let mut pair = [0_u8; 64]; + while let Some(&sibling) = rest.first() { + branch.push(sibling); + let mut tail: Vec = rest[1..].to_vec(); + if tail.len() % 2 == 1 + && let Some(&last) = tail.last() + { + tail.push(last); + } + rest = tail + .as_chunks::<2>() + .0 + .iter() + .map(|nodes| { + pair[..32].copy_from_slice(nodes[0].as_byte_array()); + pair[32..].copy_from_slice(nodes[1].as_byte_array()); + double_sha256(&pair) + }) + .collect(); + } + branch +} + +/// Bounds and rules shared by every job and session of one [`JobBook`]. +#[derive(Clone, Debug)] +pub struct JobBookConfig { + /// Coinbase growth the pool declares to the template owner. Pass the same + /// value to [`fetch_template`]. + pub budget: CoinbaseBudget, + /// Extranonce split. + pub extranonce: ExtranonceLayout, + /// Static pool bytes placed after the node's height prefix, before the extranonce. + pub coinbase_tag: Vec, + /// Version bits miners may roll; a subset of [`BIP320_VERSION_MASK`], zero for none. + pub version_mask: u32, + /// Seconds past the job time a share's `ntime` may roll; at most [`MAX_NTIME_FORWARD_SECS`]. + pub ntime_forward_secs: u32, + /// Jobs retained, active or stale. + pub max_jobs: usize, + /// Sessions open at once. + pub max_sessions: usize, + /// Accepted shares remembered per job for duplicate detection. + pub max_shares_per_job: usize, +} + +impl JobBookConfig { + fn validate(&self) -> Result<(), JobError> { + if self.max_jobs == 0 { + return Err(JobError::InvalidConfig("max_jobs must be at least 1")); + } + if self.max_sessions == 0 { + return Err(JobError::InvalidConfig("max_sessions must be at least 1")); + } + if self.max_shares_per_job == 0 { + return Err(JobError::InvalidConfig( + "max_shares_per_job must be at least 1", + )); + } + let capacity = self.extranonce.session_capacity(); + if u64::try_from(self.max_sessions).unwrap_or(u64::MAX) > capacity { + return Err(JobError::InvalidConfig( + "max_sessions exceeds the prefixes session_len can name", + )); + } + if self.version_mask & !BIP320_VERSION_MASK != 0 { + return Err(JobError::InvalidConfig( + "version_mask must be a subset of the BIP320 mask", + )); + } + if self.ntime_forward_secs > MAX_NTIME_FORWARD_SECS { + return Err(JobError::InvalidConfig( + "ntime_forward_secs exceeds the consensus future-time limit", + )); + } + let needed = self + .coinbase_tag + .len() + .saturating_add(self.extranonce.total_len()); + if needed.saturating_add(MIN_COINBASE_SCRIPT_SIG_LEN) > MAX_COINBASE_SCRIPT_SIG_LEN { + return Err(JobError::InvalidConfig( + "coinbase tag and extranonce cannot fit under the scriptSig limit", + )); + } + Ok(()) + } +} + +/// Outcome of [`JobBook::install`]. +#[derive(Clone, Debug)] +pub struct Installed { + /// The job to announce (or the unchanged newest job). + pub job: Arc, + /// A new job was created. False: same template, time, and outputs as the + /// newest active job; nothing needs sending. + pub fresh: bool, + /// Miners must drop all earlier work (SV1 `clean_jobs`, SV2 `SetNewPrevHash`). + pub clean_jobs: bool, + /// A job was already retained and its parent differs from the new job's. + /// A fact about job parents only: it says nothing about whether the chain + /// extended or reorganized. Chain reorganization is a chain observation. + pub parent_changed: bool, + /// Jobs that were active and are stale after this call. + pub invalidated: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum JobState { + Active, + Stale(StaleReason), +} + +#[derive(Debug)] +struct JobEntry { + job: Arc, + state: JobState, + hashes: Vec, +} + +/// The job and share state of one pool endpoint. +/// +/// Single owner, `&mut` methods: the adapter serializes access. Memory is +/// bounded by `max_jobs`, `max_sessions`, and `max_shares_per_job`. +#[derive(Debug)] +pub struct JobBook { + config: JobBookConfig, + sessions: SessionTable, + jobs: VecDeque, + next_job: u32, + last_issued: u32, + seen: HashMap, + scratch: Vec, + last_installed: Option, +} + +impl JobBook { + /// Builds an empty book; rejects an unusable configuration. + pub fn new(config: JobBookConfig) -> Result { + config.validate()?; + let sessions = SessionTable::new(config.extranonce, config.max_sessions); + Ok(Self { + config, + sessions, + jobs: VecDeque::new(), + next_job: 1, + last_issued: 0, + seen: HashMap::new(), + scratch: Vec::new(), + last_installed: None, + }) + } + + /// The configuration this book was built with. + #[must_use] + pub fn config(&self) -> &JobBookConfig { + &self.config + } + + /// Opens a session with a unique extranonce prefix. + pub fn open_session(&mut self) -> Result { + self.sessions.open() + } + + /// Closes a session and frees its prefix for reuse (oldest-freed first). + pub fn close_session(&mut self, id: SessionId) -> bool { + self.sessions.close(id) + } + + /// Looks up an open session. + #[must_use] + pub fn session(&self, id: SessionId) -> Option<&Session> { + self.sessions.get(id) + } + + /// Open sessions. + #[must_use] + pub fn session_count(&self) -> usize { + self.sessions.live.len() + } + + /// The newest job, if it is still active. + #[must_use] + pub fn current(&self) -> Option<&Arc> { + self.jobs + .back() + .filter(|entry| entry.state == JobState::Active) + .map(|entry| &entry.job) + } + /// The latest installation metadata, if an active job exists. + #[must_use] + pub fn current_installed(&self) -> Option<&Installed> { + self.last_installed.as_ref() + } + + /// A retained job, active or stale. + #[must_use] + pub fn job(&self, id: JobId) -> Option<&Arc> { + self.locate(id) + .ok() + .and_then(|index| self.jobs.get(index)) + .map(|entry| &entry.job) + } + + /// Marks every active job stale ([`StaleReason::Invalidated`]) and returns them. + pub fn invalidate_active(&mut self) -> Vec { + let mut invalidated = Vec::new(); + for entry in &mut self.jobs { + if entry.state == JobState::Active { + entry.state = JobState::Stale(StaleReason::Invalidated); + invalidated.push(entry.job.id); + } + } + self.last_installed = None; + invalidated + } + + /// Projects `candidate` onto a new job, or returns the unchanged newest job. + /// + /// `extra_outputs` are pool coinbase outputs; see the crate interface note + /// for their placement, budget, and value rules. + pub fn install( + &mut self, + candidate: Arc, + extra_outputs: Vec, + ) -> Result { + if let Some(newest) = self.jobs.back() + && newest.state == JobState::Active + && newest.job.candidate.template_id == candidate.template_id + && newest.job.candidate.current_time == candidate.current_time + && newest.job.extra_outputs == extra_outputs + { + let installed = Installed { + job: Arc::clone(&newest.job), + fresh: false, + clean_jobs: false, + parent_changed: false, + invalidated: Vec::new(), + }; + self.last_installed = Some(installed.clone()); + return Ok(installed); + } + + let raw_id = self.next_job; + let following = raw_id.checked_add(1).ok_or(JobError::JobIdExhausted)?; + let job = Arc::new(Job::project( + JobId(raw_id), + candidate, + &self.config, + extra_outputs, + )?); + + let newest = self + .jobs + .back() + .map(|entry| (entry.state, entry.job.candidate.previous_block_hash)); + let same_parent_active = matches!( + newest, + Some((JobState::Active, parent)) if parent == job.candidate.previous_block_hash + ); + let clean_jobs = !same_parent_active; + let parent_changed = matches!( + newest, + Some((_, parent)) if parent != job.candidate.previous_block_hash + ); + let mut invalidated = Vec::new(); + if clean_jobs { + for entry in &mut self.jobs { + if entry.state == JobState::Active { + entry.state = JobState::Stale(StaleReason::PreviousBlockChanged); + invalidated.push(entry.job.id); + } + } + } + + self.jobs.push_back(JobEntry { + job: Arc::clone(&job), + state: JobState::Active, + hashes: Vec::new(), + }); + self.next_job = following; + self.last_issued = raw_id; + while self.jobs.len() > self.config.max_jobs { + if let Some(evicted) = self.jobs.pop_front() { + for hash in &evicted.hashes { + self.seen.remove(hash); + } + } + } + + let installed = Installed { + job, + fresh: true, + clean_jobs, + parent_changed, + invalidated, + }; + self.last_installed = Some(installed.clone()); + Ok(installed) + } + + fn locate(&self, id: JobId) -> Result { + if id.0 == 0 || id.0 > self.last_issued { + return Err(ShareError::UnknownJob(id)); + } + let front = self.jobs.front().map_or_else( + || self.last_issued.saturating_add(1), + |entry| entry.job.id.0, + ); + if id.0 < front { + return Err(ShareError::Stale { + job: id, + reason: StaleReason::Retired, + }); + } + usize::try_from(id.0 - front).map_err(|_| ShareError::UnknownJob(id)) + } + + /// The single share predicate. + /// + /// Checks, in order: session, job existence, job staleness, extranonce + /// lengths, version mask, `ntime` window, duplicate, network target, share + /// target, per-job capacity. A header that meets the network target is a + /// block candidate even when it misses `target`, and is never refused for + /// capacity. Only accepted shares are remembered, so rejected input + /// cannot consume duplicate-set capacity. + pub fn validate( + &mut self, + share: &ShareSubmission<'_>, + target: &ShareTarget, + ) -> Result { + let session = self + .sessions + .get(share.session) + .ok_or(ShareError::UnknownSession(share.session))?; + let index = self.locate(share.job)?; + let entry = self + .jobs + .get(index) + .ok_or(ShareError::UnknownJob(share.job))?; + if let JobState::Stale(reason) = entry.state { + return Err(ShareError::Stale { + job: share.job, + reason, + }); + } + let job = &entry.job; + let extranonce = Extranonce { + session: session.extranonce_prefix(), + rolled: share.rolled_extranonce, + }; + job.check_extranonce(extranonce)?; + if (share.version ^ job.version) & !job.version_mask != 0 { + return Err(ShareError::VersionOutsideMask { + version: share.version, + job_version: job.version, + mask: job.version_mask, + }); + } + let min_ntime = job.min_ntime(); + if share.ntime < min_ntime { + return Err(ShareError::TimeTooEarly { + ntime: share.ntime, + min_ntime, + }); + } + if share.ntime > job.max_ntime { + return Err(ShareError::TimeTooLate { + ntime: share.ntime, + max_ntime: job.max_ntime, + }); + } + + let root = job.merkle_root_with(extranonce, &mut self.scratch)?; + let header = job.header_from(root, share.ntime, share.version, share.nonce); + let hash = header.compute_hash(); + let raw_hash = Hash256::from(hash); + if let Some(&first_job) = self.seen.get(&raw_hash) { + return Err(ShareError::Duplicate { hash, first_job }); + } + let is_block = compact_is_met_by(job.candidate.bits, raw_hash); + if !is_block && !target.meets(raw_hash) { + return Err(ShareError::BelowTarget { hash }); + } + let remembered = entry.hashes.len(); + let room = remembered < self.config.max_shares_per_job; + if !is_block && !room { + return Err(ShareError::JobShareCapacity { + job: share.job, + capacity: self.config.max_shares_per_job, + }); + } + + let mut full_extranonce = + Vec::with_capacity(extranonce.session.len() + extranonce.rolled.len()); + full_extranonce.extend_from_slice(extranonce.session); + full_extranonce.extend_from_slice(extranonce.rolled); + let accepted = AcceptedShare { + session: share.session, + job: share.job, + hash, + header, + height: job.candidate.height, + previous_block_hash: BlockHash::from(job.candidate.previous_block_hash), + extranonce: full_extranonce, + target: *target, + work: target.work(), + class: if is_block { + ShareClass::Block + } else { + ShareClass::Share + }, + }; + let outcome = if is_block { + ShareOutcome::Block(Box::new(job.found_block(accepted, extranonce))) + } else { + ShareOutcome::Share(Box::new(accepted)) + }; + + if room { + self.seen.insert(raw_hash, share.job); + if let Some(entry) = self.jobs.get_mut(index) { + entry.hashes.push(raw_hash); + } + } + Ok(outcome) + } +} + +/// Requests a template that leaves `budget` of coinbase room, through the +/// node's one authoritative template path. +/// +/// `long_poll_id` is the previous candidate's `template_id.as_str()`: the call +/// then waits for a newer generation. The returned template's `candidate` +/// feeds [`JobBook::install`]; `submit_old == Some(false)` means the previous +/// work's parent is no longer the applied tip. +pub fn fetch_template( + control: &dyn MiningControl, + budget: CoinbaseBudget, + long_poll_id: Option<&str>, +) -> Result { + let request = BlockTemplateRequest { + mode: BlockTemplateMode::Template, + capabilities: Vec::new(), + rules: Vec::new(), + long_poll_id: long_poll_id.map(CompactString::from), + coinbase_budget: Some(budget), + }; + match control.get_block_template(request)? { + BlockTemplateResult::Template(template) => Ok(template), + BlockTemplateResult::Proposal(_) => Err(MiningControlError::Failed(CompactString::from( + "template request was answered with a proposal result", + ))), + } +} diff --git a/crates/stratum/src/lib.rs b/crates/stratum/src/lib.rs new file mode 100644 index 0000000000..4d38249eea --- /dev/null +++ b/crates/stratum/src/lib.rs @@ -0,0 +1,34 @@ +//! Native Stratum pool protocol and the shared job/share projection owner. +//! +//! [`JobBook`] is the single owner of projected jobs, extranonce allocation, +//! share validation, stale/duplicate state, and block assembly. The SV1 +//! adapter is a sibling module over that owner; it does not hash headers, +//! build coinbases, or maintain a second share predicate. +#![forbid(unsafe_code)] + +mod job; +mod sv1; + +pub use job::{ + AcceptedShare, BIP320_VERSION_MASK, BlockReceipt, BlockStatus, Extranonce, ExtranonceLayout, + FoundBlock, Installed, Job, JobBook, JobBookConfig, JobError, JobId, + MAX_COINBASE_SCRIPT_SIG_LEN, MAX_NTIME_FORWARD_SECS, Session, SessionId, ShareClass, + ShareError, ShareOutcome, ShareSubmission, ShareTarget, ShareWork, StaleReason, fetch_template, +}; +pub use sv1::errors; +pub use sv1::{ + AccountResolver, AuthDenied, Authorize, ClientRequest, Configure, DecodeFailure, Difficulty, + DifficultyError, DifficultyPolicy, FrameError, Incoming, InvalidAccount, LedgerUnavailable, + LineBuffer, MAX_CONFIGURE_EXTENSIONS, MAX_DIFFICULTY_TEXT_LEN, MAX_EXTENSION_NAME_LEN, + MAX_EXTRANONCE2_BYTES, MAX_ID_LEN, MAX_JOB_ID_LEN, MAX_JSON_DEPTH, MAX_LOGIN_LEN, + MAX_METHOD_LEN, MAX_PARAMS, MAX_PASSWORD_LEN, MAX_USER_AGENT_LEN, Password, RecordOutcome, + Request, RequestId, ShareLedger, StaticAccountResolver, Submit, Subscribe, WireError, + WorkerIdentity, decode_hex, decode_line, encode_error, encode_hex, encode_notify, + encode_reject, encode_result, encode_set_difficulty, json_string, parse_u32_hex, + parse_wire_job_id, prevhash_from_wire, prevhash_to_wire, push_hex, swap_words, wire_job_id, +}; +pub use sv1::{ + BlockOutcome, ConfigError, ConnectionLimiter, ConnectionPermit, Observer, PoolCore, + RefreshError, Refreshed, RefusalReason, Server, ServerConfig, ServerDeps, ServerEvent, + SessionEnd, ShutdownReport, +}; diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs new file mode 100644 index 0000000000..0f2dc19423 --- /dev/null +++ b/crates/stratum/src/sv1.rs @@ -0,0 +1,1199 @@ +//! Native Stratum V1 pool adapter. +//! +//! Two layers: +//! +//! * this module: the SV1 wire. Bounded line framing ([`LineBuffer`]), JSON-RPC +//! decode ([`decode_line`], [`ClientRequest`]), exact response and +//! notification encoders, the prevhash word swap, BIP310 parameter parsing, +//! decimal difficulty ([`Difficulty`]), explicit account resolution +//! ([`AccountResolver`]) and the accounting boundary ([`ShareLedger`]); +//! * [`session`]: the per-connection state machine, the shared [`PoolCore`] +//! over [`crate::JobBook`], and the bounded TCP [`Server`]. +//! +//! Division of ownership. The parent crate owns job projection, the coinbase +//! split, extranonce allocation, the share predicate, stale and duplicate state, +//! and block assembly. This adapter never hashes a header, never builds a +//! coinbase, never compares a hash with a target. It owns what only SV1 has: +//! framing, dialect, handshake order, BIP310 negotiation, per-session +//! difficulty policy, deadlines, and connection bounds. It holds no keys, no +//! wallet, no signer, and no money: payouts are the ledger's business. +//! +//! JSON goes through `sonic-rs`. Untrusted frames are bounded three ways before +//! any value exists: line length ([`LineBuffer`]), nesting depth +//! ([`MAX_JSON_DEPTH`]), and parameter count ([`MAX_PARAMS`]). +#![forbid(unsafe_code)] + +mod session; + +use std::collections::HashMap; +use std::fmt; + +use crate::{AcceptedShare, BlockReceipt, FoundBlock, Job, JobError, JobId, ShareTarget}; +use bitcoin_rs_mining::MiningControlError; +use bitcoin_rs_primitives::Hash256; +use sonic_rs::{Array, JsonContainerTrait, JsonValueTrait, Value}; + +pub use session::{ + BlockOutcome, ConfigError, ConnectionLimiter, ConnectionPermit, Observer, PoolCore, + RefreshError, Refreshed, RefusalReason, Server, ServerConfig, ServerDeps, ServerEvent, + SessionEnd, ShutdownReport, +}; + +/// Longest accepted JSON-RPC request id string. +pub const MAX_ID_LEN: usize = 128; +/// Longest accepted method name. +pub const MAX_METHOD_LEN: usize = 64; +/// Most parameters any SV1 request carries (`mining.submit` has six). +pub const MAX_PARAMS: usize = 8; +/// Longest accepted worker login. +pub const MAX_LOGIN_LEN: usize = 128; +/// Longest accepted worker password. +pub const MAX_PASSWORD_LEN: usize = 128; +/// Longest user agent kept from `mining.subscribe`; longer ones are truncated. +pub const MAX_USER_AGENT_LEN: usize = 128; +/// Longest `job_id` string a `mining.submit` may carry. +pub const MAX_JOB_ID_LEN: usize = 64; +/// Most `extranonce2` bytes a `mining.submit` may carry. +pub const MAX_EXTRANONCE2_BYTES: usize = 32; +/// Most extensions one `mining.configure` may name. +pub const MAX_CONFIGURE_EXTENSIONS: usize = 16; +/// Longest extension name in `mining.configure`. +pub const MAX_EXTENSION_NAME_LEN: usize = 64; +/// Deepest array/object nesting a frame may have. SV1 needs 3. +pub const MAX_JSON_DEPTH: usize = 8; +/// Most characters a difficulty may be written with. +pub const MAX_DIFFICULTY_TEXT_LEN: usize = 20; + +// --------------------------------------------------------------------------- +// Hex and byte order +// --------------------------------------------------------------------------- + +const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; + +fn nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Appends the lowercase hex of `bytes` to `out`. +pub fn push_hex(out: &mut String, bytes: &[u8]) { + out.reserve(bytes.len() * 2); + for &byte in bytes { + out.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); + out.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); + } +} + +/// Lowercase hex of `bytes`. +#[must_use] +pub fn encode_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + push_hex(&mut out, bytes); + out +} + +/// Decodes an even-length hex string of either case; `None` on any other byte. +#[must_use] +pub fn decode_hex(text: &str) -> Option> { + let (pairs, odd_tail) = text.as_bytes().as_chunks::<2>(); + if !odd_tail.is_empty() { + return None; + } + pairs + .iter() + .map(|&[high, low]| Some((nibble(high)? << 4) | nibble(low)?)) + .collect() +} + +/// Parses `min_digits..=8` hex digits as one `u32`. No sign, no prefix. +#[must_use] +pub fn parse_u32_hex(text: &str, min_digits: usize) -> Option { + if text.len() < min_digits.max(1) || text.len() > 8 { + return None; + } + text.bytes().try_fold(0_u32, |value, byte| { + Some((value << 4) | u32::from(nibble(byte)?)) + }) +} + +/// Reverses the four bytes of each 32-bit word of a 32-byte hash. The swap is +/// its own inverse. +#[must_use] +pub fn swap_words(hash: &[u8; 32]) -> [u8; 32] { + let mut out = *hash; + for word in out.as_chunks_mut::<4>().0 { + word.reverse(); + } + out +} + +/// SV1 `prevhash` text for a parent hash in internal (header) byte order. +#[must_use] +pub fn prevhash_to_wire(internal: &[u8; 32]) -> String { + encode_hex(&swap_words(internal)) +} + +/// Parent hash in internal byte order from SV1 `prevhash` text. +#[must_use] +pub fn prevhash_from_wire(text: &str) -> Option<[u8; 32]> { + let bytes: [u8; 32] = decode_hex(text)?.try_into().ok()?; + Some(swap_words(&bytes)) +} + +/// SV1 `job_id` text of a native job id: eight lowercase hex digits. +#[must_use] +pub fn wire_job_id(id: JobId) -> String { + format!("{:08x}", id.get()) +} + +/// Native job id of SV1 `job_id` text; `None` unless it is exactly the text +/// [`wire_job_id`] writes (so no job this adapter did not issue can parse). +#[must_use] +pub fn parse_wire_job_id(text: &str) -> Option { + if text.len() != 8 + || !text + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return None; + } + parse_u32_hex(text, 8).map(JobId::new) +} + +// --------------------------------------------------------------------------- +// Wire errors +// --------------------------------------------------------------------------- + +/// One SV1 error triple `[code, message, null]`. Messages are fixed text: +/// nothing internal reaches a client. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WireError { + /// Numeric code. + pub code: i32, + /// Fixed message. + pub message: &'static str, +} + +impl WireError { + /// Builds an error. + #[must_use] + pub const fn new(code: i32, message: &'static str) -> Self { + Self { code, message } + } + + /// The `[code, "message", null]` array. + #[must_use] + pub fn to_json(&self) -> String { + format!("[{},{},null]", self.code, json_string(self.message)) + } +} + +impl fmt::Display for WireError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.code, self.message) + } +} + +/// The SV1 error vocabulary this adapter speaks. +pub mod errors { + use super::WireError; + + /// Slush draft 20: other or unknown. + pub const OTHER: i32 = 20; + /// Slush draft 21: job not found (stale). + pub const JOB_NOT_FOUND: i32 = 21; + /// Slush draft 22: duplicate share. + pub const DUPLICATE_SHARE: i32 = 22; + /// Slush draft 23: low difficulty share. + pub const LOW_DIFFICULTY: i32 = 23; + /// Slush draft 24: unauthorized worker. + pub const UNAUTHORIZED_WORKER: i32 = 24; + /// Slush draft 25: not subscribed. + pub const NOT_SUBSCRIBED: i32 = 25; + + /// Frame is not JSON. + pub const PARSE_ERROR: WireError = WireError::new(-32700, "Parse error"); + /// Frame is JSON but not a request. + pub const INVALID_REQUEST: WireError = WireError::new(-32600, "Invalid request"); + /// Unknown method. + pub const METHOD_NOT_FOUND: WireError = WireError::new(-32601, "Method not found"); + /// Parameters of the wrong shape. + pub const INVALID_PARAMS: WireError = WireError::new(-32602, "Invalid params"); + /// `extranonce2` is not hex of a bounded length. + pub const INVALID_EXTRANONCE2: WireError = WireError::new(-32602, "Invalid extranonce2"); + /// `extranonce2` length differs from the one announced at subscribe. + pub const INVALID_EXTRANONCE2_LENGTH: WireError = + WireError::new(-32602, "Invalid extranonce2 length"); + /// `ntime` is not eight hex digits. + pub const INVALID_NTIME_FORMAT: WireError = WireError::new(-32602, "Invalid ntime"); + /// `nonce` is not eight hex digits. + pub const INVALID_NONCE_FORMAT: WireError = WireError::new(-32602, "Invalid nonce"); + /// `version_bits` is not eight hex digits. + pub const INVALID_VERSION_BITS_FORMAT: WireError = + WireError::new(-32602, "Invalid version bits"); + /// `version_bits` sent without a negotiated version-rolling mask. + pub const UNEXPECTED_VERSION_BITS: WireError = + WireError::new(-32602, "Unexpected version bits"); + /// `version-rolling.mask` is not hex. + pub const INVALID_VERSION_MASK: WireError = + WireError::new(-32602, "Invalid version-rolling.mask"); + /// Difficulty is not a plain positive decimal. + pub const INVALID_DIFFICULTY: WireError = WireError::new(-32602, "Invalid difficulty"); + + /// Submit before `mining.subscribe`. + pub const NOT_SUBSCRIBED_ERROR: WireError = WireError::new(NOT_SUBSCRIBED, "Not subscribed"); + /// Submit or authorize outcome for an unknown or denied worker. + pub const UNAUTHORIZED_WORKER_ERROR: WireError = + WireError::new(UNAUTHORIZED_WORKER, "Unauthorized worker"); + /// `mining.subscribe` twice. + pub const ALREADY_SUBSCRIBED: WireError = WireError::new(OTHER, "Already subscribed"); + /// `mining.configure` after work was sent. + pub const CONFIGURE_TOO_LATE: WireError = WireError::new(OTHER, "Configure after work started"); + /// More workers than one session may authorize. + pub const TOO_MANY_WORKERS: WireError = WireError::new(OTHER, "Too many workers"); + /// The pool has no free session slot. + pub const POOL_AT_CAPACITY: WireError = WireError::new(OTHER, "Pool at capacity"); + /// Extranonce rotation is not offered. + pub const EXTRANONCE_SUBSCRIPTION_UNSUPPORTED: WireError = + WireError::new(OTHER, "Extranonce subscription not supported"); + /// Difficulty suggestion outside the pool's range. + pub const DIFFICULTY_OUT_OF_RANGE: WireError = WireError::new(OTHER, "Difficulty out of range"); + /// `version_bits` outside the negotiated mask. + pub const VERSION_BITS_OUTSIDE_MASK: WireError = + WireError::new(OTHER, "Version bits outside mask"); + /// `ntime` outside the job window. + pub const INVALID_NTIME: WireError = WireError::new(OTHER, "Invalid ntime"); + /// The job holds its maximum number of shares. + pub const JOB_SHARE_CAPACITY: WireError = WireError::new(OTHER, "Job share capacity reached"); + /// The ledger refused the accepted share. + pub const ACCOUNTING_UNAVAILABLE: WireError = + WireError::new(OTHER, "Share accounting unavailable"); + /// Share verdict that cannot occur on an open session. + pub const INTERNAL: WireError = WireError::new(OTHER, "Internal error"); + /// Job id never issued or already retired. + pub const JOB_NOT_FOUND_ERROR: WireError = WireError::new(JOB_NOT_FOUND, "Job not found"); + /// Job superseded. + pub const STALE_SHARE: WireError = WireError::new(JOB_NOT_FOUND, "Stale share"); + /// Already accepted. + pub const DUPLICATE_SHARE_ERROR: WireError = WireError::new(DUPLICATE_SHARE, "Duplicate share"); + /// Hash misses the session target. + pub const LOW_DIFFICULTY_SHARE: WireError = + WireError::new(LOW_DIFFICULTY, "Low difficulty share"); +} + +// --------------------------------------------------------------------------- +// Bounded line framing +// --------------------------------------------------------------------------- + +/// Framing failure; the connection must be closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameError { + /// A line (or an unterminated tail) exceeds the limit. + LineTooLong, +} + +/// Extra bytes one [`LineBuffer::push`] may add beyond the line limit. +const PUSH_SLACK: usize = 64 * 1024; + +/// Newline-delimited frame splitter with a hard memory bound. +/// +/// Memory never exceeds `max_line + PUSH_SLACK` bytes plus the caller's push +/// size: an unterminated tail longer than `max_line` is an error, so a client +/// that streams without a `\n` cannot grow the buffer. +#[derive(Debug)] +pub struct LineBuffer { + buf: Vec, + start: usize, + scanned: usize, + max_line: usize, + poisoned: bool, +} + +impl LineBuffer { + /// A buffer that rejects lines longer than `max_line` bytes (terminator + /// excluded). + #[must_use] + pub fn new(max_line: usize) -> Self { + Self { + buf: Vec::new(), + start: 0, + scanned: 0, + max_line, + poisoned: false, + } + } + + /// Appends received bytes. Drain [`Self::next_frame`] to `None` between + /// pushes. + pub fn push(&mut self, data: &[u8]) -> Result<(), FrameError> { + if self.poisoned { + return Err(FrameError::LineTooLong); + } + if self.pending_len().saturating_add(data.len()) > self.max_line.saturating_add(PUSH_SLACK) + { + self.poisoned = true; + return Err(FrameError::LineTooLong); + } + self.buf.extend_from_slice(data); + Ok(()) + } + + /// Bytes received and not yet returned as frames. + #[must_use] + pub fn pending_len(&self) -> usize { + self.buf.len() - self.start + } + + /// Whether an unterminated tail is buffered. + #[must_use] + pub fn has_partial(&self) -> bool { + self.pending_len() > 0 + } + + /// The next non-empty line without its `\n` or `\r\n`; `None` when only a + /// partial line remains. Empty lines are skipped. + pub fn next_frame(&mut self) -> Result>, FrameError> { + if self.poisoned { + return Err(FrameError::LineTooLong); + } + loop { + let unread = self.buf.len() - self.start; + let found = self.buf[self.start + self.scanned..] + .iter() + .position(|&byte| byte == b'\n'); + let Some(offset) = found else { + // Allow up to max_line + 1 bytes in unread in case a trailing \r is waiting for \n + if unread > self.max_line + 1 { + self.poisoned = true; + return Err(FrameError::LineTooLong); + } + self.scanned = unread; + self.compact(); + return Ok(None); + }; + let end = self.scanned + offset; + let mut line = &self.buf[self.start..self.start + end]; + if let Some((&b'\r', head)) = line.split_last() { + line = head; + } + if line.len() > self.max_line { + self.poisoned = true; + return Err(FrameError::LineTooLong); + } + let frame = line.to_vec(); + self.start += end + 1; + self.scanned = 0; + if frame.is_empty() { + continue; + } + self.compact(); + return Ok(Some(frame)); + } + } + + fn compact(&mut self) { + if self.start == self.buf.len() { + self.buf.clear(); + self.start = 0; + } else if self.start >= 4096 && self.start * 2 >= self.buf.len() { + self.buf.drain(..self.start); + self.start = 0; + } + } +} + +// --------------------------------------------------------------------------- +// JSON-RPC decode +// --------------------------------------------------------------------------- + +/// JSON text of a string value. +#[must_use] +pub fn json_string(text: &str) -> String { + sonic_rs::to_string(text).unwrap_or_else(|_| String::from("\"\"")) +} + +/// A request id, kept as the exact JSON text to echo. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestId(String); + +impl RequestId { + /// The JSON `null` id. + #[must_use] + pub fn null() -> Self { + Self(String::from("null")) + } + + /// JSON text of the id. + #[must_use] + pub fn as_json(&self) -> &str { + &self.0 + } +} + +fn request_id(value: Option<&Value>) -> Option { + let Some(value) = value else { + return Some(RequestId::null()); + }; + if value.is_null() { + return Some(RequestId::null()); + } + if let Some(text) = value.as_str() { + return (text.len() <= MAX_ID_LEN).then(|| RequestId(json_string(text))); + } + if let Some(number) = value.as_u64() { + return Some(RequestId(number.to_string())); + } + value.as_i64().map(|number| RequestId(number.to_string())) +} + +/// One decoded client request. Parameters stay inside the parsed document; +/// [`Request::params`] borrows them. +pub struct Request { + /// Id to echo in the response. + pub id: RequestId, + /// Method name, at most [`MAX_METHOD_LEN`] bytes. + pub method: String, + document: Value, +} + +impl Request { + /// Positional parameters, at most [`MAX_PARAMS`]. + #[must_use] + pub fn params(&self) -> &[Value] { + self.document + .as_object() + .and_then(|object| object.get(&"params")) + .and_then(|params| params.as_array()) + .map_or(&[][..], Array::as_slice) + } +} + +/// Deliberately omits parameters: they can carry a password. +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Request") + .field("id", &self.id) + .field("method", &self.method) + .field("params", &self.params().len()) + .finish_non_exhaustive() + } +} + +/// A decoded frame. +#[derive(Debug)] +pub enum Incoming { + /// A request or notification from the miner. + Request(Request), + /// A response to a server request. This adapter sends none, so it is dropped. + Response, +} + +/// A frame that could not be decoded, with the id to answer under. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodeFailure { + /// Id to echo; `null` when the frame had no usable id. + pub id: RequestId, + /// Error to send. + pub error: WireError, +} + +impl DecodeFailure { + fn new(id: RequestId, error: WireError) -> Self { + Self { id, error } + } +} + +fn nesting_exceeds(line: &[u8], limit: usize) -> bool { + let mut depth = 0_usize; + let mut in_string = false; + let mut escaped = false; + for &byte in line { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + match byte { + b'"' => in_string = true, + b'[' | b'{' => { + depth += 1; + if depth > limit { + return true; + } + } + b']' | b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } + false +} + +/// Decodes one frame (no terminator). +pub fn decode_line(line: &[u8]) -> Result { + if nesting_exceeds(line, MAX_JSON_DEPTH) { + return Err(DecodeFailure::new(RequestId::null(), errors::PARSE_ERROR)); + } + let document: Value = sonic_rs::from_slice(line) + .map_err(|_| DecodeFailure::new(RequestId::null(), errors::PARSE_ERROR))?; + let (id, method, shape) = { + let Some(object) = document.as_object() else { + return Err(DecodeFailure::new( + RequestId::null(), + errors::INVALID_REQUEST, + )); + }; + let Some(id) = request_id(object.get(&"id")) else { + return Err(DecodeFailure::new( + RequestId::null(), + errors::INVALID_REQUEST, + )); + }; + match object.get(&"method") { + Some(method) => { + let name = method + .as_str() + .filter(|name| !name.is_empty() && name.len() <= MAX_METHOD_LEN); + let Some(name) = name else { + return Err(DecodeFailure::new(id, errors::INVALID_REQUEST)); + }; + let params_ok = match object.get(&"params") { + None => true, + Some(params) if params.is_null() => true, + Some(params) => params + .as_array() + .is_some_and(|array| array.len() <= MAX_PARAMS), + }; + if !params_ok { + return Err(DecodeFailure::new(id, errors::INVALID_PARAMS)); + } + (id, name.to_owned(), true) + } + None => { + if object.get(&"result").is_some() || object.get(&"error").is_some() { + (id, String::new(), false) + } else { + return Err(DecodeFailure::new(id, errors::INVALID_REQUEST)); + } + } + } + }; + if shape { + Ok(Incoming::Request(Request { + id, + method, + document, + })) + } else { + Ok(Incoming::Response) + } +} + +// --------------------------------------------------------------------------- +// Secrets, accounts, difficulty +// --------------------------------------------------------------------------- + +/// A worker password. Not `Clone`, not `Display`; `Debug` prints nothing of it. +pub struct Password(String); + +impl Password { + /// Wraps password text. + #[must_use] + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } + + /// The password text. Callers must not log it. + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Password()") + } +} + +/// Who a share is credited to: the pool account and the rig under it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorkerIdentity { + /// Account that is paid. + pub account: String, + /// Rig name under the account. + pub worker: String, +} + +/// Authentication failed. Carries no reason, so unknown account and wrong +/// password are indistinguishable to the miner and to logs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AuthDenied; + +/// Maps a `mining.authorize` login and password to an identity. There is no +/// always-accepting implementation in this crate. +pub trait AccountResolver: Send + Sync { + /// Resolves the credentials or denies them. + fn resolve(&self, login: &str, password: &Password) -> Result; +} + +/// `account` or `account.rig` is not a valid login. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvalidAccount; + +/// Fixed in-memory account table: login is `account` or `account.rig`. +#[derive(Default)] +pub struct StaticAccountResolver { + accounts: HashMap, +} + +fn valid_account(name: &str) -> bool { + !name.is_empty() + && name.len() <= 100 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +fn valid_rig(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + let a = left.get(index).copied().unwrap_or(0); + let b = right.get(index).copied().unwrap_or(0); + difference |= usize::from(a ^ b); + } + difference == 0 +} + +impl StaticAccountResolver { + /// An empty table; every login is denied until accounts are added. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds `account` with `password`. + pub fn with_account(mut self, account: &str, password: &str) -> Result { + if !valid_account(account) || password.len() > MAX_PASSWORD_LEN { + return Err(InvalidAccount); + } + self.accounts + .insert(account.to_owned(), Password::new(password)); + Ok(self) + } +} + +impl fmt::Debug for StaticAccountResolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StaticAccountResolver") + .field("accounts", &self.accounts.len()) + .finish() + } +} + +impl AccountResolver for StaticAccountResolver { + fn resolve(&self, login: &str, password: &Password) -> Result { + let (account, worker) = match login.split_once('.') { + Some((account, worker)) => (account, worker), + None => (login, "default"), + }; + if !valid_account(account) || !valid_rig(worker) { + return Err(AuthDenied); + } + let stored = self.accounts.get(account); + let expected = stored.map_or(&[][..], |stored| stored.expose().as_bytes()); + let matches = constant_time_eq(expected, password.expose().as_bytes()); + if matches && stored.is_some() { + Ok(WorkerIdentity { + account: account.to_owned(), + worker: worker.to_owned(), + }) + } else { + Err(AuthDenied) + } + } +} + +/// Why a difficulty text was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DifficultyError { + /// Not a plain decimal: sign, exponent, empty part, leading zero, or too long. + Malformed, + /// Zero names no work. + Zero, + /// Bounds are not `min <= initial <= max`. + OutOfOrder, +} + +/// A pool difficulty as plain decimal text plus the share target it names. +/// +/// The target comes from `ShareTarget::from_pool_difficulty_decimal`, the +/// common owner of difficulty-to-target arithmetic; no float is involved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Difficulty { + text: String, + target: ShareTarget, +} + +impl Difficulty { + /// Parses `"16384"` or `"0.001"`: ASCII digits, an optional `.` with at + /// least one digit after it, no leading zero before the point, at most + /// [`MAX_DIFFICULTY_TEXT_LEN`] characters. The text is also valid JSON. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_DIFFICULTY_TEXT_LEN { + return Err(DifficultyError::Malformed); + } + let (whole, fraction) = match text.split_once('.') { + Some((whole, fraction)) => (whole, Some(fraction)), + None => (text, None), + }; + let canonical = !whole.is_empty() + && (whole == "0" || !whole.starts_with('0')) + && fraction.is_none_or(|fraction| !fraction.is_empty()); + if !canonical { + return Err(DifficultyError::Malformed); + } + let target = + ShareTarget::from_pool_difficulty_decimal(text).map_err(|error| match error { + JobError::ZeroTarget => DifficultyError::Zero, + _ => DifficultyError::Malformed, + })?; + Ok(Self { + text: text.to_owned(), + target, + }) + } + + /// The decimal text, exactly as sent in `mining.set_difficulty`. + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } + + /// The share target this difficulty names. + #[must_use] + pub fn target(&self) -> ShareTarget { + self.target + } +} + +/// Session difficulty bounds: the starting value and the range a miner's +/// `mining.suggest_difficulty` may move it within. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DifficultyPolicy { + initial: Difficulty, + min: Difficulty, + max: Difficulty, +} + +impl DifficultyPolicy { + /// Builds a policy; requires `min <= initial <= max` as difficulties. + pub fn new(initial: &str, min: &str, max: &str) -> Result { + let policy = Self { + initial: Difficulty::parse(initial)?, + min: Difficulty::parse(min)?, + max: Difficulty::parse(max)?, + }; + if !policy.contains(&policy.initial) || policy.max.target > policy.min.target { + return Err(DifficultyError::OutOfOrder); + } + Ok(policy) + } + + /// Difficulty a session starts at. + #[must_use] + pub fn initial(&self) -> &Difficulty { + &self.initial + } + + /// Whether `difficulty` lies inside `[min, max]`. A larger difficulty is a + /// smaller target. + #[must_use] + pub fn contains(&self, difficulty: &Difficulty) -> bool { + difficulty.target <= self.min.target && difficulty.target >= self.max.target + } +} + +// --------------------------------------------------------------------------- +// Accounting boundary +// --------------------------------------------------------------------------- + +/// The ledger could not take the record right now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LedgerUnavailable; + +/// Whether a share row was new. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecordOutcome { + /// New row written. + Recorded, + /// A row with this header hash already exists; nothing changed. + AlreadyRecorded, +} + +/// Pool accounting, separate from this adapter. +/// +/// Implementations must be idempotent on `share.hash`: the adapter retries a +/// failed call, and the common owner's duplicate set makes every first delivery +/// unique. +pub trait ShareLedger: Send + Sync { + /// Records one accepted share (`share.class` may be `Block`) credited to + /// `worker`. `share.hash` is the unique key; `share.work` is the work. + fn record_share( + &self, + worker: &WorkerIdentity, + share: &AcceptedShare, + ) -> Result; + + /// Records what the node answered when `block` was submitted. `Err` or an + /// `Undetermined` receipt means the chain outcome is unknown: keep the + /// block pending and reconcile from chain observation. + fn record_block( + &self, + worker: &WorkerIdentity, + block: &FoundBlock, + submission: &Result, + ) -> Result<(), LedgerUnavailable>; +} + +// --------------------------------------------------------------------------- +// Typed client requests +// --------------------------------------------------------------------------- + +/// Parsed `mining.subscribe`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Subscribe { + /// Miner software, truncated to [`MAX_USER_AGENT_LEN`]. + pub user_agent: Option, +} + +/// Parsed `mining.authorize`. +#[derive(Debug)] +pub struct Authorize { + /// Worker login as sent. + pub login: String, + /// Worker password; empty when absent. + pub password: Password, +} + +/// Parsed `mining.submit`; lexical checks only. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Submit { + /// Worker login as sent. + pub login: String, + /// Job id text. + pub job_id: String, + /// Miner-chosen extranonce bytes. + pub extranonce2: Vec, + /// Header time. + pub ntime: u32, + /// Header nonce. + pub nonce: u32, + /// BIP310 rolled bits, when the sixth parameter is present. + pub version_bits: Option, +} + +/// Parsed `mining.configure`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Configure { + /// Requested extension names, in request order. + pub extensions: Vec, + /// `version-rolling.mask`, present when `version-rolling` was requested. + pub version_rolling_mask: Option, + /// `version-rolling.min-bit-count`, 0 when absent. + pub version_rolling_min_bits: u32, +} + +/// A method this adapter understands, or [`ClientRequest::Unknown`]. +#[derive(Debug)] +pub enum ClientRequest { + /// BIP310 feature negotiation. + Configure(Configure), + /// Session start. + Subscribe(Subscribe), + /// Worker login. + Authorize(Authorize), + /// Share submission. + Submit(Submit), + /// NiceHash-dialect extranonce subscription. + ExtranonceSubscribe, + /// Miner's preferred difficulty. + SuggestDifficulty(Difficulty), + /// Any other method. + Unknown, +} + +fn text_at(params: &[Value], index: usize, max: usize) -> Result, WireError> { + match params.get(index) { + None => Ok(None), + Some(value) if value.is_null() => Ok(None), + Some(value) => match value.as_str() { + Some(text) if text.len() <= max => Ok(Some(text)), + _ => Err(errors::INVALID_PARAMS), + }, + } +} + +fn required_text_at(params: &[Value], index: usize, max: usize) -> Result<&str, WireError> { + match text_at(params, index, max)? { + Some(text) if !text.is_empty() => Ok(text), + _ => Err(errors::INVALID_PARAMS), + } +} + +fn truncate_utf8(text: &str, max: usize) -> String { + if text.len() <= max { + return text.to_owned(); + } + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + text[..end].to_owned() +} + +fn parse_subscribe(params: &[Value]) -> Result { + if params.len() > 2 { + return Err(errors::INVALID_PARAMS); + } + // Session-resumption id: validated for shape, never honoured. + text_at(params, 1, MAX_USER_AGENT_LEN)?; + let user_agent = match params.first() { + None => None, + Some(value) if value.is_null() => None, + Some(value) => Some(truncate_utf8( + value.as_str().ok_or(errors::INVALID_PARAMS)?, + MAX_USER_AGENT_LEN, + )), + }; + Ok(Subscribe { user_agent }) +} + +fn parse_authorize(params: &[Value]) -> Result { + if params.is_empty() || params.len() > 2 { + return Err(errors::INVALID_PARAMS); + } + let login = required_text_at(params, 0, MAX_LOGIN_LEN)?.to_owned(); + let password = text_at(params, 1, MAX_PASSWORD_LEN)?.unwrap_or(""); + Ok(Authorize { + login, + password: Password::new(password), + }) +} + +fn parse_submit(params: &[Value]) -> Result { + if params.len() != 5 && params.len() != 6 { + return Err(errors::INVALID_PARAMS); + } + let login = required_text_at(params, 0, MAX_LOGIN_LEN)?.to_owned(); + let job_id = required_text_at(params, 1, MAX_JOB_ID_LEN)?.to_owned(); + let extranonce2_text = required_text_at(params, 2, MAX_EXTRANONCE2_BYTES * 2) + .map_err(|_| errors::INVALID_EXTRANONCE2)?; + let extranonce2 = decode_hex(extranonce2_text).ok_or(errors::INVALID_EXTRANONCE2)?; + let ntime = + parse_u32_hex(required_text_at(params, 3, 8)?, 8).ok_or(errors::INVALID_NTIME_FORMAT)?; + let nonce = + parse_u32_hex(required_text_at(params, 4, 8)?, 8).ok_or(errors::INVALID_NONCE_FORMAT)?; + let version_bits = match text_at(params, 5, 8)? { + None => None, + Some(text) => Some(parse_u32_hex(text, 8).ok_or(errors::INVALID_VERSION_BITS_FORMAT)?), + }; + Ok(Submit { + login, + job_id, + extranonce2, + ntime, + nonce, + version_bits, + }) +} + +fn parse_configure(params: &[Value]) -> Result { + if params.is_empty() || params.len() > 2 { + return Err(errors::INVALID_PARAMS); + } + let names = params[0].as_array().ok_or(errors::INVALID_PARAMS)?; + if names.len() > MAX_CONFIGURE_EXTENSIONS { + return Err(errors::INVALID_PARAMS); + } + let mut extensions = Vec::with_capacity(names.len()); + for name in names.as_slice() { + let name = name + .as_str() + .filter(|name| !name.is_empty() && name.len() <= MAX_EXTENSION_NAME_LEN) + .ok_or(errors::INVALID_PARAMS)?; + extensions.push(name.to_owned()); + } + let options = match params.get(1) { + None => None, + Some(value) if value.is_null() => None, + Some(value) => Some(value.as_object().ok_or(errors::INVALID_PARAMS)?), + }; + let mut version_rolling_mask = None; + let mut version_rolling_min_bits = 0_u32; + if extensions.iter().any(|name| name == "version-rolling") { + let options = options.ok_or(errors::INVALID_PARAMS)?; + let mask_text = options + .get(&"version-rolling.mask") + .and_then(|value| value.as_str()) + .ok_or(errors::INVALID_VERSION_MASK)?; + version_rolling_mask = + Some(parse_u32_hex(mask_text, 1).ok_or(errors::INVALID_VERSION_MASK)?); + if let Some(value) = options.get(&"version-rolling.min-bit-count") { + let bits = value + .as_u64() + .filter(|bits| *bits <= 32) + .ok_or(errors::INVALID_PARAMS)?; + version_rolling_min_bits = u32::try_from(bits).map_err(|_| errors::INVALID_PARAMS)?; + } + } + Ok(Configure { + extensions, + version_rolling_mask, + version_rolling_min_bits, + }) +} + +/// The decimal text of a JSON number: integers verbatim, fractions through +/// Rust's shortest round-trip `Display` (never an exponent). No arithmetic. +fn number_text(value: &Value) -> Option { + if !value.is_number() { + return None; + } + if let Some(whole) = value.as_u64() { + return Some(whole.to_string()); + } + let float = value.as_f64()?; + (float.is_finite() && float > 0.0).then(|| format!("{float}")) +} + +fn parse_suggest(params: &[Value]) -> Result { + let [value] = params else { + return Err(errors::INVALID_PARAMS); + }; + let text = number_text(value).ok_or(errors::INVALID_DIFFICULTY)?; + Difficulty::parse(&text).map_err(|_| errors::INVALID_DIFFICULTY) +} + +impl ClientRequest { + /// Types a decoded request, or says which parameter is wrong. + pub fn parse(request: &Request) -> Result { + let params = request.params(); + match request.method.as_str() { + "mining.configure" => parse_configure(params).map(Self::Configure), + "mining.subscribe" => parse_subscribe(params).map(Self::Subscribe), + "mining.authorize" => parse_authorize(params).map(Self::Authorize), + "mining.submit" => parse_submit(params).map(Self::Submit), + "mining.extranonce.subscribe" => { + if params.is_empty() { + Ok(Self::ExtranonceSubscribe) + } else { + Err(errors::INVALID_PARAMS) + } + } + "mining.suggest_difficulty" => parse_suggest(params).map(Self::SuggestDifficulty), + _ => Ok(Self::Unknown), + } + } +} + +// --------------------------------------------------------------------------- +// Encoders +// --------------------------------------------------------------------------- + +/// `{"id":ID,"result":RESULT,"error":null}\n` +#[must_use] +pub fn encode_result(id: &RequestId, result_json: &str) -> String { + format!( + "{{\"id\":{},\"result\":{},\"error\":null}}\n", + id.as_json(), + result_json + ) +} + +/// `{"id":ID,"result":null,"error":[code,"message",null]}\n` +#[must_use] +pub fn encode_error(id: &RequestId, error: &WireError) -> String { + format!( + "{{\"id\":{},\"result\":null,\"error\":{}}}\n", + id.as_json(), + error.to_json() + ) +} + +/// `{"id":ID,"result":false,"error":[code,"message",null]}\n`: a refused +/// authorize or share. +#[must_use] +pub fn encode_reject(id: &RequestId, error: &WireError) -> String { + format!( + "{{\"id\":{},\"result\":false,\"error\":{}}}\n", + id.as_json(), + error.to_json() + ) +} + +/// `mining.set_difficulty` notification. +#[must_use] +pub fn encode_set_difficulty(difficulty: &Difficulty) -> String { + format!( + "{{\"id\":null,\"method\":\"mining.set_difficulty\",\"params\":[{}]}}\n", + difficulty.as_str() + ) +} + +/// `mining.notify` notification for `job`. +/// +/// The parent hash is word-swapped; `coinb1`, `coinb2` and the merkle branch are +/// the common owner's bytes verbatim; version, nbits and ntime are eight hex +/// digits of the integer. +#[must_use] +pub fn encode_notify(job: &Job, clean_jobs: bool) -> String { + let parent = Hash256::from(job.previous_block_hash()).to_le_bytes(); + let mut line = String::with_capacity( + 384 + 2 * (job.coinbase_prefix().len() + job.coinbase_suffix().len()) + + 68 * job.merkle_branch().len(), + ); + line.push_str("{\"id\":null,\"method\":\"mining.notify\",\"params\":[\""); + line.push_str(&wire_job_id(job.id())); + line.push_str("\",\""); + push_hex(&mut line, &swap_words(&parent)); + line.push_str("\",\""); + push_hex(&mut line, job.coinbase_prefix()); + line.push_str("\",\""); + push_hex(&mut line, job.coinbase_suffix()); + line.push_str("\",["); + for (index, sibling) in job.merkle_branch().iter().enumerate() { + if index > 0 { + line.push(','); + } + line.push('"'); + push_hex(&mut line, &sibling.to_le_bytes()); + line.push('"'); + } + line.push_str("],\""); + line.push_str(&format!("{:08x}", job.version())); + line.push_str("\",\""); + line.push_str(&format!("{:08x}", job.bits().to_consensus())); + line.push_str("\",\""); + line.push_str(&format!("{:08x}", job.ntime())); + line.push_str("\","); + line.push_str(if clean_jobs { "true" } else { "false" }); + line.push_str("]}\n"); + line +} diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs new file mode 100644 index 0000000000..feef8d5f0e --- /dev/null +++ b/crates/stratum/src/sv1/session.rs @@ -0,0 +1,1967 @@ +//! Per-connection SV1 session state machine, the shared pool core, and the +//! bounded TCP server. +//! +//! # Sharing model and lock order +//! +//! [`crate::JobBook`] is single-owner (`&mut`, not `Sync`). Many connection +//! threads reach it through [`PoolCore`], which wraps it in one `Mutex`: +//! +//! 1. `PoolCore.book` (the `JobBook`): held for `open_session`, `close_session`, +//! `install`, `invalidate_active`, and `validate`. Never across a socket +//! read or write, a ledger call, or a node call. +//! 2. `PoolCore.published` (latest job, sequence numbers): held for a clone of +//! the latest `Arc`. Taken while holding `book` only by `install` and +//! `invalidate_active`, in that order; no code takes `book` while holding +//! `published`. +//! +//! A panicking connection thread poisons nothing it cannot afford to: lock +//! guards recover from poison, and the thread's `Lease` closes its book +//! session while unwinding. +//! +//! # Ownership of verdicts +//! +//! Stale, duplicate, version, `ntime`, network-target and share-target +//! decisions are `JobBook::validate`'s. This module only maps its +//! `ShareError` onto SV1 error codes, picks the session's share target +//! (difficulty policy), and hands accepted shares to the [`ShareLedger`]. + +use std::collections::{HashMap, VecDeque}; +use std::error::Error; +use std::fmt; +use std::io::{self, Read, Write}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::{ + AcceptedShare, BlockReceipt, BlockStatus, ExtranonceLayout, FoundBlock, Installed, Job, + JobBook, JobBookConfig, JobError, JobId, Session as BookSession, SessionId, ShareClass, + ShareError, ShareOutcome, ShareSubmission, ShareTarget, StaleReason, fetch_template, +}; +use bitcoin_rs_mining::{Candidate, CoinbaseBudget, MiningControl, MiningControlError}; +use bitcoin_rs_primitives::{BlockHash, Hash256, TxOut}; + +use super::{ + AccountResolver, Authorize, ClientRequest, Configure, Difficulty, DifficultyPolicy, Incoming, + LedgerUnavailable, LineBuffer, RecordOutcome, Request, RequestId, ShareLedger, Submit, + Subscribe, WorkerIdentity, decode_line, encode_error, encode_hex, encode_notify, encode_reject, + encode_result, encode_set_difficulty, errors, json_string, parse_wire_job_id, +}; + +fn locked(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +// --------------------------------------------------------------------------- +// Events, endings, configuration +// --------------------------------------------------------------------------- + +/// Observer callback. Events never carry passwords. +pub type Observer = Arc; + +/// Why a connection was refused before a session thread started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefusalReason { + /// `max_connections` reached. + ConnectionLimit, + /// `max_connections_per_ip` reached for the peer address. + PerIpLimit, + /// The OS refused a thread. + SpawnFailed, +} + +/// What the node answered to a block found by a share. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockOutcome { + /// Accepted, or its body was already connected. + Applied, + /// Rejected, or a duplicate of a known-invalid block. + Rejected, + /// The node could not conclude; the ledger reconciles from the chain. + Undetermined, + /// The submit call itself failed; chain outcome unknown. + NodeError, +} + +/// How a connection ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionEnd { + /// The peer closed its side. + ClientClosed, + /// Server shutdown. + Cancelled, + /// Subscribe and authorize did not complete within `handshake_timeout`. + HandshakeTimeout, + /// No complete frame within `idle_timeout`. + IdleTimeout, + /// A frame stayed unterminated longer than `frame_timeout`. + FrameTimeout, + /// A line exceeded `max_frame_bytes`. + FrameTooLong, + /// A write did not finish within `write_timeout`. + WriteTimeout, + /// Other socket failure. + Io(io::ErrorKind), + /// Too many consecutive malformed or out-of-order requests. + TooManyViolations, + /// Too many failed authorizations. + TooManyAuthFailures, + /// Too many consecutive rejected shares. + TooManyRejects, + /// The pool had no session slot. + PoolRefused, + /// The pool produced something the wire cannot carry. + PoolFault(&'static str), +} + +/// Observable server facts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServerEvent { + /// A connection thread started. + Accepted { + /// Remote address. + peer: SocketAddr, + }, + /// A connection was dropped before any thread started. + Refused { + /// Remote address. + peer: SocketAddr, + /// Why. + reason: RefusalReason, + }, + /// `mining.subscribe` succeeded. + Subscribed { + /// Remote address. + peer: SocketAddr, + /// Native session id. + session: SessionId, + /// Miner software string, truncated. + user_agent: Option, + }, + /// The share passed validation and was credited to the ledger or queued + /// in the outbox. + ShareAccepted { + /// Native session id. + session: SessionId, + /// Header hash, the ledger key. + hash: BlockHash, + /// Share or block candidate. + class: ShareClass, + /// True: recorded immediately; false: buffered in the outbox. + delivered: bool, + }, + /// A found block was handed to the node. + BlockSubmitted { + /// Header hash. + hash: BlockHash, + /// Node answer class. + outcome: BlockOutcome, + }, + /// A connection thread is finishing. The session lease is already closed. + SessionEnded { + /// Remote address. + peer: SocketAddr, + /// Why. + end: SessionEnd, + }, +} + +/// A rejected [`ServerConfig`] field. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfigError { + /// Field name. + pub field: &'static str, + /// What is wrong. + pub reason: &'static str, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid server config `{}`: {}", self.field, self.reason) + } +} + +impl Error for ConfigError {} + +fn require(ok: bool, field: &'static str, reason: &'static str) -> Result<(), ConfigError> { + if ok { + Ok(()) + } else { + Err(ConfigError { field, reason }) + } +} + +/// Bounds and deadlines of one [`Server`]. +#[derive(Clone, Debug)] +pub struct ServerConfig { + /// Connections served at once. + pub max_connections: usize, + /// Connections served at once per peer address. + pub max_connections_per_ip: usize, + /// Longest inbound line, terminator excluded. + pub max_frame_bytes: usize, + /// Longest outbound `mining.notify` line. + pub max_notify_bytes: usize, + /// Socket read timeout and the interval between work polls. + pub poll_interval: Duration, + /// Sleep between `accept` attempts on an idle listener. + pub accept_poll: Duration, + /// Time from connect to a subscribed and authorized session. + pub handshake_timeout: Duration, + /// Longest silence between complete frames. + pub idle_timeout: Duration, + /// Longest an unterminated line may stay incomplete (slowloris bound). + pub frame_timeout: Duration, + /// Longest a single response write may block. + pub write_timeout: Duration, + /// Workers one connection may authorize. + pub max_workers_per_session: usize, + /// Failed authorizations before the connection is closed. + pub max_auth_failures: u32, + /// Consecutive violations before the connection is closed. + pub max_consecutive_violations: u32, + /// Consecutive rejected shares before the connection is closed. + pub max_consecutive_share_rejects: u32, + /// After a difficulty change, how long the easier of old and new target + /// still judges shares (in-flight work). + pub retarget_grace: Duration, + /// Stack of each connection thread. + pub thread_stack_bytes: usize, + /// Starting difficulty and the range `mining.suggest_difficulty` may use. + pub difficulty: DifficultyPolicy, +} + +impl ServerConfig { + /// Conservative defaults around `difficulty`. + #[must_use] + pub fn new(difficulty: DifficultyPolicy) -> Self { + Self { + max_connections: 1024, + max_connections_per_ip: 64, + max_frame_bytes: 64 * 1024, + max_notify_bytes: 1024 * 1024, + poll_interval: Duration::from_millis(100), + accept_poll: Duration::from_millis(10), + handshake_timeout: Duration::from_secs(30), + idle_timeout: Duration::from_secs(300), + frame_timeout: Duration::from_secs(15), + write_timeout: Duration::from_secs(10), + max_workers_per_session: 8, + max_auth_failures: 3, + max_consecutive_violations: 16, + max_consecutive_share_rejects: 512, + retarget_grace: Duration::from_secs(10), + thread_stack_bytes: 512 * 1024, + difficulty, + } + } + + /// Checks every bound. + pub fn validate(&self) -> Result<(), ConfigError> { + let day = Duration::new(86_400, 0); + require( + self.max_connections > 0, + "max_connections", + "must be at least 1", + )?; + require( + self.max_connections_per_ip > 0, + "max_connections_per_ip", + "must be at least 1", + )?; + require( + (256..=1 << 20).contains(&self.max_frame_bytes), + "max_frame_bytes", + "must be within 256 bytes and 1 MiB", + )?; + require( + (1024..=16 << 20).contains(&self.max_notify_bytes), + "max_notify_bytes", + "must be within 1 KiB and 16 MiB", + )?; + require( + !self.poll_interval.is_zero() && self.poll_interval <= self.frame_timeout, + "poll_interval", + "must be nonzero and at most frame_timeout", + )?; + require( + !self.accept_poll.is_zero(), + "accept_poll", + "must be nonzero", + )?; + require( + !self.handshake_timeout.is_zero() && self.handshake_timeout <= day, + "handshake_timeout", + "must be within 1 ns and one day", + )?; + require( + !self.idle_timeout.is_zero() && self.idle_timeout <= day, + "idle_timeout", + "must be within 1 ns and one day", + )?; + require( + !self.frame_timeout.is_zero() && self.frame_timeout <= day, + "frame_timeout", + "must be within 1 ns and one day", + )?; + require( + !self.write_timeout.is_zero() && self.write_timeout <= day, + "write_timeout", + "must be within 1 ns and one day", + )?; + require( + self.max_workers_per_session > 0, + "max_workers_per_session", + "must be at least 1", + )?; + require( + self.max_auth_failures > 0, + "max_auth_failures", + "must be at least 1", + )?; + require( + self.max_consecutive_violations > 0, + "max_consecutive_violations", + "must be at least 1", + )?; + require( + self.max_consecutive_share_rejects > 0, + "max_consecutive_share_rejects", + "must be at least 1", + )?; + require( + self.retarget_grace <= Duration::from_secs(3600), + "retarget_grace", + "must be at most one hour", + )?; + require( + self.thread_stack_bytes >= 256 * 1024, + "thread_stack_bytes", + "must be at least 256 KiB", + ) + } +} + +// --------------------------------------------------------------------------- +// Pool core: the shared JobBook +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct Published { + /// Increments on every fresh job. + seq: u64, + /// `seq` of the newest job that was installed with `clean_jobs`. + clean_seq: u64, + /// Newest job while it is active; `None` after `invalidate_active`. + job: Option>, +} + +struct Latest { + seq: u64, + clean_seq: u64, + job: Option>, +} + +/// A session slot in the book, closed on drop. +struct Lease { + pool: Arc, + session: BookSession, +} + +impl Drop for Lease { + fn drop(&mut self) { + let _ = locked(&self.pool.book).close_session(self.session.id()); + } +} + +#[derive(Clone, Debug)] +enum Pending { + Share { + worker: WorkerIdentity, + share: AcceptedShare, + completion: Arc, + }, + BlockRecord { + worker: WorkerIdentity, + block: Box, + submission: Result, + completion: Arc, + }, +} + +struct Completion { + remaining: AtomicUsize, +} + +/// Debug shows only the outstanding count; a completion carries no secrets. +impl fmt::Debug for Completion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Completion") + .field("remaining", &self.remaining.load(Ordering::Acquire)) + .finish() + } +} + +impl Completion { + fn new(items: usize) -> Self { + Self { + remaining: AtomicUsize::new(items), + } + } + + fn record(&self) { + let _ = self + .remaining + .try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }); + } + + fn is_complete(&self) -> bool { + self.remaining.load(Ordering::Acquire) == 0 + } +} + +impl Pending { + fn hash(&self) -> BlockHash { + match self { + Self::Share { share, .. } => share.hash, + Self::BlockRecord { block, .. } => block.share.hash, + } + } + + fn completion(&self) -> &Completion { + match self { + Self::Share { completion, .. } | Self::BlockRecord { completion, .. } => completion, + } + } + + fn bytes_cost(&self) -> usize { + match self { + Self::Share { .. } => 256, + Self::BlockRecord { block, .. } => 512 + block.raw.len(), + } + } +} + +enum SubmitFailure { + Share(ShareError), + Duplicate, + OutboxFull, +} + +struct Credited { + hash: BlockHash, + class: ShareClass, + completion: Arc, + block: Option, +} + +struct DeferredResponse { + id: RequestId, + hash: BlockHash, + class: ShareClass, + block: Option, + completion: Arc, +} + +#[derive(Default)] +struct Outbox { + items: VecDeque, + total_bytes: usize, + in_flight: usize, + reserved_items: usize, + reserved_bytes: usize, +} + +/// Atomically holds outbox space for one in-flight submit (up to 2 items and +/// 8 MiB for worst-case block entries). +struct OutboxReservation<'a> { + pool: &'a PoolCore, + items: usize, + bytes: usize, + active: bool, +} +struct DrainGuard<'a>(&'a AtomicBool); + +impl Drop for DrainGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl OutboxReservation<'_> { + /// Commits an item into the outbox by consuming part of this reservation. + /// Capacity is guaranteed because it was pre-allocated. + fn commit(&mut self, item: Pending) { + let cost = item.bytes_cost(); + let mut outbox = locked(&self.pool.outbox); + outbox.reserved_items = outbox.reserved_items.saturating_sub(1); + outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(cost); + outbox.total_bytes += cost; + outbox.items.push_back(item); + let total = outbox.items.len() + outbox.in_flight; + self.pool.pending_count.store(total, Ordering::Release); + self.items = self.items.saturating_sub(1); + self.bytes = self.bytes.saturating_sub(cost); + } +} + +impl Drop for OutboxReservation<'_> { + fn drop(&mut self) { + if self.active { + let mut outbox = locked(&self.pool.outbox); + outbox.reserved_items = outbox.reserved_items.saturating_sub(self.items); + outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(self.bytes); + self.active = false; + } + } +} + +struct ShareRequest<'a> { + session: SessionId, + job: JobId, + rolled: &'a [u8], + ntime: u32, + nonce: u32, + version_bits: Option, +} + +/// The pool: one `JobBook`, the node control it submits blocks to, and the +/// ledger it credits. Shared by every connection thread. +pub struct PoolCore { + book: Mutex, + published: Mutex, + control: Arc, + ledger: Arc, + layout: ExtranonceLayout, + version_mask: u32, + budget: CoinbaseBudget, + ledger_attempts: u32, + ledger_backoff: Duration, + outbox: Mutex, + outbox_max_items: usize, + outbox_max_bytes: usize, + pending_count: AtomicUsize, + draining: AtomicBool, +} + +/// A template fetched and installed by [`PoolCore::refresh`]. +#[derive(Debug)] +pub struct Refreshed { + /// What `JobBook::install` did. + pub installed: Installed, + /// Pass to the next [`PoolCore::refresh`] to long-poll for a newer template. + pub long_poll_id: String, + /// The template's `submit_old`. + pub submit_old: Option, +} + +/// [`PoolCore::refresh`] failure. +#[derive(Debug)] +pub enum RefreshError { + /// The node would not give a template. + Template(MiningControlError), + /// The job book would not project it. + Install(JobError), +} + +impl fmt::Display for RefreshError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Template(error) => write!(f, "template fetch failed: {error}"), + Self::Install(error) => write!(f, "job install failed: {error}"), + } + } +} + +impl Error for RefreshError {} + +fn share_error_wire(error: &ShareError) -> crate::WireError { + match error { + ShareError::UnknownSession(_) | ShareError::SessionPrefixLength { .. } => errors::INTERNAL, + ShareError::UnknownJob(_) + | ShareError::Stale { + reason: StaleReason::Retired, + .. + } => errors::JOB_NOT_FOUND_ERROR, + ShareError::Stale { .. } => errors::STALE_SHARE, + ShareError::ExtranonceLength { .. } => errors::INVALID_EXTRANONCE2_LENGTH, + ShareError::VersionOutsideMask { .. } => errors::VERSION_BITS_OUTSIDE_MASK, + ShareError::TimeTooEarly { .. } | ShareError::TimeTooLate { .. } => errors::INVALID_NTIME, + ShareError::Duplicate { .. } => errors::DUPLICATE_SHARE_ERROR, + ShareError::BelowTarget { .. } => errors::LOW_DIFFICULTY_SHARE, + ShareError::JobShareCapacity { .. } => errors::JOB_SHARE_CAPACITY, + } +} + +impl PoolCore { + /// Builds the pool over a fresh `JobBook`. Retries a failed ledger call + /// three times with a 5 ms base backoff; see [`Self::with_ledger_retry`]. + pub fn new( + config: JobBookConfig, + control: Arc, + ledger: Arc, + ) -> Result { + let layout = config.extranonce; + let version_mask = config.version_mask; + let budget = config.budget; + let book = JobBook::new(config)?; + Ok(Self { + book: Mutex::new(book), + published: Mutex::new(Published::default()), + control, + ledger, + layout, + version_mask, + budget, + ledger_attempts: 3, + ledger_backoff: Duration::from_millis(5), + outbox: Mutex::new(Outbox::default()), + outbox_max_items: 4096, + outbox_max_bytes: 32 * 1024 * 1024, + pending_count: AtomicUsize::new(0), + draining: AtomicBool::new(false), + }) + } + + /// Sets the ledger retry budget: `attempts` calls (at least one) with a + /// sleep of `backoff * attempt` plus a per-share jitter of up to 2.5 ms + /// between them. + #[must_use] + pub fn with_ledger_retry(mut self, attempts: u32, backoff: Duration) -> Self { + self.ledger_attempts = attempts.max(1); + self.ledger_backoff = backoff; + self + } + + /// Sets the outbox bounds for queuing writes when the ledger is unavailable. + #[must_use] + pub fn with_outbox(mut self, max_items: usize, max_bytes: usize) -> Self { + self.outbox_max_items = max_items.max(1); + self.outbox_max_bytes = max_bytes.max(1024); + self + } + + /// Number of writes currently waiting in the outbox. + #[must_use] + pub fn pending_ledger_writes(&self) -> usize { + self.pending_count.load(Ordering::Acquire) + } + + /// Extranonce split every session uses (SV1 `extranonce2_size` is its + /// `rolled_len`). + #[must_use] + pub fn layout(&self) -> ExtranonceLayout { + self.layout + } + + /// Version bits the pool lets miners roll (zero: no rolling). + #[must_use] + pub fn version_mask(&self) -> u32 { + self.version_mask + } + + /// Open sessions in the book. + #[must_use] + pub fn session_count(&self) -> usize { + locked(&self.book).session_count() + } + + /// Runs an owner-local validation operation under the sole authoritative + /// `JobBook` mutex. The callback must not re-enter `PoolCore`, install or + /// invalidate jobs, or perform node, ledger, or other external I/O while + /// this lock is held. + pub fn with_job_book(&self, operation: impl FnOnce(&mut JobBook) -> R) -> R { + let mut book = locked(&self.book); + operation(&mut book) + } + + /// Returns the active job from the same authoritative `JobBook` used by + /// SV1 validation and session allocation. + #[must_use] + pub fn current_job(&self) -> Option> { + self.with_job_book(|book| book.current().cloned()) + } + + /// Returns the latest installation metadata from the authoritative pool + /// owner. The value is absent before the first install or after explicit + /// invalidation. + #[must_use] + pub fn current_installed(&self) -> Option { + self.with_job_book(|book| book.current_installed().cloned()) + } + + /// Installs `candidate` (with the pool's coinbase `extra_outputs`) and, when + /// it is a fresh job, publishes it to every session at its next poll. + pub fn install( + &self, + candidate: Arc, + extra_outputs: Vec, + ) -> Result { + self.install_with(false, candidate, extra_outputs) + } + + /// Marks every active job stale. Sessions keep their last job until the + /// next install, whose `clean_jobs` is then true; shares on the stale jobs + /// are rejected by the book. + pub fn invalidate_active(&self) -> Vec { + let mut book = locked(&self.book); + let stale = book.invalidate_active(); + let mut published = locked(&self.published); + published.job = None; + stale + } + + /// Fetches one template from the node (long-polling on `long_poll_id`) and + /// installs it. `submit_old == Some(false)` first invalidates active jobs. + pub fn refresh( + &self, + long_poll_id: Option<&str>, + extra_outputs: Vec, + ) -> Result { + let template = fetch_template(&*self.control, self.budget, long_poll_id) + .map_err(RefreshError::Template)?; + let next = template.candidate.template_id.as_str().to_owned(); + let invalidate = template.submit_old == Some(false); + let installed = self + .install_with(invalidate, Arc::clone(&template.candidate), extra_outputs) + .map_err(RefreshError::Install)?; + Ok(Refreshed { + installed, + long_poll_id: next, + submit_old: template.submit_old, + }) + } + + fn install_with( + &self, + invalidate: bool, + candidate: Arc, + extra_outputs: Vec, + ) -> Result { + let mut book = locked(&self.book); + if invalidate && !book.invalidate_active().is_empty() { + locked(&self.published).job = None; + } + let installed = book.install(candidate, extra_outputs)?; + if installed.fresh { + let mut published = locked(&self.published); + published.seq = published.seq.saturating_add(1); + published.job = Some(Arc::clone(&installed.job)); + if installed.clean_jobs { + published.clean_seq = published.seq; + } + } + Ok(installed) + } + + fn open_lease(self: &Arc) -> Result { + let session = locked(&self.book).open_session()?; + Ok(Lease { + pool: Arc::clone(self), + session, + }) + } + + fn latest(&self) -> Latest { + let published = locked(&self.published); + Latest { + seq: published.seq, + clean_seq: published.clean_seq, + job: published.job.clone(), + } + } + + fn retry_pause(&self, attempt: u32, hash: BlockHash) { + let jitter = u64::from(Hash256::from(hash).to_le_bytes()[0]) * 10; + thread::sleep(self.ledger_backoff * (attempt + 1) + Duration::from_micros(jitter)); + } + + fn submit_block_to_node(&self, found: &FoundBlock) -> Result { + const ATTEMPTS: u32 = 5; + let mut attempt = 0; + loop { + match found.submit(&*self.control) { + Ok(receipt) => return Ok(receipt), + Err( + MiningControlError::Unavailable(message) | MiningControlError::Failed(message), + ) => { + attempt += 1; + if attempt == ATTEMPTS { + return Err(MiningControlError::Failed(message)); + } + self.retry_pause(attempt - 1, found.share.hash); + } + Err(non_retriable) => return Err(non_retriable), + } + } + } + + fn attempt_item(&self, item: &Pending) -> Result { + match item { + Pending::Share { worker, share, .. } => self.ledger.record_share(worker, share), + Pending::BlockRecord { + worker, + block, + submission, + .. + } => self + .ledger + .record_block(worker, block, submission) + .map(|()| RecordOutcome::Recorded), + } + } + + fn deliver_inline(&self, item: &Pending) -> Result { + self.drain_outbox(); + if self.pending_count.load(Ordering::Acquire) > 0 { + return Err(LedgerUnavailable); + } + let hash = item.hash(); + for attempt in 0..self.ledger_attempts { + match self.attempt_item(item) { + Ok(outcome) => return Ok(outcome), + Err(LedgerUnavailable) => { + if attempt + 1 < self.ledger_attempts { + self.retry_pause(attempt, hash); + } + } + } + } + Err(LedgerUnavailable) + } + + /// Drains pending outbox items sequentially. + pub fn drain_outbox(&self) { + if self.pending_count.load(Ordering::Acquire) == 0 { + return; + } + if self + .draining + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return; + } + let _guard = DrainGuard(&self.draining); + + loop { + let item = { + let mut outbox = locked(&self.outbox); + let Some(item) = outbox.items.pop_front() else { + outbox.in_flight = 0; + self.pending_count.store(0, Ordering::Release); + break; + }; + outbox.total_bytes = outbox.total_bytes.saturating_sub(item.bytes_cost()); + outbox.in_flight = 1; + self.pending_count + .store(outbox.items.len() + 1, Ordering::Release); + item + }; + + let mut succeeded = false; + for attempt in 0..self.ledger_attempts { + match self.attempt_item(&item) { + Ok(_) => { + item.completion().record(); + succeeded = true; + break; + } + Err(LedgerUnavailable) => { + if attempt + 1 < self.ledger_attempts { + self.retry_pause(attempt, item.hash()); + } + } + } + } + + let mut outbox = locked(&self.outbox); + outbox.in_flight = 0; + if succeeded { + self.pending_count + .store(outbox.items.len(), Ordering::Release); + } else { + outbox.total_bytes += item.bytes_cost(); + outbox.items.push_front(item); + self.pending_count + .store(outbox.items.len(), Ordering::Release); + break; + } + } + } + + /// Atomically reserves outbox space for one share (and potential block). + /// Returns `None` if the outbox has reached capacity. + /// Per-session bound for withheld submit responses: one outstanding + /// accounting completion. A second submit while the ledger is unavailable + /// is shed with a retryable accounting error, not buffered. + fn reserve_outbox_space(&self) -> Option> { + self.drain_outbox(); + let reserve_items = 2_usize; + let reserve_bytes = 8 * 1024 * 1024_usize; + let mut outbox = locked(&self.outbox); + let committed_items = outbox.items.len() + outbox.reserved_items; + let committed_bytes = outbox.total_bytes.saturating_add(outbox.reserved_bytes); + if committed_items.saturating_add(reserve_items) > self.outbox_max_items + || committed_bytes.saturating_add(reserve_bytes) > self.outbox_max_bytes + { + None + } else { + outbox.reserved_items += reserve_items; + outbox.reserved_bytes += reserve_bytes; + Some(OutboxReservation { + pool: self, + items: reserve_items, + bytes: reserve_bytes, + active: true, + }) + } + } + + fn submit( + &self, + worker: &WorkerIdentity, + request: &ShareRequest<'_>, + target: &ShareTarget, + ) -> Result { + // Atomically reserve space BEFORE validation. If the outbox is at + // capacity, fail with backpressure so the miner may retry and the hash + // is never burned in `JobBook`. + let mut reservation = self + .reserve_outbox_space() + .ok_or(SubmitFailure::OutboxFull)?; + + let outcome = { + let mut book = locked(&self.book); + let version = match (book.job(request.job), request.version_bits) { + (Some(job), Some(bits)) => job + .version_from_rolled_bits(bits) + .map_err(SubmitFailure::Share)?, + (Some(job), None) => job.version(), + // The book rejects an unknown or retired job before it reads + // the version. + (None, _) => 0, + }; + let submission = ShareSubmission { + session: request.session, + job: request.job, + rolled_extranonce: request.rolled, + ntime: request.ntime, + nonce: request.nonce, + version, + }; + book.validate(&submission, target) + .map_err(SubmitFailure::Share)? + }; + + match outcome { + ShareOutcome::Share(accepted) => { + let hash = accepted.hash; + let completion = Arc::new(Completion::new(1)); + let item = Pending::Share { + worker: worker.clone(), + share: *accepted, + completion: Arc::clone(&completion), + }; + // Attempt inline delivery first; otherwise the completion waits on the outbox. + match self.deliver_inline(&item) { + Ok(RecordOutcome::Recorded) => completion.record(), + Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), + Err(LedgerUnavailable) => reservation.commit(item), + } + Ok(Credited { + hash, + class: ShareClass::Share, + completion, + block: None, + }) + } + ShareOutcome::Block(found) => { + // Priority invariant: node submission runs before any ledger record. + let submission = self.submit_block_to_node(&found); + let block_outcome = match &submission { + Ok(receipt) => match receipt.status() { + BlockStatus::Applied => BlockOutcome::Applied, + BlockStatus::Rejected => BlockOutcome::Rejected, + BlockStatus::Undetermined => BlockOutcome::Undetermined, + }, + Err(_) => BlockOutcome::NodeError, + }; + let hash = found.share.hash; + let completion = Arc::new(Completion::new(2)); + let share_item = Pending::Share { + worker: worker.clone(), + share: found.share.clone(), + completion: Arc::clone(&completion), + }; + let block_item = Pending::BlockRecord { + worker: worker.clone(), + block: found, + submission, + completion: Arc::clone(&completion), + }; + + match self.deliver_inline(&share_item) { + Ok(RecordOutcome::Recorded) => completion.record(), + Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), + Err(LedgerUnavailable) => reservation.commit(share_item), + } + + // Deliver or buffer block record. + if self.deliver_inline(&block_item).is_err() { + reservation.commit(block_item); + } else { + completion.record(); + } + + Ok(Credited { + hash, + class: ShareClass::Block, + completion, + block: Some(block_outcome), + }) + } + } + } +} + +// --------------------------------------------------------------------------- +// Shared server state +// --------------------------------------------------------------------------- + +/// What a [`Server`] needs from its embedder. +pub struct ServerDeps { + /// The shared pool. + pub pool: Arc, + /// Credential check. There is no default. + pub accounts: Arc, + /// Optional event sink. + pub observer: Option, +} + +/// Shared admission limits for every pool protocol listener. +pub struct ConnectionLimiter { + max_connections: usize, + max_connections_per_ip: usize, + state: Mutex, +} + +struct AdmissionState { + total: usize, + per_ip: HashMap, +} + +/// A connection's admission slot. Dropping it releases both counters. +pub struct ConnectionPermit { + limiter: Arc, + ip: IpAddr, +} + +impl ConnectionLimiter { + /// Builds explicit total and per-peer admission caps. + pub fn new(max_connections: usize, max_connections_per_ip: usize) -> Result { + require(max_connections > 0, "max_connections", "must be at least 1")?; + require( + max_connections_per_ip > 0, + "max_connections_per_ip", + "must be at least 1", + )?; + Ok(Self { + max_connections, + max_connections_per_ip, + state: Mutex::new(AdmissionState { + total: 0, + per_ip: HashMap::new(), + }), + }) + } + + /// Attempts one total-and-per-peer admission atomically. + pub fn try_admit(self: &Arc, ip: IpAddr) -> Result { + let mut state = locked(&self.state); + if state.total >= self.max_connections { + return Err(RefusalReason::ConnectionLimit); + } + let current = state.per_ip.get(&ip).copied().unwrap_or(0); + if current >= self.max_connections_per_ip { + return Err(RefusalReason::PerIpLimit); + } + state.total += 1; + state.per_ip.insert(ip, current + 1); + Ok(ConnectionPermit { + limiter: Arc::clone(self), + ip, + }) + } + + /// Number of currently admitted connections. + #[must_use] + pub fn active(&self) -> usize { + locked(&self.state).total + } +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + let mut state = locked(&self.limiter.state); + state.total = state.total.saturating_sub(1); + if let Some(current) = state.per_ip.get_mut(&self.ip) { + *current = current.saturating_sub(1); + if *current == 0 { + state.per_ip.remove(&self.ip); + } + } + } +} + +struct Shared { + config: ServerConfig, + pool: Arc, + accounts: Arc, + observer: Option, + cancel: AtomicBool, + limiter: Arc, + children: Mutex>>, +} + +impl Shared { + fn emit(&self, event: &ServerEvent) { + if let Some(observer) = &self.observer { + observer(event); + } + } +} + +struct ConnectionSlot { + _permit: ConnectionPermit, +} + +// --------------------------------------------------------------------------- +// Session state machine +// --------------------------------------------------------------------------- + +enum Flow { + Continue, + Close(SessionEnd), +} + +enum Verdict { + /// A valid request answered; resets the violation count. + Good, + /// A share accepted; resets violation and reject counts. + Accepted, + /// Answered without touching any counter. + Ignored, + /// Malformed or out-of-order request. + Violation, + /// A share the pool refused. + Rejected, + /// End the connection. + Fatal(SessionEnd), +} + +struct SessionState { + shared: Arc, + ordinal: u64, + peer: SocketAddr, + lease: Option, + workers: Vec<(String, WorkerIdentity)>, + version_rolling: Option, + work_started: bool, + difficulty: Difficulty, + previous_target: Option<(ShareTarget, Instant)>, + difficulty_pending: bool, + seq: u64, + poll_hint: bool, + auth_failures: u32, + violations: u32, + rejects: u32, + deferred: Vec, +} + +fn emit_line(out: &mut Vec, line: &str) { + out.extend_from_slice(line.as_bytes()); +} + +impl SessionState { + fn new(shared: Arc, ordinal: u64, peer: SocketAddr) -> Self { + let difficulty = shared.config.difficulty.initial().clone(); + Self { + shared, + ordinal, + peer, + lease: None, + workers: Vec::new(), + version_rolling: None, + work_started: false, + difficulty, + previous_target: None, + difficulty_pending: true, + seq: 0, + poll_hint: false, + auth_failures: 0, + violations: 0, + rejects: 0, + deferred: Vec::new(), + } + } + + fn is_ready(&self) -> bool { + self.lease.is_some() && !self.workers.is_empty() + } + + fn take_poll_hint(&mut self) -> bool { + std::mem::take(&mut self.poll_hint) + } + + fn handle_frame(&mut self, frame: &[u8], out: &mut Vec) -> Flow { + let verdict = match decode_line(frame) { + Err(failure) => { + emit_line(out, &encode_error(&failure.id, &failure.error)); + Verdict::Violation + } + Ok(Incoming::Response) => Verdict::Ignored, + Ok(Incoming::Request(request)) => self.dispatch(&request, out), + }; + self.apply(verdict) + } + + fn apply(&mut self, verdict: Verdict) -> Flow { + let config = &self.shared.config; + match verdict { + Verdict::Ignored => Flow::Continue, + Verdict::Good => { + self.violations = 0; + Flow::Continue + } + Verdict::Accepted => { + self.violations = 0; + self.rejects = 0; + Flow::Continue + } + Verdict::Violation => { + self.violations = self.violations.saturating_add(1); + if self.violations >= config.max_consecutive_violations { + Flow::Close(SessionEnd::TooManyViolations) + } else { + Flow::Continue + } + } + Verdict::Rejected => { + self.rejects = self.rejects.saturating_add(1); + if self.rejects >= config.max_consecutive_share_rejects { + Flow::Close(SessionEnd::TooManyRejects) + } else { + Flow::Continue + } + } + Verdict::Fatal(end) => Flow::Close(end), + } + } + + fn dispatch(&mut self, request: &Request, out: &mut Vec) -> Verdict { + let parsed = match ClientRequest::parse(request) { + Ok(parsed) => parsed, + Err(error) => { + emit_line(out, &encode_error(&request.id, &error)); + return Verdict::Violation; + } + }; + match parsed { + ClientRequest::Configure(configure) => self.on_configure(&request.id, &configure, out), + ClientRequest::Subscribe(subscribe) => self.on_subscribe(&request.id, subscribe, out), + ClientRequest::Authorize(authorize) => self.on_authorize(&request.id, authorize, out), + ClientRequest::Submit(submit) => self.on_submit(&request.id, &submit, out), + ClientRequest::ExtranonceSubscribe => { + // The common owner fixes each session's extranonce prefix; it is + // never rotated, so there is nothing to subscribe to. + emit_line( + out, + &encode_reject(&request.id, &errors::EXTRANONCE_SUBSCRIPTION_UNSUPPORTED), + ); + Verdict::Ignored + } + ClientRequest::SuggestDifficulty(difficulty) => { + self.on_suggest(&request.id, difficulty, out) + } + ClientRequest::Unknown => { + emit_line(out, &encode_error(&request.id, &errors::METHOD_NOT_FOUND)); + Verdict::Violation + } + } + } + + fn on_configure( + &mut self, + id: &RequestId, + configure: &Configure, + out: &mut Vec, + ) -> Verdict { + if self.work_started { + emit_line(out, &encode_error(id, &errors::CONFIGURE_TOO_LATE)); + return Verdict::Violation; + } + let mut members: Vec = Vec::new(); + let mut answered: Vec<&str> = Vec::new(); + for name in &configure.extensions { + if answered.contains(&name.as_str()) { + continue; + } + answered.push(name); + if name == "version-rolling" { + let mask = + self.shared.pool.version_mask() & configure.version_rolling_mask.unwrap_or(0); + if mask != 0 && mask.count_ones() >= configure.version_rolling_min_bits { + self.version_rolling = Some(mask); + members.push(String::from("\"version-rolling\":true")); + members.push(format!("\"version-rolling.mask\":\"{mask:08x}\"")); + } else { + self.version_rolling = None; + members.push(String::from("\"version-rolling\":false")); + } + } else { + // minimum-difficulty, subscribe-extranonce, info, and anything + // unknown: declined, as BIP310 allows. + members.push(format!("{}:false", json_string(name))); + } + } + emit_line( + out, + &encode_result(id, &format!("{{{}}}", members.join(","))), + ); + Verdict::Good + } + + fn on_subscribe(&mut self, id: &RequestId, subscribe: Subscribe, out: &mut Vec) -> Verdict { + if self.lease.is_some() { + emit_line(out, &encode_error(id, &errors::ALREADY_SUBSCRIBED)); + return Verdict::Violation; + } + let Some(lease) = self.shared.pool.open_lease().ok() else { + emit_line(out, &encode_error(id, &errors::POOL_AT_CAPACITY)); + return Verdict::Fatal(SessionEnd::PoolRefused); + }; + let extranonce1 = encode_hex(lease.session.extranonce_prefix()); + let extranonce2_size = self.shared.pool.layout().rolled_len(); + let tag = format!("{:x}", self.ordinal); + let result = format!( + "[[[\"mining.set_difficulty\",\"d{tag}\"],[\"mining.notify\",\"n{tag}\"]],\"{extranonce1}\",{extranonce2_size}]" + ); + emit_line(out, &encode_result(id, &result)); + self.shared.emit(&ServerEvent::Subscribed { + peer: self.peer, + session: lease.session.id(), + user_agent: subscribe.user_agent, + }); + self.lease = Some(lease); + if self.is_ready() { + self.poll_hint = true; + } + Verdict::Good + } + + fn on_authorize(&mut self, id: &RequestId, authorize: Authorize, out: &mut Vec) -> Verdict { + let Ok(identity) = self + .shared + .accounts + .resolve(&authorize.login, &authorize.password) + else { + self.auth_failures = self.auth_failures.saturating_add(1); + emit_line(out, &encode_reject(id, &errors::UNAUTHORIZED_WORKER_ERROR)); + return if self.auth_failures >= self.shared.config.max_auth_failures { + Verdict::Fatal(SessionEnd::TooManyAuthFailures) + } else { + Verdict::Ignored + }; + }; + + if !self + .workers + .iter() + .any(|(login, _)| *login == authorize.login) + { + if self.workers.len() >= self.shared.config.max_workers_per_session { + emit_line(out, &encode_error(id, &errors::TOO_MANY_WORKERS)); + return Verdict::Violation; + } + self.workers.push((authorize.login, identity)); + } + emit_line(out, &encode_result(id, "true")); + if self.is_ready() { + self.poll_hint = true; + } + Verdict::Good + } + + fn on_suggest(&mut self, id: &RequestId, difficulty: Difficulty, out: &mut Vec) -> Verdict { + if !self.shared.config.difficulty.contains(&difficulty) { + emit_line(out, &encode_reject(id, &errors::DIFFICULTY_OUT_OF_RANGE)); + return Verdict::Ignored; + } + if difficulty != self.difficulty { + let until = Instant::now() + self.shared.config.retarget_grace; + self.previous_target = Some((self.difficulty.target(), until)); + self.difficulty = difficulty; + self.difficulty_pending = true; + } + emit_line(out, &encode_result(id, "true")); + if self.is_ready() { + self.poll_hint = true; + } + Verdict::Good + } + + /// The target the share is judged against: the session's, or for a short + /// grace after a change the easier of the old and new target. + fn effective_target(&self, now: Instant) -> ShareTarget { + match self.previous_target { + Some((previous, until)) if now < until => previous.max(self.difficulty.target()), + _ => self.difficulty.target(), + } + } + + fn on_submit(&mut self, id: &RequestId, submit: &Submit, out: &mut Vec) -> Verdict { + let Some(lease) = self.lease.as_ref() else { + emit_line(out, &encode_error(id, &errors::NOT_SUBSCRIBED_ERROR)); + return Verdict::Violation; + }; + let Some(identity) = self + .workers + .iter() + .find(|(login, _)| *login == submit.login) + .map(|(_, identity)| identity.clone()) + else { + emit_line(out, &encode_error(id, &errors::UNAUTHORIZED_WORKER_ERROR)); + return Verdict::Violation; + }; + if let Some(bits) = submit.version_bits { + let Some(mask) = self.version_rolling else { + emit_line(out, &encode_error(id, &errors::UNEXPECTED_VERSION_BITS)); + return Verdict::Violation; + }; + if bits & !mask != 0 { + emit_line(out, &encode_reject(id, &errors::VERSION_BITS_OUTSIDE_MASK)); + return Verdict::Rejected; + } + } + // Job text this adapter never issued cannot name a job. + let Some(job) = parse_wire_job_id(&submit.job_id) else { + emit_line(out, &encode_reject(id, &errors::JOB_NOT_FOUND_ERROR)); + return Verdict::Rejected; + }; + // Shed before validation: an accepted share burns its duplicate + // identity, so a second submit while a completion is outstanding + // must not enter the book or the outbox. + if !self.deferred.is_empty() { + emit_line(out, &encode_reject(id, &errors::ACCOUNTING_UNAVAILABLE)); + return Verdict::Ignored; + } + let target = self.effective_target(Instant::now()); + let request = ShareRequest { + session: lease.session.id(), + job, + rolled: &submit.extranonce2, + ntime: submit.ntime, + nonce: submit.nonce, + version_bits: submit.version_bits, + }; + let session = request.session; + match self.shared.pool.submit(&identity, &request, &target) { + Ok(credited) => { + if credited.completion.is_complete() { + self.emit_credited(id, session, &credited, out); + } else { + self.deferred.push(DeferredResponse { + id: id.clone(), + hash: credited.hash, + class: credited.class, + block: credited.block, + completion: credited.completion, + }); + self.poll_hint = true; + } + Verdict::Accepted + } + Err(SubmitFailure::Share(error)) => { + let wire = share_error_wire(&error); + if matches!(error, ShareError::ExtranonceLength { .. }) { + emit_line(out, &encode_error(id, &wire)); + Verdict::Violation + } else { + emit_line(out, &encode_reject(id, &wire)); + Verdict::Rejected + } + } + Err(SubmitFailure::Duplicate) => { + emit_line(out, &encode_reject(id, &errors::DUPLICATE_SHARE_ERROR)); + Verdict::Rejected + } + Err(SubmitFailure::OutboxFull) => { + // The share was rejected BEFORE validation; the hash was NOT burned. + // The miner may retry. + emit_line(out, &encode_reject(id, &errors::ACCOUNTING_UNAVAILABLE)); + Verdict::Ignored + } + } + } + + fn emit_credited( + &self, + id: &RequestId, + session: SessionId, + credited: &Credited, + out: &mut Vec, + ) { + self.shared.emit(&ServerEvent::ShareAccepted { + session, + hash: credited.hash, + class: credited.class, + delivered: true, + }); + if let Some(outcome) = credited.block { + self.shared.emit(&ServerEvent::BlockSubmitted { + hash: credited.hash, + outcome, + }); + } + emit_line(out, &encode_result(id, "true")); + } + + /// Sends pending difficulty and the newest job this session has not seen. + fn poll(&mut self, out: &mut Vec) -> Flow { + self.poll_hint = false; + self.work_started = true; + self.shared.pool.drain_outbox(); + let session = self.lease.as_ref().map(|lease| lease.session.id()); + let mut pending = Vec::with_capacity(self.deferred.len()); + for response in std::mem::take(&mut self.deferred) { + if response.completion.is_complete() { + if let Some(session) = session { + let credited = Credited { + hash: response.hash, + class: response.class, + completion: response.completion, + block: response.block, + }; + self.emit_credited(&response.id, session, &credited, out); + } + } else { + pending.push(response); + } + } + self.deferred = pending; + if self.difficulty_pending { + emit_line(out, &encode_set_difficulty(&self.difficulty)); + self.difficulty_pending = false; + } + let latest = self.shared.pool.latest(); + if latest.seq > self.seq + && let Some(job) = latest.job + { + let line = encode_notify(&job, latest.clean_seq > self.seq); + if line.len() > self.shared.config.max_notify_bytes { + return Flow::Close(SessionEnd::PoolFault("notify exceeds the configured limit")); + } + emit_line(out, &line); + self.seq = latest.seq; + } + Flow::Continue + } +} + +// --------------------------------------------------------------------------- +// Connection driver +// --------------------------------------------------------------------------- + +fn flush(stream: &mut TcpStream, out: &mut Vec) -> Result<(), SessionEnd> { + if out.is_empty() { + return Ok(()); + } + let result = stream.write_all(out); + out.clear(); + result.map_err(|error| match error.kind() { + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => SessionEnd::WriteTimeout, + kind => SessionEnd::Io(kind), + }) +} + +fn run_connection( + mut stream: TcpStream, + peer: SocketAddr, + ordinal: u64, + shared: &Arc, +) -> SessionEnd { + let config = &shared.config; + // Best effort: latency only. + let _ = stream.set_nodelay(true); + if let Err(error) = stream + .set_nonblocking(false) + .and_then(|()| stream.set_read_timeout(Some(config.poll_interval))) + .and_then(|()| stream.set_write_timeout(Some(config.write_timeout))) + { + return SessionEnd::Io(error.kind()); + } + shared.emit(&ServerEvent::Accepted { peer }); + + let mut session = SessionState::new(Arc::clone(shared), ordinal, peer); + let mut framer = LineBuffer::new(config.max_frame_bytes); + let mut out: Vec = Vec::with_capacity(1024); + let mut chunk = [0_u8; 4096]; + let started = Instant::now(); + let mut last_activity = started; + let mut last_poll = started; + let mut partial_since: Option = None; + + let end = 'run: loop { + if shared.cancel.load(Ordering::Acquire) { + break SessionEnd::Cancelled; + } + let mut completed_frame = false; + match stream.read(&mut chunk) { + Ok(0) => break SessionEnd::ClientClosed, + Ok(count) => { + if framer.push(&chunk[..count]).is_err() { + break SessionEnd::FrameTooLong; + } + loop { + match framer.next_frame() { + Ok(Some(frame)) => { + completed_frame = true; + last_activity = Instant::now(); + if let Flow::Close(end) = session.handle_frame(&frame, &mut out) { + break 'run end; + } + } + Ok(None) => break, + Err(_) => break 'run SessionEnd::FrameTooLong, + } + } + } + Err(error) => match error.kind() { + io::ErrorKind::WouldBlock + | io::ErrorKind::TimedOut + | io::ErrorKind::Interrupted => {} + kind => break SessionEnd::Io(kind), + }, + } + let now = Instant::now(); + if completed_frame { + partial_since = None; + } + if framer.has_partial() { + partial_since.get_or_insert(now); + } else { + partial_since = None; + } + if let Err(end) = flush(&mut stream, &mut out) { + break end; + } + if !session.is_ready() && now.duration_since(started) >= config.handshake_timeout { + break SessionEnd::HandshakeTimeout; + } + if now.duration_since(last_activity) >= config.idle_timeout { + break SessionEnd::IdleTimeout; + } + if partial_since.is_some_and(|since| now.duration_since(since) >= config.frame_timeout) { + break SessionEnd::FrameTimeout; + } + if session.is_ready() + && (session.take_poll_hint() || now.duration_since(last_poll) >= config.poll_interval) + { + last_poll = now; + if let Flow::Close(end) = session.poll(&mut out) { + break end; + } + if let Err(end) = flush(&mut stream, &mut out) { + break end; + } + } + }; + // Best effort: deliver the answer that precedes a close. + let _ = flush(&mut stream, &mut out); + let _ = stream.shutdown(Shutdown::Both); + end +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +/// Result of [`Server::shutdown`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ShutdownReport { + /// Connection threads joined, including those that ended earlier. + pub sessions_joined: usize, + /// Connection threads that panicked. + pub sessions_panicked: usize, +} + +/// A bounded Stratum V1 listener. Dropping it cancels and joins like +/// [`Self::shutdown`]. +pub struct Server { + local_addr: SocketAddr, + shared: Arc, + accept: Option>)>>, + pending: Vec>, +} + +fn refuse(shared: &Shared, stream: &TcpStream, peer: SocketAddr, reason: RefusalReason) { + let _ = stream.shutdown(Shutdown::Both); + shared.emit(&ServerEvent::Refused { peer, reason }); +} + +fn admit(shared: &Arc, stream: TcpStream, peer: &SocketAddr, ordinal: u64) { + let peer_value = *peer; + let config = &shared.config; + let permit = match shared.limiter.try_admit(peer_value.ip()) { + Ok(permit) => permit, + Err(reason) => { + refuse(shared, &stream, peer_value, reason); + return; + } + }; + let slot = ConnectionSlot { _permit: permit }; + let thread_shared = Arc::clone(shared); + let spawned = thread::Builder::new() + .name(format!("sv1-conn-{ordinal}")) + .stack_size(config.thread_stack_bytes) + .spawn(move || { + let _slot = slot; + let end = run_connection(stream, peer_value, ordinal, &thread_shared); + thread_shared.emit(&ServerEvent::SessionEnded { + peer: peer_value, + end, + }); + }); + match spawned { + Ok(handle) => locked(&shared.children).push(handle), + // The closure, with its slot and stream, was dropped: the connection + // is closed and its slot released. + Err(_) => shared.emit(&ServerEvent::Refused { + peer: peer_value, + reason: RefusalReason::SpawnFailed, + }), + } +} + +fn reap(handles: &mut Vec>, report: &mut ShutdownReport) { + let mut index = 0; + while index < handles.len() { + if handles[index].is_finished() { + let handle = handles.swap_remove(index); + if handle.join().is_ok() { + report.sessions_joined += 1; + } else { + report.sessions_panicked += 1; + } + } else { + index += 1; + } + } +} + +fn take_children(shared: &Shared) -> Vec> { + std::mem::take(&mut *locked(&shared.children)) +} + +fn accept_loop( + listener: &TcpListener, + shared: &Arc, +) -> (ShutdownReport, Vec>) { + let mut report = ShutdownReport::default(); + let mut ordinal = 0_u64; + while !shared.cancel.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, peer)) => { + ordinal += 1; + admit(shared, stream, &peer, ordinal); + } + Err(error) => match error.kind() { + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => { + thread::sleep(shared.config.accept_poll); + } + // Resource exhaustion or a reset handshake: back off, retry. + _ => thread::sleep(shared.config.accept_poll * 10), + }, + } + let mut children = locked(&shared.children); + reap(&mut children, &mut report); + } + let children = take_children(shared); + (report, children) +} + +impl Server { + /// Binds `addr` and starts accepting. Connection threads exit on the next + /// poll tick after [`Self::shutdown`]. + pub fn bind( + addr: A, + config: ServerConfig, + deps: ServerDeps, + ) -> io::Result { + config + .validate() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let limiter = Arc::new( + ConnectionLimiter::new(config.max_connections, config.max_connections_per_ip) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?, + ); + Self::bind_with_limiter(addr, config, deps, limiter) + } + + /// Binds a server using an admission limiter shared with other protocols. + pub fn bind_with_limiter( + addr: A, + config: ServerConfig, + deps: ServerDeps, + limiter: Arc, + ) -> io::Result { + config + .validate() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let listener = TcpListener::bind(addr)?; + listener.set_nonblocking(true)?; + let local_addr = listener.local_addr()?; + let shared = Arc::new(Shared { + config, + pool: deps.pool, + accounts: deps.accounts, + observer: deps.observer, + cancel: AtomicBool::new(false), + limiter, + children: Mutex::new(Vec::new()), + }); + let accept_shared = Arc::clone(&shared); + let accept = thread::Builder::new() + .name(String::from("sv1-accept")) + .spawn(move || accept_loop(&listener, &accept_shared))?; + Ok(Self { + local_addr, + shared, + accept: Some(accept), + pending: Vec::new(), + }) + } + + /// The bound address (useful with port 0). + #[must_use] + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + /// Connections currently admitted across every protocol sharing the limiter. + #[must_use] + pub fn active_sessions(&self) -> usize { + self.shared.limiter.active() + } + + /// Stops accepting, tells every connection to end, and joins every thread. + pub fn shutdown(mut self) -> ShutdownReport { + self.stop() + } + + /// Requests shutdown and joins the accept thread until `deadline`. + /// + /// On timeout, the unfinished accept handle remains owned by this server; + /// it is never detached. Dropping the server retains final ownership and + /// performs the blocking join. + pub fn shutdown_until(&mut self, deadline: Instant) -> io::Result { + self.shared.cancel.store(true, Ordering::Release); + let Some(accept) = self.accept.take() else { + return self.join_pending_until(deadline, ShutdownReport::default()); + }; + + loop { + if accept.is_finished() { + let Ok((report, children)) = accept.join() else { + self.pending.extend(take_children(&self.shared)); + return Err(io::Error::other( + "stratum accept thread panicked during shutdown", + )); + }; + self.pending.extend(children); + return self.join_pending_until(deadline, report); + } + let now = Instant::now(); + if now >= deadline { + self.accept = Some(accept); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "stratum server shutdown deadline expired", + )); + } + thread::sleep((deadline - now).min(Duration::from_millis(5))); + } + } + + fn join_pending_until( + &mut self, + deadline: Instant, + mut report: ShutdownReport, + ) -> io::Result { + loop { + let mut remaining = Vec::with_capacity(self.pending.len()); + for handle in self.pending.drain(..) { + if handle.is_finished() { + if handle.join().is_ok() { + report.sessions_joined += 1; + } else { + report.sessions_panicked += 1; + } + } else { + remaining.push(handle); + } + } + self.pending = remaining; + if self.pending.is_empty() { + return Ok(report); + } + let now = Instant::now(); + if now >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "stratum server shutdown deadline expired", + )); + } + thread::sleep((deadline - now).min(Duration::from_millis(5))); + } + } + + fn stop(&mut self) -> ShutdownReport { + self.shared.cancel.store(true, Ordering::Release); + let mut report = match self.accept.take() { + Some(handle) => { + if let Ok((report, children)) = handle.join() { + self.pending.extend(children); + report + } else { + self.pending.extend(take_children(&self.shared)); + ShutdownReport { + sessions_panicked: 1, + ..ShutdownReport::default() + } + } + } + None => ShutdownReport::default(), + }; + for handle in self.pending.drain(..) { + if handle.join().is_ok() { + report.sessions_joined += 1; + } else { + report.sessions_panicked += 1; + } + } + report + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.stop(); + } +} diff --git a/crates/stratum/tests/jobs.rs b/crates/stratum/tests/jobs.rs new file mode 100644 index 0000000000..b42a0a9ea4 --- /dev/null +++ b/crates/stratum/tests/jobs.rs @@ -0,0 +1,1246 @@ +//! Job projection and share predicate tests over real regtest candidates. +//! +//! Candidates come from the node's own `assemble_candidate` over a real +//! mempool filled through the public `MempoolGateway::submit_transaction` +//! path (script verification and policy included, no test seam); blocks are +//! checked by the consensus crate and by the same `compact_is_met_by` the +//! chain crate uses. Only the node boundary (`MiningControl`) is replaced, by +//! a recorder that implements no job logic. + +use std::collections::HashMap; +use std::error::Error; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use bitcoin_rs_chain::compact_is_met_by; +use bitcoin_rs_consensus::ValidationEngine; +use bitcoin_rs_consensus::bip34::check_bip34; +use bitcoin_rs_consensus::verify_block::verify_block_rules; +use bitcoin_rs_mempool::{ + AdmissionChain, AdmissionOrigin, ChainAdmissionSnapshot, Mempool, MempoolGateway, + MempoolLimits, SubmitOutcome, +}; +use bitcoin_rs_mining::{ + BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, + BlockValidationResult, Candidate, CandidateContext, CoinbaseBudget, GenerateRequest, + GeneratedBlock, MiningControl, MiningControlError, MiningError, MiningInfo, assemble_candidate, +}; +use bitcoin_rs_primitives::{ + Amount, BlockHash, BlockPayload, CompactTarget, Hash256, Header, LockTime, Network, OutPoint, + Script, Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::consensus_bytes, +}; +use bitcoin_rs_script::VerifyFlags; +use bitcoin_rs_stratum::{ + BIP320_VERSION_MASK, BlockStatus, Extranonce, ExtranonceLayout, Job, JobBook, JobBookConfig, + JobError, JobId, Session, SessionId, ShareClass, ShareError, ShareOutcome, ShareSubmission, + ShareTarget, ShareWork, StaleReason, fetch_template, +}; +use compact_str::CompactString; +use parking_lot::RwLock; +use sha2::{Digest, Sha256}; + +type TestResult = Result<(), Box>; + +const PAYOUT: &[u8] = &[0x51]; +static ROLLED: [u8; 4] = [7, 7, 7, 7]; + +fn context(height: u32, parent: u8) -> CandidateContext { + CandidateContext { + previous_block_hash: Hash256::from_le_bytes(&[parent; 32]), + height, + version: 0x2000_0000, + bits: CompactTarget::from_consensus(0x207f_ffff), + min_time: 1_700_000_001, + current_time: 1_700_000_600, + locktime_cutoff: 1_700_000_000, + staged_parent_mtp: 1_700_000_000, + network: Network::Regtest, + csv_active: true, + segwit_active: true, + max_weight: 4_000_000, + max_size: 4_000_000, + max_sigops: 80_000, + } +} + +const COIN_VALUE_SAT: u64 = 1_000_000; +const ADMISSION_TIME: u64 = 1_700_000_000; + +/// `OP_0 `: spent by the witness stack `[OP_TRUE]`, so the +/// spending transaction carries witness data and its wtxid differs from its txid. +fn wsh_true_script() -> Vec { + let program: [u8; 32] = Sha256::digest([0x51_u8]).into(); + let mut script = vec![0x00, 0x20]; + script.extend_from_slice(&program); + script +} + +fn p2wpkh_script(label: u8) -> Vec { + let mut script = vec![0x00, 0x14]; + script.extend_from_slice(&[label; 20]); + script +} + +fn coin_outpoint(label: u8) -> OutPoint { + let mut bytes = [0xc0_u8; 32]; + bytes[0] = label; + OutPoint::new(Txid(Hash256::from_le_bytes(&bytes)), 0) +} + +/// One confirmed `P2WSH(OP_TRUE)` coin spent by one 1-in/1-out `P2WPKH` +/// transaction at `2 + label` `sat/vB`, so fee-rate order follows `label`. +fn spend(label: u8) -> Tx { + let mut tx = Tx { + version: 2, + inputs: vec![TxIn { + previous_output: coin_outpoint(label), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::from_stack(vec![vec![0x51]]), + }], + outputs: vec![TxOut { + value: Amount::ZERO, + script_pubkey: Script::from_bytes(p2wpkh_script(label)), + }], + lock_time: LockTime::ZERO, + }; + let fee = tx.vsize() * (2 + u64::from(label)); + tx.outputs[0].value = Amount::from_sat(COIN_VALUE_SAT - fee); + tx +} + +/// A fixed confirmed coin set at a fixed tip, in the shape the node's +/// chain adapter answers admission with. +struct Coins(HashMap); + +impl AdmissionChain for Coins { + fn snapshot(&self, tx: &Tx) -> Option { + let prevouts = tx + .inputs + .iter() + .filter_map(|input| { + self.0 + .get(&input.previous_output) + .map(|output| (input.previous_output, output.clone())) + }) + .collect(); + Some(ChainAdmissionSnapshot { + prevouts, + height: 1, + locktime_cutoff: 0, + csv_active: false, + next_block_script_flags: VerifyFlags::STANDARD, + confirmed: false, + ..ChainAdmissionSnapshot::default() + }) + } +} + +/// A real gateway holding `count` admitted transactions. +fn admitted_gateway(count: u8) -> Result> { + let gateway = MempoolGateway::new( + Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))), + None, + ValidationEngine::Native, + None, + ); + let script = wsh_true_script(); + let coins = Coins( + (1..=count) + .map(|label| { + ( + coin_outpoint(label), + TxOut { + value: Amount::from_sat(COIN_VALUE_SAT), + script_pubkey: Script::from_bytes(script.clone()), + }, + ) + }) + .collect(), + ); + for label in 1..=count { + let outcome = gateway.submit_transaction( + Arc::new(spend(label)), + AdmissionOrigin::Rpc, + None, + ADMISSION_TIME, + &coins, + )?; + if !matches!(outcome, SubmitOutcome::Committed(_)) { + return Err( + format!("fixture transaction {label} was not committed: {outcome:?}").into(), + ); + } + } + Ok(gateway) +} + +fn assemble(context: &CandidateContext, txs: u8) -> Result> { + let gateway = admitted_gateway(txs)?; + let pool = gateway.read(); + Ok(assemble_candidate(context, &pool, PAYOUT)?) +} + +fn build_candidate(height: u32, parent: u8, txs: u8) -> Result, Box> { + let candidate = assemble(&context(height, parent), txs)?; + assert_eq!(candidate.transactions.len(), usize::from(txs)); + assert!( + candidate + .transactions + .iter() + .all(|entry| Hash256::from(entry.txid) != Hash256::from(entry.wtxid)), + "fixture transactions carry witness data" + ); + Ok(Arc::new(candidate)) +} + +fn budget() -> CoinbaseBudget { + CoinbaseBudget { + additional_size: 200, + additional_sigops: 100, + } +} + +fn config() -> JobBookConfig { + JobBookConfig { + budget: budget(), + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/pool/".to_vec(), + version_mask: BIP320_VERSION_MASK, + ntime_forward_secs: 600, + max_jobs: 8, + max_sessions: 16, + max_shares_per_job: 64, + } +} + +struct Fixture { + book: JobBook, + session: Session, + job: Arc, + candidate: Arc, +} + +fn fixture(config: JobBookConfig, height: u32, txs: u8) -> Result> { + let candidate = build_candidate(height, 0xab, txs)?; + let mut book = JobBook::new(config)?; + let session = book.open_session()?; + let installed = book.install(Arc::clone(&candidate), Vec::new())?; + Ok(Fixture { + book, + session, + job: installed.job, + candidate, + }) +} + +fn submit_for(session: &Session, job: &Job, nonce: u32) -> ShareSubmission<'static> { + ShareSubmission { + session: session.id(), + job: job.id(), + rolled_extranonce: &ROLLED, + ntime: job.ntime(), + nonce, + version: job.version(), + } +} + +fn nonces( + session: &Session, + job: &Job, + want_block: bool, + count: usize, +) -> Result, Box> { + let extranonce = Extranonce { + session: session.extranonce_prefix(), + rolled: &ROLLED, + }; + let mut found = Vec::new(); + for nonce in 0..=u32::MAX { + let header = job.header(extranonce, job.ntime(), job.version(), nonce)?; + let hash = Hash256::from(header.compute_hash()); + if compact_is_met_by(job.bits(), hash) == want_block { + found.push(nonce); + if found.len() == count { + return Ok(found); + } + } + } + Err("nonce space exhausted".into()) +} + +fn header_hash(session: &Session, job: &Job, nonce: u32) -> Result> { + let extranonce = Extranonce { + session: session.extranonce_prefix(), + rolled: &ROLLED, + }; + let header = job.header(extranonce, job.ntime(), job.version(), nonce)?; + Ok(Hash256::from(header.compute_hash())) +} + +fn easy() -> Result { + ShareTarget::from_le_bytes([0xff; 32]) +} + +/// Little-endian bytes of the target value 1: no real hash meets it. +fn tiny_target_bytes() -> [u8; 32] { + let mut value = [0_u8; 32]; + value[0] = 1; + value +} + +fn minus_one(mut little_endian: [u8; 32]) -> [u8; 32] { + for byte in &mut little_endian { + if *byte == 0 { + *byte = 0xff; + } else { + *byte -= 1; + break; + } + } + little_endian +} + +#[test] +fn branch_root_equals_node_merkle_root_for_every_tree_shape() -> TestResult { + let mut plain = config(); + plain.extranonce = ExtranonceLayout::new(0, 0); + plain.coinbase_tag = Vec::new(); + plain.max_sessions = 1; + for txs in [0_u8, 1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17] { + let candidate = build_candidate(100, 0xab, txs)?; + let mut book = JobBook::new(plain.clone())?; + let installed = book.install(Arc::clone(&candidate), Vec::new())?; + let expected = candidate.into_unsolved_block()?.header.merkle_root; + let empty = Extranonce { + session: &[], + rolled: &[], + }; + assert_eq!( + installed.job.merkle_root(empty)?, + expected, + "{txs} transactions" + ); + let leaves = usize::from(txs) + 1; + let depth = usize::try_from((leaves - 1).bit_width())?; + assert_eq!( + installed.job.merkle_branch().len(), + depth, + "{txs} transactions" + ); + } + Ok(()) +} + +#[test] +fn coinbase_split_reproduces_the_txid_serialization() -> TestResult { + let candidate = build_candidate(800_000, 0xab, 3)?; + let mut book = JobBook::new(config())?; + let session = book.open_session()?; + let installed = book.install(Arc::clone(&candidate), Vec::new())?; + let job = &installed.job; + let rolled = [1_u8, 2, 3, 4]; + let extranonce = Extranonce { + session: session.extranonce_prefix(), + rolled: &rolled, + }; + + let base = candidate.coinbase.inputs[0].script_sig.as_bytes().to_vec(); + let mut script = base.clone(); + script.extend_from_slice(b"/pool/"); + script.extend_from_slice(session.extranonce_prefix()); + script.extend_from_slice(&rolled); + let mut expected = candidate.coinbase.clone(); + expected.inputs[0].script_sig = Script::from_bytes(script); + check_bip34(800_000, expected.inputs[0].script_sig.as_bytes())?; + + assert_eq!(job.coinbase_txid(extranonce)?, expected.txid()); + assert_eq!(job.coinbase_prefix().len(), 42 + base.len() + 6); + + let mut stripped = expected.clone(); + stripped.inputs[0].witness = Witness::new(); + let mut joined = job.coinbase_prefix().to_vec(); + joined.extend_from_slice(session.extranonce_prefix()); + joined.extend_from_slice(&rolled); + joined.extend_from_slice(job.coinbase_suffix()); + assert_eq!(joined, consensus_bytes(&stripped)); + + let other = [9_u8, 9, 9, 9]; + assert_ne!( + job.coinbase_txid(extranonce)?, + job.coinbase_txid(Extranonce { + session: session.extranonce_prefix(), + rolled: &other, + })? + ); + Ok(()) +} + +#[test] +fn block_candidate_passes_consensus_and_reproduces_the_share() -> TestResult { + let mut fx = fixture(config(), 100, 3)?; + let nonce = nonces(&fx.session, &fx.job, true, 1)?[0]; + let submission = submit_for(&fx.session, &fx.job, nonce); + let target = ShareTarget::from_pool_difficulty(1)?; + + let ShareOutcome::Block(found) = fx.book.validate(&submission, &target)? else { + return Err("a network-target hash must classify as a block".into()); + }; + assert_eq!(found.share.class, ShareClass::Block); + verify_block_rules(&found.block)?; + assert_eq!(found.raw, consensus_bytes(&found.block)); + assert_eq!(found.block.block_hash(), found.share.hash); + assert!(compact_is_met_by( + fx.candidate.bits, + Hash256::from(found.share.hash) + )); + assert_eq!(found.block.txs.len(), 4); + assert_eq!(found.coinbase_txid, found.block.txs[0].txid()); + assert_eq!(found.coinbase_value, fx.candidate.coinbase_value); + let paid: u64 = found.block.txs[0] + .outputs + .iter() + .map(|output| output.value.to_sat()) + .sum(); + assert_eq!(paid, fx.candidate.coinbase_value); + assert!(found.block.weight() <= fx.candidate.max_weight); + assert_eq!(found.share.work, target.work()); + assert_eq!(found.share.height, 100); + assert_eq!(found.share.job, fx.job.id()); + + let mut extranonce = fx.session.extranonce_prefix().to_vec(); + extranonce.extend_from_slice(&ROLLED); + assert_eq!(found.share.extranonce, extranonce); + let script_sig = found.block.txs[0].inputs[0].script_sig.as_bytes(); + assert_eq!( + &script_sig[2..], + [b"/pool/".as_slice(), extranonce.as_slice()] + .concat() + .as_slice() + ); + + let replay = fx.book.validate(&submission, &target); + assert!( + matches!(replay, Err(ShareError::Duplicate { first_job, .. }) if first_job == fx.job.id()) + ); + Ok(()) +} + +#[test] +fn scriptsig_may_grow_to_exactly_the_consensus_limit() -> TestResult { + let mut wide = config(); + wide.extranonce = ExtranonceLayout::new(1, 95); + wide.coinbase_tag = Vec::new(); + wide.max_sessions = 4; + let candidate = build_candidate(800_000, 0xab, 1)?; + let mut book = JobBook::new(wide.clone())?; + let session = book.open_session()?; + let installed = book.install(Arc::clone(&candidate), Vec::new())?; + let rolled = [0x00_u8; 95]; + let extranonce = Extranonce { + session: session.extranonce_prefix(), + rolled: &rolled, + }; + let mut nonce = 0_u32; + loop { + let header = installed.job.header( + extranonce, + installed.job.ntime(), + installed.job.version(), + nonce, + )?; + if compact_is_met_by(installed.job.bits(), Hash256::from(header.compute_hash())) { + break; + } + nonce += 1; + } + let submission = ShareSubmission { + session: session.id(), + job: installed.job.id(), + rolled_extranonce: &rolled, + ntime: installed.job.ntime(), + nonce, + version: installed.job.version(), + }; + let ShareOutcome::Block(found) = book.validate(&submission, &easy()?)? else { + return Err("expected a block".into()); + }; + assert_eq!(found.block.txs[0].inputs[0].script_sig.len(), 100); + verify_block_rules(&found.block)?; + + wide.extranonce = ExtranonceLayout::new(1, 96); + let mut over = JobBook::new(wide)?; + assert_eq!( + over.install(candidate, Vec::new()).err(), + Some(JobError::ExtranonceExceedsScriptSig { + room: 96, + needed: 97 + }) + ); + Ok(()) +} + +#[test] +fn share_classes_and_target_boundaries() -> TestResult { + let probe = fixture(config(), 100, 3)?; + let share_nonce = nonces(&probe.session, &probe.job, false, 1)?[0]; + let block_nonce = nonces(&probe.session, &probe.job, true, 1)?[0]; + let hash = header_hash(&probe.session, &probe.job, share_nonce)?; + + let mut fx = fixture(config(), 100, 3)?; + let outcome = fx + .book + .validate(&submit_for(&fx.session, &fx.job, share_nonce), &easy()?)?; + let ShareOutcome::Share(accepted) = outcome else { + return Err("a hash above the network target is a share".into()); + }; + assert_eq!(accepted.class, ShareClass::Share); + assert_eq!(accepted.hash, BlockHash::from(hash)); + assert_eq!(accepted.work.as_u128(), Some(1)); + assert_eq!(accepted.header.nonce, share_nonce); + + let mut fx = fixture(config(), 100, 3)?; + let at_target = ShareTarget::from_le_bytes(hash.to_le_bytes())?; + let outcome = fx + .book + .validate(&submit_for(&fx.session, &fx.job, share_nonce), &at_target)?; + assert!(matches!(outcome, ShareOutcome::Share(_))); + + let mut fx = fixture(config(), 100, 3)?; + let below = ShareTarget::from_le_bytes(minus_one(hash.to_le_bytes()))?; + let verdict = fx + .book + .validate(&submit_for(&fx.session, &fx.job, share_nonce), &below); + assert!(matches!(verdict, Err(ShareError::BelowTarget { .. }))); + + let mut fx = fixture(config(), 100, 3)?; + let tightest = ShareTarget::from_le_bytes(tiny_target_bytes())?; + let verdict = fx + .book + .validate(&submit_for(&fx.session, &fx.job, block_nonce), &tightest)?; + assert!( + matches!(verdict, ShareOutcome::Block(_)), + "network target takes precedence over the share target" + ); + Ok(()) +} + +fn assert_malformed_session_and_job_inputs( + book: &mut JobBook, + session: &Session, + job: &Arc, + target: &ShareTarget, +) { + let verdict = book.validate( + &ShareSubmission { + session: SessionId::new(999), + ..submit_for(session, job, 0) + }, + target, + ); + assert_eq!( + verdict.err(), + Some(ShareError::UnknownSession(SessionId::new(999))) + ); + for unknown in [JobId::new(0), JobId::new(77)] { + let verdict = book.validate( + &ShareSubmission { + job: unknown, + ..submit_for(session, job, 0) + }, + target, + ); + assert_eq!(verdict.err(), Some(ShareError::UnknownJob(unknown))); + } + + let first = book.validate(&submit_for(session, job, 41), target); + assert!(first.is_ok()); + let second = book.validate(&submit_for(session, job, 41), target); + assert!(matches!( + second, + Err(ShareError::Duplicate { first_job, .. }) if first_job == job.id() + )); + + assert!(book.close_session(session.id())); + let verdict = book.validate(&submit_for(session, job, 42), target); + assert_eq!( + verdict.err(), + Some(ShareError::UnknownSession(session.id())) + ); +} + +#[test] +fn malformed_shares_are_rejected_for_their_own_reason() -> TestResult { + let mut fx = fixture(config(), 100, 2)?; + let target = easy()?; + let job = Arc::clone(&fx.job); + + let short = [1_u8, 2, 3]; + let verdict = fx.book.validate( + &ShareSubmission { + rolled_extranonce: &short, + ..submit_for(&fx.session, &job, 0) + }, + &target, + ); + assert!(matches!( + verdict, + Err(ShareError::ExtranonceLength { + expected: 4, + actual: 3 + }) + )); + + let outside = job.version() ^ 0x0000_0001; + let verdict = fx.book.validate( + &ShareSubmission { + version: outside, + ..submit_for(&fx.session, &job, 0) + }, + &target, + ); + assert_eq!( + verdict.err(), + Some(ShareError::VersionOutsideMask { + version: outside, + job_version: job.version(), + mask: BIP320_VERSION_MASK + }) + ); + + let rolled_version = job.version_from_rolled_bits(0x0000_2000)?; + assert_eq!(rolled_version, job.version() | 0x0000_2000); + assert!( + fx.book + .validate( + &ShareSubmission { + version: rolled_version, + ..submit_for(&fx.session, &job, 0) + }, + &target + ) + .is_ok() + ); + assert!(matches!( + job.version_from_rolled_bits(0x0000_0001), + Err(ShareError::VersionOutsideMask { .. }) + )); + + let verdict = fx.book.validate( + &ShareSubmission { + ntime: job.min_ntime() - 1, + ..submit_for(&fx.session, &job, 0) + }, + &target, + ); + assert_eq!( + verdict.err(), + Some(ShareError::TimeTooEarly { + ntime: job.min_ntime() - 1, + min_ntime: job.min_ntime() + }) + ); + for edge in [job.min_ntime(), job.max_ntime()] { + let verdict = fx.book.validate( + &ShareSubmission { + ntime: edge, + ..submit_for(&fx.session, &job, 0) + }, + &target, + ); + assert!(verdict.is_ok(), "ntime {edge} is inside the window"); + } + let verdict = fx.book.validate( + &ShareSubmission { + ntime: job.max_ntime() + 1, + ..submit_for(&fx.session, &job, 0) + }, + &target, + ); + assert_eq!( + verdict.err(), + Some(ShareError::TimeTooLate { + ntime: job.max_ntime() + 1, + max_ntime: job.max_ntime() + }) + ); + + assert_malformed_session_and_job_inputs(&mut fx.book, &fx.session, &job, &target); + Ok(()) +} + +#[test] +fn rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block() -> TestResult { + let mut tiny = config(); + tiny.max_shares_per_job = 1; + let probe = fixture(tiny.clone(), 100, 1)?; + let shares = nonces(&probe.session, &probe.job, false, 2)?; + let block = nonces(&probe.session, &probe.job, true, 1)?[0]; + + let mut fx = fixture(tiny, 100, 1)?; + let tight = ShareTarget::from_le_bytes(tiny_target_bytes())?; + for _ in 0..4 { + let verdict = fx + .book + .validate(&submit_for(&fx.session, &fx.job, shares[0]), &tight); + assert!(matches!(verdict, Err(ShareError::BelowTarget { .. }))); + } + let target = easy()?; + assert!( + fx.book + .validate(&submit_for(&fx.session, &fx.job, shares[0]), &target) + .is_ok() + ); + let verdict = fx + .book + .validate(&submit_for(&fx.session, &fx.job, shares[1]), &target); + assert_eq!( + verdict.err(), + Some(ShareError::JobShareCapacity { + job: fx.job.id(), + capacity: 1 + }) + ); + let outcome = fx + .book + .validate(&submit_for(&fx.session, &fx.job, block), &target)?; + assert!(matches!(outcome, ShareOutcome::Block(_))); + Ok(()) +} + +#[test] +fn extra_outputs_are_funded_from_the_payout_and_stay_valid() -> TestResult { + let candidate = build_candidate(100, 0xab, 2)?; + let extra = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: Script::from_bytes(vec![0x51, 0x51]), + }]; + let mut book = JobBook::new(config())?; + let session = book.open_session()?; + let installed = book.install(Arc::clone(&candidate), extra.clone())?; + assert_eq!(installed.job.extra_outputs(), extra.as_slice()); + let nonce = nonces(&session, &installed.job, true, 1)?[0]; + let ShareOutcome::Block(found) = book.validate( + &submit_for(&session, &installed.job, nonce), + &ShareTarget::from_pool_difficulty(1)?, + )? + else { + return Err("expected a block".into()); + }; + verify_block_rules(&found.block)?; + let outputs = &found.block.txs[0].outputs; + assert_eq!(outputs.len(), 3); + assert_eq!(outputs[0].value.to_sat(), candidate.coinbase_value - 1_000); + assert_eq!( + outputs[0].script_pubkey, + candidate.coinbase.outputs[0].script_pubkey + ); + assert_eq!(outputs[1], extra[0]); + assert_eq!(outputs[2], candidate.coinbase.outputs[1]); + let paid: u64 = outputs.iter().map(|output| output.value.to_sat()).sum(); + assert_eq!(paid, candidate.coinbase_value); + Ok(()) +} + +#[test] +fn out_of_budget_jobs_are_refused() -> TestResult { + let candidate = build_candidate(100, 0xab, 1)?; + let mut book = JobBook::new(config())?; + + let oversize = vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: Script::from_bytes(vec![0x51; 300]), + }]; + assert_eq!( + book.install(Arc::clone(&candidate), oversize).err(), + Some(JobError::ExtraOutputsSize { + used: 8 + 3 + 300, + budget: 200 + }) + ); + + let sigop_heavy = vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: Script::from_bytes(vec![0xac; 30]), + }]; + assert!(matches!( + book.install(Arc::clone(&candidate), sigop_heavy).err(), + Some(JobError::ExtraOutputsSigops { used, budget: 100 }) if used > 100 + )); + + let overspend = vec![TxOut { + value: Amount::from_sat(candidate.coinbase_value + 1), + script_pubkey: Script::from_bytes(vec![0x51]), + }]; + assert_eq!( + book.install(Arc::clone(&candidate), overspend).err(), + Some(JobError::ExtraOutputsValue { + extra: candidate.coinbase_value + 1, + available: candidate.coinbase_value + }) + ); + + let roomy = build_candidate(100, 0xab, 0)?; + let snug = assemble( + &CandidateContext { + max_weight: roomy.weight, + ..context(100, 0xab) + }, + 0, + )?; + assert_eq!( + book.install(Arc::new(snug), Vec::new()).err(), + Some(JobError::Budget(MiningError::CapacityExhausted { + field: "weight" + })) + ); + + assert_eq!(book.current().map(|job| job.id()), None); + Ok(()) +} + +#[test] +fn sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first() -> TestResult { + let mut small = config(); + small.extranonce = ExtranonceLayout::new(1, 4); + small.max_sessions = 3; + let mut book = JobBook::new(small)?; + let first = book.open_session()?; + let second = book.open_session()?; + let third = book.open_session()?; + assert_eq!(first.extranonce_prefix(), &[0]); + assert_eq!(second.extranonce_prefix(), &[1]); + assert_eq!(third.extranonce_prefix(), &[2]); + assert_eq!( + book.open_session().err(), + Some(JobError::SessionCapacity { capacity: 3 }) + ); + assert!(book.close_session(second.id())); + assert!(!book.close_session(second.id())); + assert!(book.session(second.id()).is_none()); + let reopened = book.open_session()?; + assert_eq!(reopened.extranonce_prefix(), &[1]); + assert_ne!(reopened.id(), second.id()); + assert_eq!(book.session_count(), 3); + + let mut single = config(); + single.extranonce = ExtranonceLayout::new(0, 4); + single.max_sessions = 1; + let mut book = JobBook::new(single.clone())?; + assert_eq!(book.open_session()?.extranonce_prefix(), &[0_u8; 0]); + assert!(book.open_session().is_err()); + + single.max_sessions = 2; + assert!(matches!( + JobBook::new(single).err(), + Some(JobError::InvalidConfig(_)) + )); + Ok(()) +} + +#[test] +fn unusable_configurations_are_refused() { + let broken = [ + JobBookConfig { + max_jobs: 0, + ..config() + }, + JobBookConfig { + max_sessions: 0, + ..config() + }, + JobBookConfig { + max_shares_per_job: 0, + ..config() + }, + JobBookConfig { + version_mask: 0x8000_0000, + ..config() + }, + JobBookConfig { + ntime_forward_secs: 7_201, + ..config() + }, + JobBookConfig { + extranonce: ExtranonceLayout::new(4, 90), + ..config() + }, + ]; + for candidate in broken { + assert!( + matches!( + JobBook::new(candidate).err(), + Some(JobError::InvalidConfig(_)) + ), + "configuration must be refused" + ); + } +} + +#[test] +fn install_reports_exact_parent_and_staleness_facts() -> TestResult { + let mut book = JobBook::new(config())?; + let session = book.open_session()?; + let target = easy()?; + + let first = book.install(build_candidate(100, 1, 0)?, Vec::new())?; + assert!(first.fresh && first.clean_jobs && !first.parent_changed); + assert_eq!(first.invalidated.len(), 0); + + let unchanged = book.install(Arc::clone(first.job.candidate()), Vec::new())?; + assert!(!unchanged.fresh && !unchanged.clean_jobs && !unchanged.parent_changed); + assert!(Arc::ptr_eq(&unchanged.job, &first.job)); + + let refreshed = book.install(build_candidate(100, 1, 1)?, Vec::new())?; + assert!(refreshed.fresh && !refreshed.clean_jobs && !refreshed.parent_changed); + assert_eq!(refreshed.invalidated.len(), 0); + assert!( + book.validate(&submit_for(&session, &first.job, 5), &target) + .is_ok(), + "a same-parent refresh does not stale earlier jobs" + ); + + let extended = book.install(build_candidate(101, 2, 0)?, Vec::new())?; + assert!(extended.clean_jobs && extended.parent_changed); + assert_eq!( + extended.invalidated, + vec![first.job.id(), refreshed.job.id()] + ); + let verdict = book.validate(&submit_for(&session, &refreshed.job, 6), &target); + assert_eq!( + verdict.err(), + Some(ShareError::Stale { + job: refreshed.job.id(), + reason: StaleReason::PreviousBlockChanged + }) + ); + + let competing = book.install(build_candidate(100, 3, 0)?, Vec::new())?; + assert!(competing.clean_jobs && competing.parent_changed); + assert_eq!(competing.invalidated, vec![extended.job.id()]); + + assert_eq!(book.invalidate_active(), vec![competing.job.id()]); + assert!(book.current().is_none()); + let verdict = book.validate(&submit_for(&session, &competing.job, 7), &target); + assert_eq!( + verdict.err(), + Some(ShareError::Stale { + job: competing.job.id(), + reason: StaleReason::Invalidated + }) + ); + + let resumed = book.install(build_candidate(100, 3, 1)?, Vec::new())?; + assert!(resumed.clean_jobs && !resumed.parent_changed); + assert_eq!(resumed.invalidated.len(), 0); + + let back = book.install(build_candidate(101, 1, 0)?, Vec::new())?; + assert!(back.clean_jobs && back.parent_changed); + let verdict = book.validate(&submit_for(&session, &first.job, 8), &target); + assert!( + matches!(verdict, Err(ShareError::Stale { .. })), + "a stale job is never revived when its parent returns" + ); + assert_eq!(book.current().map(|job| job.id()), Some(back.job.id())); + Ok(()) +} + +#[test] +fn retention_is_bounded_and_retired_jobs_are_stale() -> TestResult { + let mut small = config(); + small.max_jobs = 2; + let mut book = JobBook::new(small)?; + let session = book.open_session()?; + let first = book.install(build_candidate(100, 1, 0)?, Vec::new())?; + let second = book.install(build_candidate(100, 1, 1)?, Vec::new())?; + let third = book.install(build_candidate(100, 1, 2)?, Vec::new())?; + assert!(book.job(first.job.id()).is_none()); + assert!(book.job(second.job.id()).is_some()); + assert!(book.job(third.job.id()).is_some()); + let target = easy()?; + assert_eq!( + book.validate(&submit_for(&session, &first.job, 1), &target) + .err(), + Some(ShareError::Stale { + job: first.job.id(), + reason: StaleReason::Retired + }) + ); + assert!( + book.validate(&submit_for(&session, &second.job, 1), &target) + .is_ok() + ); + Ok(()) +} + +#[test] +fn pool_difficulty_targets_and_work_are_exact() -> TestResult { + let one = ShareTarget::from_pool_difficulty(1)?; + let mut expected = [0_u8; 32]; + expected[4] = 0xff; + expected[5] = 0xff; + assert_eq!(one.to_be_bytes(), expected); + let mut little = expected; + little.reverse(); + assert_eq!(one.to_le_bytes(), little); + assert_eq!(ShareTarget::from_be_bytes(expected)?, one); + assert_eq!(one.work().as_u128(), Some(4_295_032_833)); + + let two = ShareTarget::from_pool_difficulty(2)?; + let mut halved = [0_u8; 32]; + halved[4] = 0x7f; + halved[5] = 0xff; + halved[6] = 0x80; + assert_eq!(two.to_be_bytes(), halved); + assert!(two.work() > one.work()); + + let half = ShareTarget::from_pool_difficulty_decimal("0.5")?; + let mut doubled = [0_u8; 32]; + doubled[3] = 0x01; + doubled[4] = 0xff; + doubled[5] = 0xfe; + assert_eq!(half.to_be_bytes(), doubled); + assert_eq!(ShareTarget::from_pool_difficulty_ratio(1, 2)?, half); + assert_eq!(ShareTarget::from_pool_difficulty_ratio(3, 6)?, half); + assert_eq!(ShareTarget::from_pool_difficulty_decimal("2")?, two); + assert_eq!(ShareTarget::from_pool_difficulty_decimal("2.000")?, two); + assert_eq!(ShareTarget::from_pool_difficulty_decimal("2.")?, two); + + let everything = ShareTarget::from_le_bytes([0xff; 32])?; + assert_eq!( + ShareTarget::from_pool_difficulty_ratio(1, u64::MAX)?, + everything + ); + assert_eq!(everything.work().as_u128(), Some(1)); + let total = one + .work() + .checked_add(two.work()) + .and_then(ShareWork::as_u128); + assert_eq!( + total, + one.work() + .as_u128() + .zip(two.work().as_u128()) + .map(|(a, b)| a + b) + ); + + assert_eq!( + ShareTarget::from_le_bytes([0; 32]), + Err(JobError::ZeroTarget) + ); + assert_eq!( + ShareTarget::from_pool_difficulty(0), + Err(JobError::ZeroTarget) + ); + assert_eq!( + ShareTarget::from_pool_difficulty_ratio(1, 0), + Err(JobError::ZeroTarget) + ); + for zero in ["0", "0.0", "000.000"] { + assert_eq!( + ShareTarget::from_pool_difficulty_decimal(zero), + Err(JobError::ZeroTarget), + "{zero}" + ); + } + for malformed in [ + "", + "-1", + "1e3", + ".5", + "1.2.3", + " 1", + "12345678901234567890", + "1,5", + ] { + assert_eq!( + ShareTarget::from_pool_difficulty_decimal(malformed), + Err(JobError::InvalidDifficulty), + "{malformed:?}" + ); + } + Ok(()) +} + +struct RecordingControl { + template: Option, + verdict: Mutex>, + requests: Mutex>, + submitted: Mutex>, +} + +impl RecordingControl { + fn new( + template: Option, + verdict: Result, + ) -> Self { + Self { + template, + verdict: Mutex::new(verdict), + requests: Mutex::new(Vec::new()), + submitted: Mutex::new(Vec::new()), + } + } + + fn set_verdict(&self, verdict: Result) { + *lock(&self.verdict) = verdict; + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn unused() -> MiningControlError { + MiningControlError::Unavailable(CompactString::from("not used by these tests")) +} + +impl MiningControl for RecordingControl { + fn get_block_template( + &self, + request: BlockTemplateRequest, + ) -> Result { + lock(&self.requests).push(request); + match &self.template { + Some(template) => Ok(BlockTemplateResult::Template(template.clone())), + None => Ok(BlockTemplateResult::Proposal( + BlockValidationResult::Accepted, + )), + } + } + + fn mining_info(&self) -> Result { + Err(unused()) + } + + fn network_hash_ps(&self, _lookup: i64, _height: i64) -> Result { + Err(unused()) + } + + fn submit_block( + &self, + payload: BlockPayload, + ) -> Result { + lock(&self.submitted).push(payload); + lock(&self.verdict).clone() + } + + fn submit_header(&self, _header: Header) -> Result<(), MiningControlError> { + Err(unused()) + } + + fn publish_generation(&self) {} + + fn publish_fee_delta_generation(&self, _fee_delta_sequence: u64) {} + + fn generate( + &self, + _request: GenerateRequest, + ) -> Result, MiningControlError> { + Err(unused()) + } +} + +fn template_around(candidate: &Arc) -> BlockTemplate { + BlockTemplate { + candidate: Arc::clone(candidate), + rules: Vec::new(), + version_bits_available: Vec::new(), + version_bits_required: 0, + capabilities: Vec::new(), + mutable: Vec::new(), + submit_old: None, + signet: None, + } +} + +#[test] +fn found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts() -> TestResult { + let mut fx = fixture(config(), 100, 2)?; + let nonce = nonces(&fx.session, &fx.job, true, 1)?[0]; + let ShareOutcome::Block(found) = fx.book.validate( + &submit_for(&fx.session, &fx.job, nonce), + &ShareTarget::from_pool_difficulty(1)?, + )? + else { + return Err("expected a block".into()); + }; + + let control = RecordingControl::new(None, Ok(BlockValidationResult::Accepted)); + let receipt = found.submit(&control)?; + assert_eq!(receipt.status(), BlockStatus::Applied); + assert_eq!(receipt.hash, found.share.hash); + assert_eq!(receipt.height, 100); + assert_eq!(receipt.coinbase_txid, found.coinbase_txid); + assert_eq!(receipt.coinbase_value, fx.candidate.coinbase_value); + assert_eq!(receipt.session, fx.session.id()); + assert_eq!(receipt.job, fx.job.id()); + let submitted = lock(&control.submitted); + assert_eq!(submitted.len(), 1); + assert_eq!(submitted[0].block(), &found.block); + assert_eq!(submitted[0].bytes().as_ref(), found.raw.as_slice()); + drop(submitted); + + let cases = [ + (BlockValidationResult::Duplicate, BlockStatus::Applied), + ( + BlockValidationResult::DuplicateInvalid, + BlockStatus::Rejected, + ), + ( + BlockValidationResult::Rejected(CompactString::from("bad-txnmrklroot")), + BlockStatus::Rejected, + ), + ( + BlockValidationResult::Inconclusive, + BlockStatus::Undetermined, + ), + ( + BlockValidationResult::DuplicateInconclusive, + BlockStatus::Undetermined, + ), + ]; + for (verdict, status) in cases { + control.set_verdict(Ok(verdict.clone())); + let receipt = found.submit(&control)?; + assert_eq!(receipt.result, verdict); + assert_eq!(receipt.status(), status); + } + + control.set_verdict(Err(unused())); + assert_eq!(found.submit(&control).err(), Some(unused())); + control.set_verdict(Ok(BlockValidationResult::Accepted)); + assert_eq!(found.submit(&control)?.status(), BlockStatus::Applied); + Ok(()) +} + +#[test] +fn templates_are_requested_with_the_declared_budget() -> TestResult { + let candidate = build_candidate(100, 0xab, 1)?; + let control = RecordingControl::new( + Some(template_around(&candidate)), + Ok(BlockValidationResult::Accepted), + ); + let template = fetch_template(&control, budget(), Some("previous-generation"))?; + assert!(Arc::ptr_eq(&template.candidate, &candidate)); + { + let requests = lock(&control.requests); + assert_eq!(requests.len(), 1); + assert!(matches!(requests[0].mode, BlockTemplateMode::Template)); + assert_eq!(requests[0].coinbase_budget, Some(budget())); + assert_eq!( + requests[0].long_poll_id.as_deref(), + Some("previous-generation") + ); + } + + let mut book = JobBook::new(config())?; + let installed = book.install(Arc::clone(&template.candidate), Vec::new())?; + assert!(installed.fresh && installed.clean_jobs); + + let proposal_only = RecordingControl::new(None, Ok(BlockValidationResult::Accepted)); + assert!(matches!( + fetch_template(&proposal_only, budget(), None), + Err(MiningControlError::Failed(_)) + )); + Ok(()) +} diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs new file mode 100644 index 0000000000..ddf6fbfa7b --- /dev/null +++ b/crates/stratum/tests/protocol.rs @@ -0,0 +1,785 @@ +//! Integration and wire transcript tests for the native Stratum V1 pool adapter. +//! +//! Checks: +//! * Framing: line limits, chunk boundaries, trailing `\r\n`, slowloris bounds. +//! * Prevhash: word-swapping against immutable test vectors. +//! * Handshake: BIP310 `mining.configure`, `mining.subscribe`, `mining.authorize`. +//! * Work notification: exact `mining.notify` wire text from the common owner. +//! * Difficulty: `mining.set_difficulty`, decimal format, `mining.suggest_difficulty` +//! bounds and retarget grace. +//! * Share submissions: valid share accepted, low difficulty rejected, duplicate +//! rejected, stale share rejected, extranonce length error, version-bits mask error. +//! * Ledger boundary: exactly-once credit, transactional outbox under ledger unavailability. +//! * Connection bounds: global connection cap, per-IP connection cap, shutdown join. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::Duration; + +use bitcoin_rs_mining::{ + Candidate, CoinbaseBudget, FakeMiningControl, MiningControl, MiningControlError, TemplateId, +}; +use bitcoin_rs_primitives::{ + Amount, CompactTarget, Hash256, LockTime, OutPoint, Script, Sequence, Tx, TxIn, TxOut, Witness, +}; +use bitcoin_rs_stratum::{ + AcceptedShare, ConnectionLimiter, DifficultyPolicy, ExtranonceLayout, FoundBlock, FrameError, + JobBookConfig, LineBuffer, Observer, PoolCore, RecordOutcome, RefusalReason, Server, + ServerConfig, ServerDeps, ServerEvent, ShareLedger, StaticAccountResolver, prevhash_from_wire, + prevhash_to_wire, swap_words, +}; + +type TestResult = Result<(), Box>; + +// --------------------------------------------------------------------------- +// Test doubles and fixtures +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct TestLedger { + shares: Mutex>, + blocks: Mutex>, + fail_shares: Mutex>, + share_calls: AtomicUsize, +} + +impl TestLedger { + /// Stops share-record failures; later calls succeed. + fn disarm(&self) { + *self + .fail_shares + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")) = None; + } + + fn armed_with_failures(count: usize) -> Self { + Self { + shares: Mutex::new(Vec::new()), + blocks: Mutex::new(Vec::new()), + fail_shares: Mutex::new(Some(count)), + share_calls: AtomicUsize::new(0), + } + } + + fn recorded_shares(&self) -> Vec { + self.shares + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")) + .iter() + .map(|(_, s)| s.clone()) + .collect() + } +} + +impl ShareLedger for TestLedger { + fn record_share( + &self, + worker: &bitcoin_rs_stratum::WorkerIdentity, + share: &AcceptedShare, + ) -> Result { + self.share_calls.fetch_add(1, Ordering::SeqCst); + let mut fail = self + .fail_shares + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + if let Some(remaining) = fail.as_mut() + && *remaining > 0 + { + *remaining -= 1; + return Err(bitcoin_rs_stratum::LedgerUnavailable); + } + let mut shares = self + .shares + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + if shares.iter().any(|(_, s)| s.hash == share.hash) { + Ok(RecordOutcome::AlreadyRecorded) + } else { + shares.push(( + format!("{}.{}", worker.account, worker.worker), + share.clone(), + )); + Ok(RecordOutcome::Recorded) + } + } + + fn record_block( + &self, + worker: &bitcoin_rs_stratum::WorkerIdentity, + block: &FoundBlock, + _submission: &Result, + ) -> Result<(), bitcoin_rs_stratum::LedgerUnavailable> { + self.blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")) + .push(( + format!("{}.{}", worker.account, worker.worker), + block.clone(), + )); + Ok(()) + } +} + +fn sample_candidate(prev: Hash256, bits: CompactTarget, height: u32) -> Candidate { + let script_sig_bytes = vec![ + 0x03, + u8::try_from(height).unwrap_or_else(|_| panic!("test height must fit BIP34 byte")), + 0x00, + 0x00, + ]; // BIP34 height prefix + Candidate { + template_id: TemplateId::new(&prev, 1), + previous_block_hash: prev, + height, + version: 0x2000_0000, + bits, + min_time: 1_700_000_000, + current_time: 1_700_000_000, + csv_active: true, + segwit_active: true, + max_weight: 4_000_000, + max_size: 4_000_000, + max_sigops: 80_000, + mempool_sequence: 1, + coinbase: Tx { + version: 1, + lock_time: LockTime::ZERO, + inputs: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: Script::from_bytes(script_sig_bytes), + sequence: Sequence::ZERO, + witness: Witness::default(), + }], + outputs: vec![TxOut { + value: Amount::from_sat(50_0000_0000), + script_pubkey: Script::new(), + }], + }, + coinbase_value: 50_0000_0000, + weight: 1000, + transactions: Vec::new(), + witness_commitment: None, + } +} + +fn build_pool(control: Arc, ledger: Arc) -> Arc { + let config = JobBookConfig { + budget: CoinbaseBudget { + additional_size: 100, + additional_sigops: 10, + }, + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/test-pool/".to_vec(), + version_mask: 0x1fff_e000, + ntime_forward_secs: 7200, + max_jobs: 16, + max_sessions: 64, + max_shares_per_job: 1024, + }; + Arc::new( + PoolCore::new(config, control, ledger) + .unwrap_or_else(|error| panic!("test operation failed: {error}")), + ) +} + +struct TestClient { + stream: TcpStream, + reader: BufReader, +} + +impl TestClient { + fn connect(addr: SocketAddr) -> Self { + let stream = TcpStream::connect(addr) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let reader = BufReader::new( + stream + .try_clone() + .unwrap_or_else(|error| panic!("test operation failed: {error}")), + ); + Self { stream, reader } + } + + fn send(&mut self, text: &str) { + self.stream + .write_all(text.as_bytes()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + } + + fn read_line(&mut self) -> String { + let mut line = String::new(); + self.reader + .read_line(&mut line) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + line + } + + fn read_available(reader: &mut BufReader, into: &mut String) -> bool { + let mut byte = [0_u8; 1]; + match reader.read(&mut byte) { + Ok(0) => false, + Ok(_) => { + into.push(char::from(byte[0])); + let _ = reader.read_line(into); + true + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + false + } + Err(error) => panic!("test operation failed: {error}"), + } + } + + fn expect_eof(&mut self) { + let mut buf = [0_u8; 1]; + let n = self + .reader + .read(&mut buf) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert_eq!(n, 0, "expected connection to be closed"); + } +} + +// --------------------------------------------------------------------------- +// Unit tests: shared admission +// --------------------------------------------------------------------------- + +#[test] +fn shared_connection_limiter_enforces_total_and_per_ip_caps() -> TestResult { + let total_limiter = Arc::new(ConnectionLimiter::new(1, 1)?); + let first = total_limiter + .try_admit(IpAddr::V4(Ipv4Addr::LOCALHOST)) + .map_err(|reason| { + std::io::Error::other(format!("unexpected total admission refusal: {reason:?}")) + })?; + assert_eq!(total_limiter.active(), 1); + assert!(matches!( + total_limiter.try_admit(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))), + Err(RefusalReason::ConnectionLimit) + )); + drop(first); + assert_eq!(total_limiter.active(), 0); + let second = total_limiter + .try_admit(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))) + .map_err(|reason| { + std::io::Error::other(format!("unexpected retry admission refusal: {reason:?}")) + })?; + assert_eq!(total_limiter.active(), 1); + drop(second); + assert_eq!(total_limiter.active(), 0); + + let per_ip_limiter = Arc::new(ConnectionLimiter::new(3, 1)?); + let first = per_ip_limiter + .try_admit(IpAddr::V4(Ipv4Addr::LOCALHOST)) + .map_err(|reason| { + std::io::Error::other(format!("unexpected per-IP admission refusal: {reason:?}")) + })?; + assert!(matches!( + per_ip_limiter.try_admit(IpAddr::V4(Ipv4Addr::LOCALHOST)), + Err(RefusalReason::PerIpLimit) + )); + let second = per_ip_limiter + .try_admit(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))) + .map_err(|reason| { + std::io::Error::other(format!( + "unexpected second-IP admission refusal: {reason:?}" + )) + })?; + assert_eq!(per_ip_limiter.active(), 2); + drop(first); + drop(second); + assert_eq!(per_ip_limiter.active(), 0); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Unit tests: prevhash and framing +// --------------------------------------------------------------------------- + +#[test] +fn prevhash_vector_round_trips_exact_byte_swap() { + // Canonical SV1 prevhash byte-order vector: + // Wire: "4d16b6f85af6e2198f44ae2a6de67f78487ae5611b77c6c0440b921e00000000" + // Canonical display: "00000000440b921e1b77c6c0487ae5616de67f788f44ae2a5af6e2194d16b6f8" + let wire_hex = "4d16b6f85af6e2198f44ae2a6de67f78487ae5611b77c6c0440b921e00000000"; + let internal = prevhash_from_wire(wire_hex).unwrap_or_else(|| panic!("valid hex")); + let back_to_wire = prevhash_to_wire(&internal); + assert_eq!(wire_hex, back_to_wire); + + // Verify word swap symmetry: swap(swap(x)) == x + let swapped = swap_words(&internal); + let original = swap_words(&swapped); + assert_eq!(internal, original); +} + +#[test] +fn line_buffer_enforces_bounds_and_crlf() { + let mut buf = LineBuffer::new(64); + + // Incomplete push + buf.push(b"hello") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + assert_eq!( + buf.next_frame() + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + None + ); + + // Complete line with \r\n + buf.push(b" world\r\n") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + assert_eq!( + buf.next_frame() + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + Some(b"hello world".to_vec()) + ); + assert_eq!( + buf.next_frame() + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + None + ); + + // Empty lines skipped + buf.push(b"\r\n\nline\n") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + assert_eq!( + buf.next_frame() + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + Some(b"line".to_vec()) + ); + assert_eq!( + buf.next_frame() + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + None + ); + + // Over-length push exceeding max_line + PUSH_SLACK rejected + let big = vec![b'x'; 64 + 64 * 1024 + 1]; + assert_eq!(buf.push(&big), Err(FrameError::LineTooLong)); +} + +// --------------------------------------------------------------------------- +// Integration tests: Server lifecycle and wire transcripts +// --------------------------------------------------------------------------- + +#[test] +fn full_sv1_handshake_and_share_submit_transcript() { + let prev = Hash256::from_le_bytes(&[0x11; 32]); + let candidate = Arc::new(sample_candidate( + prev, + CompactTarget::from_consensus(0x207f_ffff), + 1, + )); + let fake_control = Arc::new(FakeMiningControl::default()); + let ledger = Arc::new(TestLedger::default()); + let pool = build_pool(fake_control, ledger.clone()); + + // Install candidate job + let installed = pool + .install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let job_id = installed.job.id(); + + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("alice", "secret123") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let diff_policy = DifficultyPolicy::new("0.0000000001", "0.00000000001", "1000") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let config = ServerConfig::new(diff_policy); + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool: pool.clone(), + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + let mut client = TestClient::connect(addr); + + // 1. mining.configure (BIP310 version-rolling) + client.send("{\"id\":1,\"method\":\"mining.configure\",\"params\":[[\"version-rolling\"],{\"version-rolling.mask\":\"1fffe000\",\"version-rolling.min-bit-count\":2}]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"id\":1")); + assert!(resp.contains("\"version-rolling\":true")); + assert!(resp.contains("\"version-rolling.mask\":\"1fffe000\"")); + + // 2. mining.subscribe + client.send("{\"id\":2,\"method\":\"mining.subscribe\",\"params\":[\"miner/1.0.0\"]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"id\":2")); + assert!(resp.contains("\"result\":[[[\"mining.set_difficulty\"")); + assert!(resp.contains("\"error\":null")); + + // 3. mining.authorize + client.send( + "{\"id\":3,\"method\":\"mining.authorize\",\"params\":[\"alice.worker1\",\"secret123\"]}\n", + ); + let resp = client.read_line(); + assert_eq!(resp, "{\"id\":3,\"result\":true,\"error\":null}\n"); + + // After authorize, server should push initial difficulty and the installed job notification + let set_diff = client.read_line(); + assert!(set_diff.contains("\"method\":\"mining.set_difficulty\"")); + assert!(set_diff.contains("0.0000000001")); + + let notify = client.read_line(); + assert!(notify.contains("\"method\":\"mining.notify\"")); + assert!(notify.contains(&format!("{:08x}", job_id.get()))); + + // 4. mining.submit (find a valid nonce for the very low difficulty target) + let en1 = pool.layout().session_len(); + let en2 = pool.layout().rolled_len(); + assert_eq!(en1, 4); + assert_eq!(en2, 4); + + let extranonce2_hex = "00000001"; + let ntime_hex = format!("{:08x}", installed.job.ntime()); + let nonce_hex = "00000000"; + let version_bits_hex = "00002000"; // Within mask 0x1fffe000 + + let submit_cmd = format!( + "{{\"id\":4,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{job_id:08x}\",\"{extranonce2_hex}\",\"{ntime_hex}\",\"{nonce_hex}\",\"{version_bits_hex}\"]}}\n", + job_id = job_id.get() + ); + client.send(&submit_cmd); + let submit_resp = client.read_line(); + assert_eq!(submit_resp, "{\"id\":4,\"result\":true,\"error\":null}\n"); + + // Assert share reached the ledger + let recorded = ledger.recorded_shares(); + assert_eq!(recorded.len(), 1); + assert_eq!(recorded[0].job, job_id); + + // Duplicate submit should be rejected with code 22 + client.send(&submit_cmd); + let dup_resp = client.read_line(); + assert!(dup_resp.contains("\"error\":[22,\"Duplicate share\",null]")); + assert_eq!(ledger.recorded_shares().len(), 1); + + server.shutdown(); +} + +#[test] +fn transactional_outbox_buffers_shares_when_ledger_fails() { + let prev = Hash256::from_le_bytes(&[0x22; 32]); + let candidate = Arc::new(sample_candidate( + prev, + CompactTarget::from_consensus(0x207f_ffff), + 2, + )); + let fake_control = Arc::new(FakeMiningControl::default()); + // A permanently failing ledger is a deterministic barrier: neither the + // three inline attempts nor any background poll drain can record the + // share until the test disarms the ledger. + let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); + let pool = build_pool(fake_control, ledger.clone()); + let installed = pool + .install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let job_id = installed.job.id(); + + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("alice", "secret123") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let diff_policy = DifficultyPolicy::new("0.0000000001", "0.00000000001", "1000") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let config = ServerConfig::new(diff_policy); + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool: pool.clone(), + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + let mut client = TestClient::connect(addr); + client.send("{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}\n"); + let _ = client.read_line(); + client.send( + "{\"id\":2,\"method\":\"mining.authorize\",\"params\":[\"alice.w1\",\"secret123\"]}\n", + ); + let _ = client.read_line(); + let _ = client.read_line(); // diff + let _ = client.read_line(); // notify + + let submit = format!( + "{{\"id\":3,\"method\":\"mining.submit\",\"params\":[\"alice.w1\",\"{job_id:08x}\",\"00000001\",\"{ntime:08x}\",\"00000000\"]}}\n", + job_id = job_id.get(), + ntime = installed.job.ntime(), + ); + client.send(&submit); + // The first two inline attempts fail, so the share is buffered in the + // outbox and the wire response is withheld until the record is durable. + client + .stream + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut early = String::new(); + let blocked = TestClient::read_available(&mut client.reader, &mut early); + assert!(!blocked, "no success may precede the durable record"); + assert!(early.is_empty(), "unexpected early frame: {early:?}"); + + // Disarm the barrier, then drain: the deferred response is emitted by the + // session's next poll. + ledger.disarm(); + pool.drain_outbox(); + client + .stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let resp = client.read_line(); + assert_eq!(resp, "{\"id\":3,\"result\":true,\"error\":null}\n"); + assert_eq!(ledger.recorded_shares().len(), 1); + + // A resubmit after acknowledgement keeps the protocol's duplicate + // rejection and credits nothing further. + client.send(&submit); + let replay = client.read_line(); + assert!(replay.contains("\"error\":[22,\"Duplicate share\",null]")); + assert_eq!(ledger.recorded_shares().len(), 1); + + server.shutdown(); +} + +#[test] +fn protocol_negative_scenarios_rejected_deterministically() { + let prev = Hash256::from_le_bytes(&[0x33; 32]); + let candidate = Arc::new(sample_candidate( + prev, + CompactTarget::from_consensus(0x207f_ffff), + 3, + )); + let pool = build_pool( + Arc::new(FakeMiningControl::default()), + Arc::new(TestLedger::default()), + ); + let installed = pool + .install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let job_id = installed.job.id(); + + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("bob", "pass") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let diff_policy = DifficultyPolicy::new("0.0000000001", "0.00000000001", "1000") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let mut config = ServerConfig::new(diff_policy); + config.max_consecutive_violations = 4; + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool, + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + let mut client = TestClient::connect(addr); + + // Negative 1: Submit before subscribe/auth -> 25 Not subscribed + client.send("{\"id\":1,\"method\":\"mining.submit\",\"params\":[\"bob\",\"00000001\",\"00000001\",\"60000000\",\"00000000\"]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"error\":[25,\"Not subscribed\",null]")); + + // Subscribe + client.send("{\"id\":2,\"method\":\"mining.subscribe\",\"params\":[]}\n"); + let _ = client.read_line(); + + // Negative 2: Submit before authorize -> 24 Unauthorized worker + client.send("{\"id\":3,\"method\":\"mining.submit\",\"params\":[\"bob\",\"00000001\",\"00000001\",\"60000000\",\"00000000\"]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"error\":[24,\"Unauthorized worker\",null]")); + + // Authorize with bad password -> error + client.send("{\"id\":4,\"method\":\"mining.authorize\",\"params\":[\"bob\",\"wrong\"]\n"); + // Also test malformed json syntax directly + let parse_err = client.read_line(); + assert!(parse_err.contains("-32700")); + + // Authorized correctly + client.send("{\"id\":5,\"method\":\"mining.authorize\",\"params\":[\"bob\",\"pass\"]}\n"); + assert_eq!( + client.read_line(), + "{\"id\":5,\"result\":true,\"error\":null}\n" + ); + let _ = client.read_line(); // diff + let _ = client.read_line(); // notify + + // Negative 3: Invalid extranonce2 length (expected 4 bytes = 8 hex chars, send 2 hex chars) + let bad_en2 = format!( + "{{\"id\":6,\"method\":\"mining.submit\",\"params\":[\"bob\",\"{job_id:08x}\",\"01\",\"{ntime:08x}\",\"00000000\"]}}\n", + job_id = job_id.get(), + ntime = installed.job.ntime(), + ); + client.send(&bad_en2); + let en2_err = client.read_line(); + assert!(en2_err.contains("Invalid extranonce2 length") || en2_err.contains("-32602")); + + // Negative 4: Unexpected version bits when version-rolling was not negotiated + let unexp_bits = format!( + "{{\"id\":7,\"method\":\"mining.submit\",\"params\":[\"bob\",\"{job_id:08x}\",\"00000001\",\"{ntime:08x}\",\"00000000\",\"00002000\"]}}\n", + job_id = job_id.get(), + ntime = installed.job.ntime(), + ); + client.send(&unexp_bits); + let bits_err = client.read_line(); + assert!(bits_err.contains("Unexpected version bits")); + + server.shutdown(); +} + +#[test] +fn suggest_difficulty_validates_bounds_honestly() { + let pool = build_pool( + Arc::new(FakeMiningControl::default()), + Arc::new(TestLedger::default()), + ); + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("user", "pass") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + // Allowed range: 1.0 to 100.0 + let diff_policy = DifficultyPolicy::new("10.0", "1.0", "100.0") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let config = ServerConfig::new(diff_policy); + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool, + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + let mut client = TestClient::connect(addr); + + // Suggest within bounds -> true + client.send("{\"id\":1,\"method\":\"mining.suggest_difficulty\",\"params\":[25.5]}\n"); + let resp = client.read_line(); + assert_eq!(resp, "{\"id\":1,\"result\":true,\"error\":null}\n"); + + // Suggest out of bounds (too low) -> rejected with error + client.send("{\"id\":2,\"method\":\"mining.suggest_difficulty\",\"params\":[0.5]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"error\":[20,\"Difficulty out of range\",null]")); + + // Suggest out of bounds (too high) -> rejected with error + client.send("{\"id\":3,\"method\":\"mining.suggest_difficulty\",\"params\":[500.0]}\n"); + let resp = client.read_line(); + assert!(resp.contains("\"error\":[20,\"Difficulty out of range\",null]")); + + server.shutdown(); +} + +// --------------------------------------------------------------------------- +// Connection bounds +// --------------------------------------------------------------------------- + +#[test] +fn connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions() { + type Tighten = fn(&mut ServerConfig); + let cases: [(Tighten, RefusalReason); 2] = [ + ( + |config| config.max_connections = 1, + RefusalReason::ConnectionLimit, + ), + ( + |config| config.max_connections_per_ip = 1, + RefusalReason::PerIpLimit, + ), + ]; + for (tighten, expected) in cases { + let pool = build_pool( + Arc::new(FakeMiningControl::default()), + Arc::new(TestLedger::default()), + ); + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("user", "pass") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let mut config = ServerConfig::new( + DifficultyPolicy::new("10.0", "1.0", "100.0") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + tighten(&mut config); + + // Bounded event queue; an event arriving at a full queue is shed, and + // this test reads every event it waits for before the next connect. + let (events, observed) = mpsc::sync_channel::(16); + let observer: Observer = Arc::new(move |event: &ServerEvent| { + let _ = events.try_send(event.clone()); + }); + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool, + accounts, + observer: Some(observer), + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + // The first peer is served: its slot is held once `Accepted` is seen. + let first = TestClient::connect(addr); + let accepted = observed + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert!(matches!(accepted, ServerEvent::Accepted { .. })); + assert_eq!(server.active_sessions(), 1); + + // The second peer is closed without a session thread. + let mut second = TestClient::connect(addr); + second.expect_eof(); + match observed + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|error| panic!("test operation failed: {error}")) + { + ServerEvent::Refused { reason, .. } => assert_eq!(reason, expected), + other => panic!("expected a refusal, saw {other:?}"), + } + assert_eq!(server.active_sessions(), 1); + + // Shutdown ends the served session and joins its thread; the refused + // peer never had one. + let report = server.shutdown(); + assert_eq!(report.sessions_joined, 1); + assert_eq!(report.sessions_panicked, 0); + drop(first); + } +} diff --git a/deny.toml b/deny.toml index 898bcbba13..5f24a1407d 100644 --- a/deny.toml +++ b/deny.toml @@ -82,12 +82,13 @@ deny = [ ] # Duplicate-version guard (DEP-02 / issue #174). # -# Duplicates are denied graph-wide. `bitcoin`, `bitcoin_hashes`, `secp256k1`, -# and `secp256k1-sys` carry NO skip entry on purpose: the workspace pins one -# copy of each (secp256k1 0.29 aligned with bitcoin 0.32's embedded line), so -# any future duplicate in the consensus stack hard-fails this check. The -# cargo-deny bans check above is the sole uniqueness owner for these four -# crates. +# Duplicates are denied graph-wide. `bitcoin` carries no skip entry on purpose. +# The workspace pins one copy of it, so any future duplicate in the consensus +# stack hard-fails this check. `secp256k1`, `secp256k1-sys`, `bitcoin_hashes`, +# and the RustCrypto transport crates have one narrowly-scoped exception below +# for the audited upstream `noise_sv2 2.0.0` SV2 chain; every other duplicate +# remains denied. The cargo-deny bans check above is the uniqueness owner for +# these crypto crates. # # Everything below is an audited, third-party-only split with the driver of # each skipped copy named. One current version remains unskipped as the @@ -103,6 +104,23 @@ skip = [ { crate = "rand@0.8.8", reason = "secp256k1 0.29 rand-std" }, { crate = "rand_chacha@0.3.1", reason = "rand 0.8 chain" }, { crate = "rand_core@0.6.4", reason = "rand 0.8 chain" }, + # SV2's audited upstream Noise_NX chain (noise_sv2 2.0.0) carries the + # older RustCrypto/secp256k1 line. These skips are intentionally scoped to + # exact versions and this dependency family; duplicate-version denial + # remains active for every other graph path. + { crate = "aead@0.5.2", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "bitcoin_hashes@0.13.1", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "hex-conservative@0.1.2", reason = "bitcoin_hashes 0.13.1 via noise_sv2 2.0.0" }, + { crate = "chacha20@0.9.1", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "chacha20poly1305@0.10.1", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "cipher@0.4.4", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "cpufeatures@0.2.17", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "crypto-common@0.1.7", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "inout@0.1.4", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "poly1305@0.8.0", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, + { crate = "secp256k1@0.28.2", reason = "noise_sv2 2.0.0 upstream SV2 authority-key chain" }, + { crate = "secp256k1-sys@0.9.2", reason = "noise_sv2 2.0.0 upstream SV2 authority-key chain" }, + { crate = "universal-hash@0.5.1", reason = "noise_sv2 2.0.0 upstream SV2 transport chain" }, { crate = "r-efi@5.3.0", reason = "getrandom 0.3 UEFI backend" }, # dashmap 6.2 pins 0.14; metrics-util/quick_cache pin 0.16; the workspace uses 0.17. { crate = "hashbrown@0.14.5", reason = "dashmap 6.2 pin" }, diff --git a/docs/README.md b/docs/README.md index 19e1f9717d..9edef64916 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ | Place instrumentation in the right layer | [Observability boundary](observability.md), [tracing/USDT](tracing.md) | | Map process tests to their Core functional references | [Reorg coverage map](../e2e/REORG-COVERAGE.md) | | Mine regtest blocks and inspect templates | [Solo-mining generate path](contracts/external-api.md#api-05-solo-mining-generate-path) and [template extras](contracts/external-api.md#api-11-bip22bip23-template-extras) | +| Configure the default-off experimental native pool with an external payout signer | [Native pool operation](pool.md) and [pool contract](contracts/pool.md) | | Inspect a Core snapshot file without starting a node | [Snapshot inspector](../tools/snapshot/README.md) | | Watch a running node for stalls | [Runtime stall watchdog](operations/runtime-stall.md) | | Review unselected index-design options (historical) | [Index ideation record](ideation/better-tasting-and-efficient-index-design-and-abstractions.md): brainstorm only, not a plan | diff --git a/docs/contracts/README.md b/docs/contracts/README.md index c69fd7ffc2..2e0f70520f 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -23,6 +23,7 @@ When code and a contract disagree, fix the drift in the same change. Do not dupl | [chain-events.md](chain-events.md) | `EVT-01`–`EVT-05` | Applied-chain event seam and consumer cursors | state/apply/txindex recovery tests | | [mempool-mutations.md](mempool-mutations.md) | `MPL-01`–`MPL-04` | Mutation ordering, sequence events, chain-change fencing | mempool gateway, RPC ZMQ, and node apply tests | | [mempool-policy.md](mempool-policy.md) | `POL-01`–`POL-06` | Admission owner, policy pins, preview/finality, replacement and package rules | policy/RBF suites plus planned admission/finality/cluster suites | +| [pool.md](pool.md) | `POOL-01`–`POOL-09` | Default-off experimental native SV1/SV2 pool, PPLNS accounting, and external-signer boundary | scoped protocol, ledger, node, and RPC suites; final strict gates pending | | [external-api.md](external-api.md) | `API-01`–`API-35` | RPC/REST/ZMQ behavior, mining requests, errors, and reference provenance | Manifest coverage, Core parity, mining and request-validation tests | | [wallet-facing.md](wallet-facing.md) | `WF-01`–`WF-03` | Public wallet-facing surface and isolation from node internals | wallet-facing and Esplora tests; `listener_directory_table_is_closed_over_http` | | [p2p-wire.md](p2p-wire.md) | `P2P-01`–`P2P-11` | Wire compatibility, peer leases, demonstrated capability, canonical frontier recovery, connected-socket posture, body-carried header admission, header-led block announcements with bounded ingress, inbound admission and outbound service gating, block-body service eligibility, header presync admission gating, BIP324 v2 transport negotiation | P2P compatibility/live interop, dispatch/listener, peer-table and sync tests | diff --git a/docs/contracts/pool.md b/docs/contracts/pool.md new file mode 100644 index 0000000000..fafcb25fdf --- /dev/null +++ b/docs/contracts/pool.md @@ -0,0 +1,117 @@ +# Native pool contract + +This contract defines the boundaries of the native SV1/SV2 pool, its durable PPLNS ledger, and the external payout-signing interface. The [operator guide](../pool.md) describes configuration and management RPC usage. + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, and **MAY** express requirement levels as defined in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.txt). They govern this implementation contract; they do not claim complete Stratum specification coverage or external miner interoperability. + +## Owners + +| Boundary | Authoritative owner | Responsibility | +| --- | --- | --- | +| Operator options | `crates/node/src/options.rs`, `config.rs` | Feature/runtime gating, explicit configuration, and validation. | +| Composition and shutdown | `crates/node/src/lifecycle.rs`, `pool.rs` | Ledger construction, RPC handle publication, listeners, reconciliation, and draining. | +| Jobs and SV1 | `crates/stratum/src/` | MiningControl-backed jobs, share validation, bounded admission, and SV1 protocol handling. | +| SV2 protocol | `crates/stratum-v2/src/` | Noise responder, mining channels, job/share frames, and deferred acknowledgements. | +| Node SV2 adapter | `crates/node/src/pool/sv2.rs`, `authority.rs` | Socket lifecycle, account mapping, found-block handling, and authority-file security. | +| Accounting | `crates/pool-ledger/src/lib.rs`, `store.rs`, `kv.rs` | PPLNS work, round/batch transitions, balance conservation, audit records, and versioned payout codecs over `KvStore`. | +| Applied-chain observations | `crates/node/src/pool/ledger.rs` | Hash-keyed canonicality, coinbase bodies, payout transaction/spend observations, and stable reconciliation. | +| Management API | `crates/rpc/src/handlers/pool.rs` | Authenticated key-free accounting operations through the composed pool handle. | + +## Clauses + +### `POOL-01`: Explicit enablement and operator configuration + +- The service MUST be off by default. The `pool` build feature MUST NOT enable the runtime implicitly. SV2 additionally requires `pool-sv2`. +- Enabling the runtime MUST require a nonempty dedicated pool data directory, at least one explicit listener, an exact plain-decimal share difficulty, a positive work window, and a reward script. Node mining MUST also have a payout address. +- The operator MUST configure the node mining payout address and ledger reward script consistently. The ledger MUST NOT infer ownership or redirect a coinbase from the reward-script setting. +- Listener ports MUST be nonzero. SV1 and SV2 addresses MUST differ. A non-loopback listener MUST require `pool_allow_non_loopback`; omission MUST NOT imply permission. +- SV2 bind and authority-file path MUST be configured together. Pool sub-options MUST NOT be silently ignored when the runtime is disabled. +- The node MUST reject maturity below 100, zero payout confirmations, fees above 10,000 basis points, and a reorg watch shorter than maturity, payout confirmation depth, or the fixed six-confirmation abandon depth. +- A reward script MUST be nonempty, bounded to 10,000 bytes, minimally encoded, and not begin with `OP_RETURN`. Passing these checks MUST NOT be represented as proof of spendability or control by the operator. + +### `POOL-02`: Schema consent, storage, and immutable policy + +- The additive `PoolLedger` storage family and local integration have explicit approval. This approval does not authorize deployment or an existing-data cutover. +- Backend verification MUST use disposable stores, including old-schema fixtures. This work MUST NOT open or migrate existing operator databases. Existing-data cutover requires separate approval. +- The pool MUST use the selected node storage backend through the storage-owned `KvStore` boundary. The ledger MUST NOT acquire a direct engine dependency or reset an existing database implicitly. +- The service MUST lock the dedicated pool directory. It MUST create a ledger only when absent and reopen an existing ledger with the persisted immutable policy checked for equality. +- Accounting policy changes MUST NOT silently reinterpret historical shares or funds. A configuration mismatch MUST fail rather than delete or recreate the ledger. +- Ledger creation/opening and run-epoch allocation MUST precede listener admission. A run epoch MUST distinguish restarted protocol job identities in durable share accounting. + +### `POOL-03`: Shared admission and bounded deferred work + +- SV1 and SV2 MUST use one shared total/per-IP connection limiter. Defaults are 128 total connections and 8 per IP. Both limits MUST be positive, and the per-IP limit MUST NOT exceed the total. +- Runtime job retention MUST be bounded at 64 jobs, owner sessions at twice the configured connection cap, and duplicate-share tracking at 65,536 entries per job. Owner sessions are not the same budget as live TCP connections. +- The node-composed deferred share-ledger outbox MUST be bounded by both 1024 records and 8 MiB. Exhaustion MUST reject work before validation. SV1 MUST withhold success for queued writes until their accounting completion is recorded. +- Found-block processing MUST reserve capacity before SV2 protocol feed/validation can emit work requiring a record. Found-record reservations and recovery records MUST share the eight-slot budget, with a 4,000,000-byte charge per slot. Failed writes MUST retain the compact accounting projection rather than serialized full blocks. +- Per-connection protocol limits MUST remain in force in addition to aggregate admission. None of these individual bounds is a claim of a total-process memory bound. + +### `POOL-04`: Authority-key handling + +- The SV2 authority MUST be a matching BIP340 x-only public/secret keypair. It authenticates the mining transport and MUST NOT be treated as payout custody. +- On Unix the key file MUST be a regular file with permission bits exactly `0600`. Opening MUST use `RDONLY`, `CLOEXEC`, `NOFOLLOW`, and `NONBLOCK`, followed by validation of the opened descriptor's metadata. A final-component symlink or special file MUST NOT bypass this check. +- The file MUST contain exactly two ASCII-whitespace-separated 64-digit hexadecimal fields, public key first and secret key second. Input larger than 256 bytes or extra fields MUST be rejected. +- Secure authority-file loading MUST fail closed where this implementation has no supported loader; the non-Unix path reports unsupported operation. +- The file input buffer, decoded secret buffer, and stored authority secret MUST use zeroizing ownership. Debug formatting MUST redact the stored secret, and parse errors MUST NOT include key input. This requirement does not certify zeroization of cryptographic dependency internals, kernel buffers, or every transient machine-level copy. +- Operators SHOULD protect ancestor directories. `NOFOLLOW` on the opened path MUST NOT be described as an ancestor-directory trust guarantee. + +### `POOL-05`: One mining owner and acknowledgement boundaries + +- Both protocols MUST derive jobs and validate work through the shared native pool owner backed by `MiningControl`. Protocol adapters MUST NOT introduce a second template or consensus validator. +- A found block MUST be submitted through node mining control before its accounting record is finalized. Submission outcome MUST NOT substitute for active-chain evidence. Applied, duplicate, rejected, missing-parent, and unavailable submission results do not by themselves authorize a reward. +- SV2 `SubmitShares.Success` MUST remain deferred until host confirmation of the durable accounting outcome. An idempotently recorded share MAY satisfy that confirmation; a failed or merely queued record MUST NOT be presented as committed. +- SV1 success MUST wait for a completion token covering the required durable share write and, for a found block, its block-record write. Failed inline writes MAY remain in the bounded volatile outbox, but queueing alone MUST NOT complete the response. +- Each SV1 session MUST retain at most one pending accounting response. Additional submissions while it is pending MUST be rejected before validation with a retryable accounting error. Polling MUST send the deferred success only after accounting completion. Process failure MAY lose queued work before completion, but such work MUST NOT already have received success. +- A found-block accounting record MUST initially be pending and deduplicated by its block/share identity. Retrying a record MUST NOT allocate the same reward twice. +- New job parents, clean-job flags, and submission receipts MUST NOT be used as reorg or maturity evidence. + +### `POOL-06`: PPLNS reward and maturity + +- A round MUST snapshot its accepted-share work window at the solving share. The window is work-weighted, not a count of connections, accounts, or shares. +- Reward fixing MUST require authoritative canonical observation of the exact found block and its coinbase. The observed coinbase txid and total value MUST agree with the recorded claim. Missing bodies or contradictory observations MUST NOT fabricate a reward. +- Gross pool reward MUST be the sum of observed coinbase outputs with the configured pool script. The fee MUST be computed in integer basis points, rounded down. Net allocations MUST conserve that reward after the fee. +- Available balances MUST be credited only after the configured maturity, using confirmations `tip_height - block_height + 1`. Node configuration MUST keep that threshold at or above 100. +- A registered payout script MUST be required for miner admission. Decimal account IDs MAY include one nonempty worker suffix. Login syntax MUST NOT be represented as cryptographic proof of payout-script ownership. + +### `POOL-07`: Hash-keyed reorg reconciliation and conservation + +- Active-chain membership MUST be observed by exact block hash. A claimed height is only a lookup hint. Known displaced hashes MUST remain distinguishable from unknown hashes even when the replacement tip is lower or equal. +- The node adapter MUST take stable applied-chain observations. Transaction and spend index hints MUST be checked against the corresponding canonical block body before being accepted as payout evidence. +- A monitored matured round displaced from the active chain MUST become orphaned and reverse its allocations. Reversal MUST consume available funds first; the remainder MUST become a deficit rather than erase a reservation or rewrite an already-paid amount. +- Future credits MUST offset deficits before increasing available funds. Per account, `available + reserved + paid - deficit` MUST equal credited matured-round allocations. +- Unknown membership MUST NOT be treated as proof of canonicality or displacement. Reorg monitoring is bounded: rounds and confirmed batches leave monitoring according to the configured watch horizon. This contract MUST NOT be advertised as unlimited deep-reorg recovery. +- Payout confirmation reversal MUST preserve the batch's financial reservation/accounting transitions. A transaction disappearing from a mempool MUST NOT prove that its signature is unusable or that its inputs cannot later be spent. + +### `POOL-08`: External signing without wallet custody + +- Management pool RPC MUST remain authenticated and key-free. The pool MUST NOT generate, import, expose, or use Bitcoin payout private keys. The external signer MUST own funding, input selection, signatures, and fee policy. +- `preparepayoutbatch` MUST reserve eligible funds with a 32-byte idempotency key. Its policy MUST NOT lower the configured minimum payout or exceed the configured output cap. The absolute maximum is 2500 outputs; the configured minimum is bounded from 546 satoshis through `MAX_MONEY`. +- The signer request MUST use the strict canonical BRPR version 1 codec. The receipt MUST use BRPT version 1. Metadata integers are big-endian. BRPR carries exact payout outputs and authorized change scripts; BRPT carries batch ID, txid, and raw transaction. +- Receipt recording MUST recompute the txid, match the exact payout-output multiset, reject unauthorized extra outputs, reject invalid or duplicate inputs, and preserve reservations on rejection. A replacement MUST conflict with earlier signed transactions of that batch rather than permit independent double payment. +- The generic verifier allows at most one authorized change output. The node policy supplies no authorized change scripts, so node-composed batches MUST reject all extra change outputs. +- Receipt validation MUST NOT be described as signature or input-ownership verification. `recordpayoutreceipt` MUST NOT broadcast. Chain observation of the recomputed txid supplies node-acceptance evidence; payment confirmation additionally requires the configured depth. +- Signing failure, timeout, or receipt rejection MUST NOT release a reservation implicitly. Cancellation MUST require a prepared batch and explicit `never_handed_to_signer` or `signer_revoked` basis. The operator MUST establish that external basis; the RPC cannot prove signer revocation. +- Lack of a transaction index prevents confirmed payout observation outside the mempool; lack of a script index prevents confirmed-spend observation. Such observer errors MUST NOT be converted into evidence of nonpayment or safe abandonment. + +### `POOL-09`: Lifecycle and protocol scope + +- Composition MUST construct the ledger/operator handle before management RPC publication. Mining listeners MUST start only after RPC binds successfully and an initial template is available. +- Reconciliation MUST refresh work and observe rounds/payouts without holding shutdown hostage to a future mining generation. Shutdown MUST stop admission, cancel and join protocol workers, and drain bounded deferred accounting work within the node's shutdown deadline. A missed deadline or worker panic MUST remain an error, not a success report. +- SV2 support is limited to the role matrix in `crates/stratum-v2/src/lib.rs`: Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling, including the adapter's reconnect API. +- Custom jobs/work selection, Job Declaration, a Template Distribution server, extranonce-prefix changes, and extensions negotiation MUST NOT be advertised as implemented. The in-process template source MUST NOT be confused with a Template Distribution server. + +## Evidence boundary and source map + +The supplied evidence baseline records **71 prior protocol tests**, **112 prior ledger tests**, and **111 selected node/RPC tests**. These are scoped results from separate runs, not a final all-features or whole-tree certification. Strict final gates remain pending. No external miner interoperability is claimed. No checks were run by this documentation assignment. + +| Contract area | Source anchor | Evidence boundary | +| --- | --- | --- | +| Options and validation | `crates/node/src/options.rs`, `config.rs::resolve_pool`, `PoolConfig::validate` | Implemented configuration, not deployment approval. | +| Persistence and budgets | `crates/node/src/pool.rs::LedgerComposer`, `PoolService::prepare`, `PoolService::start`; `pool/ledger.rs::try_reserve_block_slot` | Approved additive storage and runtime bounds; no operator-data cutover. | +| Key security | `crates/node/src/pool/authority.rs::load`, `open_private_file`; `crates/stratum-v2/src/lib.rs::AuthorityKeypair` | Descriptor checks and explicitly owned zeroizing buffers. | +| Block and share records | `crates/node/src/pool/ledger.rs::record_submitted_block`; `pool/sv2.rs::handle_events`; `crates/stratum/src/sv1/session.rs::Completion`, `PoolCore::submit`, `on_submit`, `poll` | Submission ordering and deferred durable success in both protocols; not miner interoperability. | +| Canonicality and reward reversal | `crates/pool-ledger/src/store.rs::reconcile_rounds`, `apply_round_observation`, `fix_reward`, `reverse_round_credits`; node `RawChainView` | Hash-keyed observations within the watch horizon. | +| Signer codecs and receipt checks | `crates/pool-ledger/src/lib.rs::PayoutRequest`, `PayoutReceipt`, `verify_payout_transaction`; `store.rs::record_signed` | Exact output/txid checks; no in-process wallet or signature-verification claim. | +| Operator API | `crates/rpc/src/handlers/pool.rs` | Selected node/RPC evidence, not strict final-gate completion. | + +For operator configuration and the external signing sequence, read [Native pool operation](../pool.md). Existing operator databases MUST remain untouched by this work. Deployment and existing-data cutover require separate approval. diff --git a/docs/pool.md b/docs/pool.md new file mode 100644 index 0000000000..2a61b9e7c3 --- /dev/null +++ b/docs/pool.md @@ -0,0 +1,102 @@ +# Native pool operation + +The native pool combines SV1 and optional SV2 mining listeners with PPLNS accounting and an external payout signer. It is disabled by default. The node builds mining candidates in process; it does not use a separate template-provider service. + +The additive `PoolLedger` storage family and local integration are explicitly approved. This approval does not authorize a production deployment or an existing-data cutover. Backend verification MUST use disposable stores, including old-schema fixtures. Existing operator databases MUST remain untouched by this work. Pool operation remains opt-in. See the [pool contract](contracts/pool.md) for the financial and lifecycle requirements. + +## Prerequisites + +- The binary MUST include the `pool` feature. An SV2 listener additionally requires `pool-sv2`. These features do not enable the runtime by themselves. +- The operator MUST supply a dedicated pool data directory, at least one explicit mining listener, an exact share difficulty, a positive PPLNS work window, and the externally controlled reward script. +- The node also requires `--mining-payout-address`. The operator MUST ensure its resulting coinbase script matches `--pool-reward-script`; configuring an unrelated script does not redirect the candidate's coinbase. The ledger recognizes only observed coinbase outputs matching its configured reward script. +- An external signer MUST control payout funding and signing. The pool has no wallet custody. The SV2 Noise authority secret authenticates the mining service; it is not a Bitcoin payout key. +- Management RPC MUST remain on the authenticated management listener. Mining listener exposure does not grant management access. +- Payout confirmation requires transaction-index observations (`--txindex`); confirmed conflicting-spend observation requires the script index (`--scriptindex=full`). Index hints are checked against canonical block bodies. Operators MUST retain the bodies needed for those observations and MUST NOT treat an unavailable or incomplete index as evidence that a payout cannot confirm. + +The option declarations are in [`options.rs`](../crates/node/src/options.rs), and resolution and validation are in [`config.rs`](../crates/node/src/config.rs). Native TOML keys are flat, such as `pool_v1_bind`; environment names use `BITCOIN_RS_` followed by the uppercase key. There are no pool-specific `bitcoin.conf` aliases. + +## Configure the candidate + +Before enabling the service, choose the reward destination with the external signer and obtain its address and exact scriptPubKey. Supply the address through `--mining-payout-address` and the script bytes as hexadecimal through `--pool-reward-script`. Operators MUST NOT copy a test script or someone else's address into a funded configuration. + +Set `--pool` and provide each required value below. No listener, pool directory, difficulty, work window, or reward script is inferred. + +| Option / TOML key | Type and default | Requirement | +| --- | --- | --- | +| `--pool` / `pool` | Boolean; `false` | Enables the service. Pool sub-options without this setting are rejected. | +| `--pool-datadir` / `pool_datadir` | Path; required | Dedicated ledger directory. The service locks it and opens its selected storage backend under `ledger/`. | +| `--pool-v1-bind` / `pool_v1_bind` | Socket address; unset | Explicit SV1 address, for example `127.0.0.1:3333`. | +| `--pool-sv2-bind` / `pool_sv2_bind` | Socket address; unset | Explicit SV2 address, for example `127.0.0.1:3336`; requires the authority-key path. | +| `--pool-sv2-authority-key` / `pool_sv2_authority_key` | Path; unset | Required exactly when an SV2 bind is configured. See key requirements below. | +| `--pool-allow-non-loopback` / `pool_allow_non_loopback` | Boolean; `false` | Explicit permission for non-loopback mining binds. | +| `--pool-share-difficulty` / `pool_share_difficulty` | Exact plain-decimal string; required | Fixed share difficulty, not a floating-point approximation or vardiff policy. | +| `--pool-window-work` / `pool_window_work` | Positive `u64`; required | PPLNS window in accepted-share work units, not a share count. | +| `--pool-reward-script` / `pool_reward_script` | Hexadecimal scriptPubKey; required | Nonempty, at most 10,000 bytes, minimally encoded, and not beginning with `OP_RETURN`. These checks do not prove the operator owns the destination. | +| `--pool-fee-bps` / `pool_fee_bps` | `u16`; `0` | Pool fee, from 0 through 10,000 basis points. | +| `--pool-max-connections` / `pool_max_connections` | Positive `u32`; `128` | Aggregate live connection limit across SV1 and SV2. | +| `--pool-max-connections-per-ip` / `pool_max_connections_per_ip` | Positive `u32`; `8` | Aggregate per-IP limit across both protocols; cannot exceed the total. | +| `--pool-maturity` / `pool_maturity` | `u32`; `100` | Coinbase confirmation threshold; MUST be at least 100. | +| `--pool-payout-confirmations` / `pool_payout_confirmations` | Positive `u32`; `6` | Confirmation threshold for a payout batch. | +| `--pool-reorg-watch-depth` / `pool_reorg_watch_depth` | `u32`; `1000` | MUST cover maturity, payout confirmations, and the fixed six-confirmation abandon depth. | +| `--pool-min-payout-sat` / `pool_min_payout_sat` | `u64`; `546` | Between 546 and 2,100,000,000,000,000 satoshis. | +| `--pool-max-payout-outputs` / `pool_max_payout_outputs` | `u32`; `2500` | Between 1 and 2500 outputs. | + +Write difficulty with an ASCII-digit integer part and, if needed, a decimal point and fractional digits, such as `16384` or `0.001`. There MUST be at most 19 digits in total. Signs, exponent notation, a missing integer part, and zero are rejected. The target conversion uses integer-ratio arithmetic; see `ShareTarget::from_pool_difficulty_decimal` in [`job.rs`](../crates/stratum/src/job.rs). + +At least one protocol bind MUST be present. Ports MUST be nonzero, and the two bind addresses MUST differ. Non-loopback binds require explicit permission even when the address is a wildcard. Operators SHOULD keep candidate listeners on loopback. + +Accounting policy is persisted at ledger creation and checked on reopen. Changing the PPLNS window, fee, reward script, maturity, confirmation depths, or other immutable ledger policy is not a configuration-only migration. Operators MUST preserve the existing ledger; they MUST NOT delete it to bypass a policy mismatch. + +## Protect the SV2 authority key + +On Unix, the authority file MUST be a regular file with mode exactly `0600`, including no extra special permission bits. Its contents MUST be two ASCII-whitespace-separated fields: a 64-digit hexadecimal BIP340 x-only public key followed by a 64-digit hexadecimal secret key. Extra fields and files larger than 256 bytes are rejected. Operators MUST provision a matching keypair and distribute the public key through their miner trust configuration. + +The loader opens the path with `RDONLY | CLOEXEC | NOFOLLOW | NONBLOCK` and checks metadata on the opened descriptor. A final-component symlink, FIFO, directory, or insecure mode is not accepted. `NOFOLLOW` is not a claim that every parent directory is trusted; operators SHOULD protect the containing directory from modification by other users. Secure authority-file loading fails closed on non-Unix platforms. + +The input buffer, decoded secret, and stored authority secret use `Zeroizing`; the authority object's debug output redacts the secret. Parse errors do not include file contents. These protections cover the owned buffers in this implementation, not every internal copy in a cryptographic dependency or operating-system memory. See [`authority.rs`](../crates/node/src/pool/authority.rs) and [`AuthorityKeypair`](../crates/stratum-v2/src/lib.rs). + +## Register miners and inspect accounting + +Use the authenticated management JSON-RPC listener. Pool methods are unavailable without the compiled feature; with the feature compiled but the runtime off, they report `MethodDisabled`. Parameters are positional arrays. + +| Method | Parameters | Result or effect | +| --- | --- | --- | +| `registerpoolaccount` | `[account, script_hex]` | Registers the numeric account's payout script. This is required before miner admission. | +| `getpoolinfo` | `[]` | PPLNS configuration, share count, and the last 20 audit records. | +| `getpoolbalances` | `[account]` | Available, reserved, paid, and deficit balances in satoshis, plus the payout script. | +| `getpoolrounds` | `[first_round, count]` | Round states and rewards; first round MUST be positive. Count defaults to 100 and is at most 1000. | +| `preparepayoutbatch` | `[idempotency_key_hex, min_payout_sat, max_outputs]` | Reserves eligible balances and returns `batch` and hexadecimal BRPR `request`, or `null` if no batch qualifies. The key MUST encode exactly 32 bytes. | +| `getpayoutrequest` | `[batch_id_hex]` | Returns the canonical BRPR request for a reservation-holding batch. | +| `recordpayoutreceipt` | `[receipt_hex]` | Validates and records a BRPT receipt. It does not broadcast the transaction. | +| `getpayoutbatch` | `[batch_id_hex]` | Returns the batch state, or `null` for an unknown batch. | +| `cancelpayoutbatch` | `[batch_id_hex, basis]` | Cancels a prepared batch with explicit basis `never_handed_to_signer` or `signer_revoked`. | + +Miner logins are decimal account IDs, optionally followed by one nonempty worker suffix, such as `7.rig1`. Additional dots are rejected. A registered payout script is the admission prerequisite; a worker login is not proof of control of that script. Operators MUST restrict management access and register scripts only through their own account-authorization process. + +RPC ownership: [`handlers/pool.rs`](../crates/rpc/src/handlers/pool.rs). Admission and shared durable accounting: [`pool/ledger.rs`](../crates/node/src/pool/ledger.rs). + +## Complete a payout outside the node + +1. Inspect balances and prepare a batch with a retained idempotency key. The requested minimum MUST be no lower than the configured minimum; the requested output count MUST be no higher than the configured cap. +2. Transfer the returned BRPR bytes to the external signer. BRPR version 1 contains the batch ID and exact account, amount, and script outputs, followed by allowed change scripts. The node's configured ledger authorizes no change scripts, so its receipts MUST NOT add a change output. +3. Have the signer fund and sign the transaction, then return a BRPT version 1 receipt containing the batch ID, claimed txid, and raw transaction. The signer remains responsible for funding, signatures, input ownership, and fee policy. +4. Submit the receipt with `recordpayoutreceipt`. The ledger recomputes the txid and checks the exact output multiset. Recording is not signature verification, broadcast, or payment confirmation. +5. Broadcast through the external transaction-submission workflow and observe `getpayoutbatch`. Node reconciliation advances a recorded transaction from observed mempool or chain state and confirms payment only at the configured active-chain depth. Without transaction/spend index observations, some historical payout or conflict evidence is unavailable; operators MUST NOT interpret missing evidence as proof that a signed transaction cannot confirm. + +Both wire formats use strict versioned codecs with big-endian metadata fields. Reservations survive signing errors and rejected receipts. A timeout or a transaction disappearing from the mempool is not safe cancellation evidence. Operators MUST NOT assert `signer_revoked` unless the external signer has actually revoked the prepared request; the node cannot establish that external fact. + +## Understand rewards, reorgs, and pressure + +PPLNS fixes each found block's work window at its solving share. A submission receipt, a new job parent, or a reported height does not establish reward eligibility. The ledger first observes the exact block hash on the active chain, verifies the coinbase claim, and computes rewards from outputs matching the reward script. Confirmations are `tip_height - block_height + 1`. Only matured allocations become available for payout. + +Within the configured reorg-watch horizon, displacement of a matured block reverses its allocations. Available funds are removed first; amounts already reserved or paid create a deficit that future credits offset. A lower or equal replacement tip does not hide displacement because canonicality is hash-based. Unknown membership is not positive canonicality evidence. Monitoring is bounded by the configured watch depth; this is not an indefinite deep-reorg guarantee. + +Both mining protocols share the configured total and per-IP connection limiter. They also share the job and accounting owners. The runtime bounds retained jobs at 64, owner sessions at twice the configured connection limit, and duplicate-share tracking at 65,536 entries per job. The node explicitly sets the SV1 deferred ledger outbox to 1024 records and 8 MiB, overriding the standalone core defaults of 4096 records and 32 MiB. Found-block recovery has a shared eight-slot admission budget with 4,000,000 bytes charged per slot; failed records retain a compact ledger projection, not complete blocks. These are distinct budgets, not a total-process memory bound. A queue-pressure rejection is not an accepted share. SV1 retains failed writes in its bounded in-memory outbox but withholds success until the required share record and, for a found block, block record complete. Each SV1 session permits only one pending accounting response; additional submissions are rejected before validation with a retryable accounting error. Process failure can lose queued work before completion, but that work has not received a success response. SV2 likewise holds success until the host confirms the ledger record. Operators MUST NOT equate queued work with committed accounting. + +## Evidence and operational limits + +The recorded prior evidence is **71 protocol tests** and **112 ledger tests**. The selected node/RPC evidence is **111 tests**. These counts describe separate reported runs, not one final verification of the entire current tree. Strict final gates remain pending. This documentation task ran no builds, tests, linters, formatters, or interoperability checks. + +No external miner interoperability is claimed. The SV2 implementation supports authenticated Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling. It does not implement custom mining jobs, Job Declaration, a Template Distribution server, extranonce-prefix changes, or extensions negotiation. Protocol implementation tests do not establish interoperability with a particular miner or proxy. + +Before operation, inspect the [pool contract](contracts/pool.md). Keep existing operator databases unchanged. A deployment or existing-data cutover requires separate approval. diff --git a/docs/rpc-reference.md b/docs/rpc-reference.md index 7666b0b6b5..7480e4468c 100644 --- a/docs/rpc-reference.md +++ b/docs/rpc-reference.md @@ -106,6 +106,15 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | surface | since | notes | |---|---|---| | `getblocktweaks` | 0.14.0 | Experimental keyless BIP352 extension; requires --silent-payments. One applied-chain height or hash per call, certified retained inputs, final publication check, one in-flight projection, and shared block-render permits. Source read budgets can refuse valid blocks; use another complete source for those blocks. No draft HTTP/gRPC compatibility or wallet discovery claim. See docs/silent-payments.md. | +| `registerpoolaccount` | 0.14.0 | Authenticated management-only payout-script registration: [account_u64, script_hex]. Changes future batches only and enables public credit-routing login '[.rig]'; miner password is unused. Knowing a login permits contributing credit, not withdrawals or payout-script changes. No mining credential authentication or wallet custody. Requires enabled pool service. | +| `preparepayoutbatch` | 0.14.0 | Management-only: [idempotency_key_hex32, min_payout_sat, max_outputs]. Caller minimum must meet configured payout minimum; output count must not exceed configured ceiling. Durably reserves eligible balances in immutable batch; returns batch status and canonical BRPR request hex, or null when none qualify. Never signs or broadcasts. | +| `getpayoutrequest` | 0.14.0 | Management-only: [batch_id_hex32]. Exports ledger BRPR v1 hex for external signing; terminal batches refuse export. | +| `recordpayoutreceipt` | 0.14.0 | Management-only: [BRPT_hex]. Strict ledger BRPT v1 decoder and record_signed verify recomputed txid, exact payout outputs, allowed change and replacement conflicts. Records receipt only, never broadcasts; no signature-validity or chain-acceptance claim. | +| `getpayoutbatch` | 0.14.0 | Management-only: [batch_id_hex32]. Returns real ledger batch state, recorded transaction ids and observed status; null for unknown batch. No signing, broadcasting or chain-success inference. | +| `cancelpayoutbatch` | 0.14.0 | Management-only: [batch_id_hex32, never_handed_to_signer or signer_revoked]. Operator attests cancellation basis; ledger refuses cancellation after signing. No external signer revocation is performed. | +| `getpoolinfo` | 0.14.0 | Management-only: []. PPLNS configuration, durable share count and last twenty audit records read in one bounded snapshot. Inspect individual batches with getpayoutbatch; no unbounded batch enumeration. Does not verify the full audit chain. Disabled pool returns MethodDisabled, not synthetic status. | +| `getpoolrounds` | 0.14.0 | Management-only: [first_round_id, count=100]. Reads monotonic ledger rounds, up to 1000 per call. States reflect hash-keyed chain reconciliation, not submission acknowledgements. | +| `getpoolbalances` | 0.14.0 | Management-only: [account_u64]. Reads one account's available, reserved, paid and deficit satoshis plus payout script. No wallet balance or custody. | | `getcapabilities` | 0.4.0 | bitcoin-rs reporting of compiled/enabled concrete service capabilities and index lifecycle state (crates/rpc/src/handlers/chain.rs, crates/index/src/capabilities.rs). | ### Unimplemented @@ -240,4 +249,4 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `rawtx` | 0.4.0 | Requires the zmq feature and a --zmqpubrawtx endpoint. | | `sequence` | 0.4.0 | Requires the zmq feature and a --zmqpubsequence endpoint. Publishes C/D block events and A/R mempool events; A/R carry reversed txid, the label byte, and the mempool sequence as u64 LE (crates/rpc/src/zmq.rs). | -Row counts: Supported 0, Deviation 31, Implemented (unverified) 57, Extension 3, Disabled 0, Unimplemented 85 - total 176. +Row counts: Supported 0, Deviation 31, Implemented (unverified) 57, Extension 12, Disabled 0, Unimplemented 85 - total 185. diff --git a/scripts/feature-matrix.tsv b/scripts/feature-matrix.tsv index 52415f4719..604ffb2eac 100644 --- a/scripts/feature-matrix.tsv +++ b/scripts/feature-matrix.tsv @@ -11,6 +11,8 @@ portable bitcoin-rs pure --no-default-features --features fjall,zmq no-zmq bitcoin-rs pure --no-default-features --features fjall,redb fjall-only bitcoin-rs pure --no-default-features --features fjall silent-payments bitcoin-rs pure --no-default-features --features fjall,silent-payments +pool bitcoin-rs pure --no-default-features --features fjall,pool +pool-sv2 bitcoin-rs pure --no-default-features --features fjall,pool-sv2 redb-only bitcoin-rs pure --no-default-features --features redb portable-kernel bitcoin-rs native --no-default-features --features fjall,zmq,kernel full-node bitcoin-rs native --no-default-features --features rocksdb,fjall,redb,kernel From 51697e44d10f466c4e5c873257f3789a86b42bc2 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:05:32 +0900 Subject: [PATCH 02/61] Retire mining jobs when the node applies a block The template refresh interval left accepted work active. A miner could submit another winning sibling before the next refresh, causing a stale parent rejection and an avoidable SV2 disconnect. Retire work at the authoritative Applied result in both native hosts. Preserve duplicate identity for exact accepted replays and record in-flight stale-parent receipts before returning a stale share error. Prove both host paths with failing-before regression tests. The native SV1/SV2 process proof covers maturity, external signing, payout recovery and hash-based orphan accounting without custody in the node. --- Cargo.lock | 5 + crates/mining/src/bip22.rs | 5 + crates/mining/src/lib.rs | 4 +- crates/node/Cargo.toml | 7 + crates/node/src/mining.rs | 4 +- crates/node/src/pool/sv2.rs | 580 +++++++++++++++++++++++++++- crates/stratum-v2/src/lib.rs | 17 + crates/stratum-v2/src/session.rs | 45 +++ crates/stratum-v2/tests/protocol.rs | 357 ++++++++++++++++- crates/stratum/src/job.rs | 102 +++-- crates/stratum/src/sv1/session.rs | 16 +- crates/stratum/tests/jobs.rs | 49 +++ crates/stratum/tests/protocol.rs | 257 +++++++++++- docs/contracts/pool.md | 2 + docs/pool.md | 1 + 15 files changed, 1406 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ab0fcdbfc..cd7217bd63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,6 +450,7 @@ version = "0.14.0" dependencies = [ "anyhow", "arc-swap", + "binary_sv2", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-chainstate", @@ -470,12 +471,16 @@ dependencies = [ "bytes", "cap-fs-ext", "cap-std", + "codec_sv2", + "common_messages_sv2", "compact_str", "criterion", "crossbeam-channel", "hashbrown 0.17.1", "metrics", "metrics-exporter-prometheus", + "mining_sv2", + "noise_sv2", "parking_lot", "quanta 0.13.0", "rayon", diff --git a/crates/mining/src/bip22.rs b/crates/mining/src/bip22.rs index b1de3a51ec..b9e1d9ca2d 100644 --- a/crates/mining/src/bip22.rs +++ b/crates/mining/src/bip22.rs @@ -8,6 +8,11 @@ use crate::{MiningControlError, MiningError}; const SIGOPS_LIMIT_REASON: &str = "bad-blk-sigops"; +/// BIP22 reason for a block whose parent is no longer the best tip. The node +/// answers it for `ApplyError::PrevHashMismatch`; pool adapters read it as a +/// stale sibling instead of a defective block. +pub const STALE_PARENT_REASON: &str = "inconclusive-not-best-prevblk"; + /// Core `GetRejectReason` strings for consensus failures (API-19). pub fn consensus_reject_reason(error: &ConsensusError) -> CompactString { CompactString::from(match error { diff --git a/crates/mining/src/lib.rs b/crates/mining/src/lib.rs index bfb38ddd70..e60c00955c 100755 --- a/crates/mining/src/lib.rs +++ b/crates/mining/src/lib.rs @@ -20,7 +20,9 @@ mod policy; /// Transport-neutral candidate assembly. mod template; -pub use bip22::{chain_reject_reason, consensus_reject_reason, header_reject_reason}; +pub use bip22::{ + STALE_PARENT_REASON, chain_reject_reason, consensus_reject_reason, header_reject_reason, +}; pub use coinbase::{ MiningError, WITNESS_RESERVED_VALUE, needs_witness_reserved_value, update_uncommitted_block_structures, witness_commitment_script, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index c19d0fd383..cead95c220 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -122,6 +122,13 @@ rustix.workspace = true bitcoin-rs-script.workspace = true bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } +# Miner side of the SV2 host-path regression in `pool/sv2.rs`: the same pinned +# SRI release family the pool adapter links, never a second wire version. +binary_sv2.workspace = true +codec_sv2 = { workspace = true, features = ["noise_sv2"] } +common_messages_sv2.workspace = true +mining_sv2.workspace = true +noise_sv2.workspace = true [[bench]] name = "sync_pipeline" diff --git a/crates/node/src/mining.rs b/crates/node/src/mining.rs index 034da288bc..5bc5d273e4 100644 --- a/crates/node/src/mining.rs +++ b/crates/node/src/mining.rs @@ -662,7 +662,9 @@ fn test_block_validity_error(error: &ApplyError) -> MiningControlError { fn bip22_reject_reason(error: &ApplyError) -> Result { let reason = match error { ApplyError::ProofOfWork { .. } => CompactString::from("high-hash"), - ApplyError::PrevHashMismatch { .. } => CompactString::from("inconclusive-not-best-prevblk"), + ApplyError::PrevHashMismatch { .. } => { + CompactString::from(bitcoin_rs_mining::STALE_PARENT_REASON) + } ApplyError::TargetAboveLimit => CompactString::from("bad-diffbits"), ApplyError::BlockOutputsExceedInputs | ApplyError::BlockValueOverflow => { CompactString::from("bad-cb-amount") diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 8d9b660ec7..3c5b89e10c 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -10,8 +10,8 @@ use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore}; use bitcoin_rs_stratum_v2::{ - AdmitError, AuthorityKeypair, Connection, ERROR_CODE_SHARE_NOT_RECORDED, Event, Limits, - MAX_FEED_LEN, ServerConfig, + AdmitError, AuthorityKeypair, Connection, ERROR_CODE_SHARE_NOT_RECORDED, + ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, ServerConfig, }; use pool_ledger::MinerId; @@ -251,6 +251,7 @@ fn serve_connection( core.with_job_book(|book| connection.feed(&input[..length], book))?; handle_events( &mut connection, + core, ledger, control, &mut accounts, @@ -282,6 +283,7 @@ fn announce_current_job(connection: &mut Connection, core: &PoolCore) -> Result< fn handle_events( connection: &mut Connection, + core: &PoolCore, ledger: &LedgerHost, control: &dyn MiningControl, accounts: &mut HashMap, @@ -327,6 +329,13 @@ fn handle_events( .ok_or_else(|| anyhow!("SV2 block event has no host record reservation"))?; let found = pending.found_block(); let submission = pending.submit(control); + // The node's Applied verdict is authoritative: retire the job + // before any ledger or socket step below can return early. + if let Ok(submitted) = &submission + && submitted.receipt().status() == BlockStatus::Applied + { + core.invalidate_active(); + } let receipt = submission .as_ref() .map(|submitted| submitted.receipt().clone()) @@ -349,6 +358,10 @@ fn handle_events( BlockStatus::Applied | BlockStatus::Undetermined => { connection.confirm_block(&submission)?; } + BlockStatus::Rejected if submission.receipt().is_stale_parent() => { + connection + .reject_block(&submission, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE)?; + } BlockStatus::Rejected => { // A rejected node verdict remains a pending accounting // round until chain reconciliation. Do not send a @@ -465,3 +478,566 @@ fn wait_until_finished( } Ok(()) } + +/// Host-path regression: a real `Connection` is driven through the node's own +/// `announce_current_job` / `handle_events` against a real `PoolCore`, +/// `LedgerHost`, and a recording `MiningControl`. Nothing here calls +/// `invalidate_active`; only the host's `Applied` handling may retire the job. +#[cfg(all(test, any(feature = "fjall", feature = "redb")))] +mod tests { + use std::collections::HashMap; + use std::error::Error; + use std::path::Path; + use std::sync::Arc; + use std::sync::atomic::Ordering; + use std::time::Duration; + + use binary_sv2::{Encodable, GetSize, Str0255, U256}; + use bitcoin::secp256k1::{Keypair, Secp256k1}; + use bitcoin_rs_chain::compact_is_met_by; + use bitcoin_rs_mempool::{Mempool, MempoolLimits}; + use bitcoin_rs_mining::{ + Candidate, CandidateContext, CoinbaseBudget, FakeMiningControl, MiningControl, + assemble_candidate, + }; + use bitcoin_rs_primitives::{BlockHash, CompactTarget, Hash256, Network}; + use bitcoin_rs_rpc::context::PoolOperator as _; + use bitcoin_rs_storage::KvStore; + use bitcoin_rs_stratum::{ + BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Job, JobBookConfig, + MAX_NTIME_FORWARD_SECS, PoolCore, ShareLedger, ShareTarget, + }; + use bitcoin_rs_stratum_v2::{ + AdmitError, AuthorityKeypair, Connection, FLAG_REQUIRES_STANDARD_JOBS, Limits, ServerConfig, + }; + use codec_sv2::{ + Decoded, Decrypted, Handshake, InitiatorSent, MessageFrame, NoiseDecoder, NoiseEncoder, + TransportDecryptState, TransportEncryptState, + }; + use common_messages_sv2::{ + MESSAGE_TYPE_SETUP_CONNECTION, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, Protocol, + SetupConnection, + }; + use mining_sv2::{ + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, MESSAGE_TYPE_SUBMIT_SHARES_ERROR, + MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + OpenStandardMiningChannel, OpenStandardMiningChannelSuccess, SubmitSharesError, + SubmitSharesStandard, + }; + use noise_sv2::{INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE, Initiator}; + use pool_ledger::{Ledger, MinerId, PayoutPolicy}; + use zeroize::Zeroizing; + + use super::{LedgerHost, announce_current_job, handle_events}; + + type TestResult = Result<(), Box>; + + /// Failure of the miner-side fixture itself, never of the host under test. + #[derive(Debug, thiserror::Error)] + enum FixtureError { + #[error("SV2 {stage} failed: {detail}")] + Sv2 { stage: &'static str, detail: String }, + #[error("miner fixture is missing {0}")] + Missing(&'static str), + #[error("no received frame has type {0:#04x}")] + NoFrame(u8), + #[error("fewer than {0} nonces of the requested class exist")] + NonceSpace(usize), + } + + fn sv2(stage: &'static str) -> impl FnOnce(E) -> FixtureError { + move |error| FixtureError::Sv2 { + stage, + detail: format!("{error:?}"), + } + } + + #[cfg(feature = "fjall")] + type Store = bitcoin_rs_storage::FjallStore; + #[cfg(all(feature = "redb", not(feature = "fjall")))] + type Store = bitcoin_rs_storage::RedbStore; + + const LOGIN: &str = "42.rig"; + const PAYOUT: &[u8] = &[0x51]; + const EPOCH: u32 = 17; + + // ---- host fixtures: the node's real composition, minus sockets ---------- + + fn ledger_host(path: &Path) -> Result, Box> { + let store: Arc = Arc::new(Store::open(path)?); + let ledger = Ledger::create(store, pool_ledger::PoolConfig::new(1_000, PAYOUT.to_vec()))?; + ledger.set_payout_script(MinerId(42), PAYOUT)?; + Ok(Arc::new(LedgerHost::new( + Arc::new(ledger), + EPOCH, + PayoutPolicy { + min_payout_sat: 1_000, + max_outputs: 10, + }, + ))) + } + + fn candidate(height: u32, parent: u8) -> Result, Box> { + let context = CandidateContext { + previous_block_hash: Hash256::from_le_bytes(&[parent; 32]), + height, + version: 0x2000_0000, + bits: CompactTarget::from_consensus(0x207f_ffff), + min_time: 1_700_000_001, + current_time: 1_700_000_600, + locktime_cutoff: 1_700_000_000, + staged_parent_mtp: 1_700_000_000, + network: Network::Regtest, + csv_active: true, + segwit_active: true, + max_weight: 4_000_000, + max_size: 4_000_000, + max_sigops: 80_000, + }; + let pool = Mempool::new(MempoolLimits::default()); + Ok(Arc::new(assemble_candidate(&context, &pool, PAYOUT)?)) + } + + fn pool_core( + control: &Arc, + ledger: &Arc, + ) -> Result> { + let control: Arc = Arc::::clone(control); + let ledger: Arc = Arc::::clone(ledger); + let config = JobBookConfig { + budget: CoinbaseBudget { + additional_size: 0, + additional_sigops: 0, + }, + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/btcrs/".to_vec(), + version_mask: BIP320_VERSION_MASK, + ntime_forward_secs: MAX_NTIME_FORWARD_SECS, + max_jobs: 8, + max_sessions: 4, + max_shares_per_job: 64, + }; + Ok(PoolCore::new(config, control, ledger)?) + } + + /// The connection configuration `start` builds for every accepted socket. + fn server_config(ledger: &Arc) -> Result> { + let mut config = ServerConfig::new(ShareTarget::from_le_bytes([0xff; 32])?); + config.limits = Limits { + max_pending_acks: 1, + max_pending_events: 1, + ..Limits::DEFAULT + }; + let ledger = Arc::clone(ledger); + config.admit_user = Arc::new(move |identity: &[u8]| { + let login = std::str::from_utf8(identity).map_err(|_| AdmitError::InvalidIdentity)?; + ledger + .account(login) + .map(|_| ()) + .ok_or(AdmitError::UnknownUser) + }); + Ok(config) + } + + /// A fixed, disposable authority key pair; never a real key. + fn authority() -> Result> { + let secp = Secp256k1::new(); + let pair = Keypair::from_seckey_slice(&secp, &[0x11; 32])?; + Ok(AuthorityKeypair::from_bytes( + pair.x_only_public_key().0.serialize(), + Zeroizing::new(pair.secret_bytes()), + )) + } + + // ---- the miner side: a real Noise initiator over the same SRI crates ---- + + struct Frame { + msg_type: u8, + payload: Vec, + } + + struct Channel { + id: u32, + prefix: Vec, + } + + struct Miner { + enc: TransportEncryptState, + dec: Option, + encoder: NoiseEncoder, + decoder: NoiseDecoder, + inbuf: Vec, + } + + fn text(value: &'static str) -> Result, FixtureError> { + Str0255::try_from(value).map_err(sv2("text")) + } + + impl Miner { + fn seal( + &mut self, + msg_type: u8, + channel_msg: bool, + msg: T, + ) -> Result, FixtureError> { + let frame = + MessageFrame::from_message(msg, msg_type, 0, channel_msg).map_err(sv2("frame"))?; + let sealed = self + .encoder + .encode_transport(frame, &mut self.enc) + .map_err(sv2("encrypt"))?; + let bytes: &[u8] = sealed.as_ref(); + Ok(bytes.to_vec()) + } + + fn open(&mut self, bytes: &[u8]) -> Result, FixtureError> { + self.inbuf.extend_from_slice(bytes); + let mut frames = Vec::new(); + loop { + let want = self.decoder.writable_len(); + if self.inbuf.len() < want { + break; + } + if want > 0 { + self.decoder.writable().copy_from_slice(&self.inbuf[..want]); + self.inbuf.drain(..want); + } + let state = self + .dec + .take() + .ok_or(FixtureError::Missing("decrypt state"))?; + match self + .decoder + .next_transport_frame(state) + .map_err(sv2("server frame"))? + { + Decrypted::Frame(mut frame, state) => { + self.dec = Some(state); + let msg_type = frame.header().msg_type(); + frames.push(Frame { + msg_type, + payload: frame.payload().to_vec(), + }); + } + Decrypted::Incomplete(_, state) => self.dec = Some(state), + } + } + Ok(frames) + } + + fn setup(&mut self) -> Result, FixtureError> { + let setup = SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: FLAG_REQUIRES_STANDARD_JOBS, + endpoint_host: text("pool.invalid")?, + endpoint_port: 3333, + vendor: text("host-test")?, + hardware_version: text("sim-1")?, + firmware: text("fw-0")?, + device_id: text("")?, + }; + self.seal(MESSAGE_TYPE_SETUP_CONNECTION, false, setup) + } + + fn open_channel(&mut self) -> Result, FixtureError> { + let open = OpenStandardMiningChannel { + request_id: 7, + user_identity: text(LOGIN)?, + nominal_hash_rate: 1.0e12, + max_target: U256::from(&[0xff_u8; 32]), + }; + self.seal(MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, false, open) + } + + fn submit( + &mut self, + channel: &Channel, + sequence_number: u32, + job: &Job, + nonce: u32, + ) -> Result, FixtureError> { + let share = SubmitSharesStandard { + channel_id: channel.id, + sequence_number, + job_id: job.id().get(), + nonce, + ntime: job.ntime(), + version: job.version(), + }; + self.seal(MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, true, share) + } + } + + /// Runs the miner's half of `Noise_NX` against the pinned authority. + fn handshake( + connection: &mut Connection, + core: &PoolCore, + pinned: [u8; 32], + ) -> Result { + let initiator = + Handshake::initiator(Initiator::from_raw_k(pinned).map_err(sv2("authority key"))?); + let (act1, initiator) = initiator.step_0().map_err(sv2("handshake step 0"))?; + let mut encoder = NoiseEncoder::new(); + let wire = encoder.encode_handshake(act1); + let wire: &[u8] = wire.as_ref(); + core.with_job_book(|book| connection.feed(wire, book)) + .map_err(sv2("server feed"))?; + let reply = connection.take_transmit(); + let mut decoder = NoiseDecoder::new(); + let mut offset = 0; + let act2 = loop { + let window = decoder.writable(); + let end = offset + window.len(); + window.copy_from_slice( + reply + .get(offset..end) + .ok_or(FixtureError::Missing("handshake reply bytes"))?, + ); + offset = end; + match decoder + .next_handshake_frame::() + .map_err(sv2("handshake decode"))? + { + Decoded::Frame(frame) => break frame, + Decoded::Incomplete(_) => {} + } + }; + let act2: [u8; INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE] = act2 + .payload() + .try_into() + .map_err(|_| FixtureError::Missing("act 2 of the expected length"))?; + let transport = initiator.step_2(act2).map_err(sv2("handshake step 2"))?; + let (enc, dec) = transport.split(); + Ok(Miner { + enc, + dec: Some(dec), + encoder, + decoder: NoiseDecoder::new(), + inbuf: Vec::new(), + }) + } + + // ---- deterministic work ------------------------------------------------- + + fn header_hash(job: &Job, prefix: &[u8], nonce: u32) -> Result { + let (session, rolled) = prefix.split_at(job.extranonce_layout().session_len()); + job.header( + Extranonce { session, rolled }, + job.ntime(), + job.version(), + nonce, + ) + .map(|header| header.compute_hash()) + .map_err(sv2("header")) + } + + /// The first `count` nonces whose header meets the network target. + fn block_nonces(job: &Job, prefix: &[u8], count: usize) -> Result, FixtureError> { + nonces(job, prefix, true, count) + } + + /// The first nonce whose header misses the network target: a plain share. + fn share_nonce(job: &Job, prefix: &[u8]) -> Result { + nonces(job, prefix, false, 1)? + .pop() + .ok_or(FixtureError::NonceSpace(1)) + } + + /// Scans upward from zero over the whole `u32` space. The job fixes every + /// other header field, so the answer is fixed: no clock, no randomness. + fn nonces( + job: &Job, + prefix: &[u8], + meets_target: bool, + count: usize, + ) -> Result, FixtureError> { + let mut found = Vec::with_capacity(count); + for nonce in 0..u32::MAX { + let hash = header_hash(job, prefix, nonce)?; + if compact_is_met_by(job.bits(), Hash256::from(hash)) == meets_target { + found.push(nonce); + if found.len() == count { + return Ok(found); + } + } + } + Err(FixtureError::NonceSpace(count)) + } + + fn take(frames: &mut Vec, msg_type: u8) -> Result { + let at = frames + .iter() + .position(|frame| frame.msg_type == msg_type) + .ok_or(FixtureError::NoFrame(msg_type))?; + Ok(frames.remove(at)) + } + + fn has(frames: &[Frame], msg_type: u8) -> bool { + frames.iter().any(|frame| frame.msg_type == msg_type) + } + + /// `(sequence_number, error_code)` of the one `SubmitShares.Error` in `frames`. + fn share_error(frames: &mut Vec) -> Result<(u32, String), FixtureError> { + let mut frame = take(frames, MESSAGE_TYPE_SUBMIT_SHARES_ERROR)?; + let error: SubmitSharesError = + binary_sv2::from_bytes(&mut frame.payload).map_err(sv2("share error decode"))?; + Ok((error.sequence_number, error.error_code.as_utf8_or_hex())) + } + + // ---- the host under test ------------------------------------------------ + + struct Host { + core: PoolCore, + ledger: Arc, + control: Arc, + connection: Connection, + accounts: HashMap, + miner: Miner, + job: Arc, + _directory: tempfile::TempDir, + } + + impl Host { + fn new() -> Result> { + let directory = tempfile::tempdir()?; + let ledger = ledger_host(directory.path())?; + let control = Arc::new(FakeMiningControl::default()); + let core = pool_core(&control, &ledger)?; + let job = core.install(candidate(100, 0xab)?, Vec::new())?.job; + let authority = authority()?; + let config = server_config(&ledger)?; + let mut connection = core.with_job_book(|book| { + Connection::accept(&authority, Duration::from_secs(3_600), config, book) + })?; + let miner = handshake(&mut connection, &core, *authority.public())?; + Ok(Self { + core, + ledger, + control, + connection, + accounts: HashMap::new(), + miner, + job, + _directory: directory, + }) + } + + /// One `serve_connection` iteration: announce, reserve the block + /// record permit, feed, then the node's own `handle_events`. + fn deliver(&mut self, wire: &[u8]) -> Result, Box> { + announce_current_job(&mut self.connection, &self.core)?; + let permit = self + .ledger + .try_reserve_block_slot() + .ok_or("block record slot unavailable")?; + let mut permit = Some(permit); + self.core + .with_job_book(|book| self.connection.feed(wire, book))?; + handle_events( + &mut self.connection, + &self.core, + &self.ledger, + self.control.as_ref(), + &mut self.accounts, + &mut permit, + )?; + let sent = self.connection.take_transmit(); + Ok(self.miner.open(&sent)?) + } + + fn open_channel(&mut self) -> Result> { + let setup = self.miner.setup()?; + let frames = self.deliver(&setup)?; + assert!(has(&frames, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS)); + let open = self.miner.open_channel()?; + let mut frames = self.deliver(&open)?; + let mut frame = take( + &mut frames, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + )?; + let opened: OpenStandardMiningChannelSuccess = + binary_sv2::from_bytes(&mut frame.payload).map_err(sv2("open decode"))?; + let channel = Channel { + id: opened.channel_id, + prefix: opened.extranonce_prefix.as_bytes().to_vec(), + }; + assert_eq!(self.accounts.get(&channel.id), Some(&MinerId(42))); + Ok(channel) + } + + fn submit( + &mut self, + channel: &Channel, + sequence_number: u32, + job: &Job, + nonce: u32, + ) -> Result, Box> { + let wire = self.miner.submit(channel, sequence_number, job, nonce)?; + self.deliver(&wire) + } + + fn node_submissions(&self) -> usize { + self.control.submit_calls.load(Ordering::Relaxed) + } + + fn credited_shares(&self) -> Result> { + Ok(self.ledger.ledger().share_count()?) + } + } + + /// After the retirement the same connection carries work on a new parent. + fn new_parent_work_is_accepted(host: &mut Host, channel: &Channel) -> TestResult { + let next = host.core.install(candidate(101, 0xcd)?, Vec::new())?.job; + let nonce = share_nonce(&next, &channel.prefix)?; + let frames = host.submit(channel, 4, &next, nonce)?; + assert!(has(&frames, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS)); + assert_eq!(host.credited_shares()?, 2); + assert_eq!(host.node_submissions(), 1); + Ok(()) + } + + #[test] + fn applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node() -> TestResult { + let mut host = Host::new()?; + let channel = host.open_channel()?; + let job = Arc::clone(&host.job); + let winners = block_nonces(&job, &channel.prefix, 2)?; + let &[first, second] = winners.as_slice() else { + return Err("two winning nonces exist".into()); + }; + + // The node applies the first winner; the host acknowledges it only + // after the durable found-block record. + let applied = host.submit(&channel, 1, &job, first)?; + assert!(has(&applied, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS)); + assert_eq!(host.node_submissions(), 1); + let first_hash = header_hash(&job, &channel.prefix, first)?; + assert!(host.ledger.found_block_recorded(first_hash)?); + assert_eq!(host.credited_shares()?, 1); + + // A distinct winner on the same job is stale: the host's `Applied` + // handling is the only thing that retired the job. + let sibling = host.submit(&channel, 2, &job, second); + assert_eq!( + host.node_submissions(), + 1, + "sibling winner reached the node after an Applied block" + ); + let mut frames = sibling?; + assert_eq!(share_error(&mut frames)?, (2, "stale-share".to_owned())); + let second_hash = header_hash(&job, &channel.prefix, second)?; + assert!(!host.ledger.found_block_recorded(second_hash)?); + assert_eq!(host.credited_shares()?, 1); + + // The exact accepted replay keeps its duplicate identity. + let mut frames = host.submit(&channel, 3, &job, first)?; + assert_eq!(share_error(&mut frames)?, (3, "duplicate-share".to_owned())); + assert_eq!(host.node_submissions(), 1); + assert_eq!(host.credited_shares()?, 1); + + new_parent_work_is_accepted(&mut host, &channel) + } +} diff --git a/crates/stratum-v2/src/lib.rs b/crates/stratum-v2/src/lib.rs index 3c9ddb2a50..0aafac3c55 100644 --- a/crates/stratum-v2/src/lib.rs +++ b/crates/stratum-v2/src/lib.rs @@ -77,6 +77,7 @@ mod session; +pub use mining_sv2::ERROR_CODE_SUBMIT_SHARES_STALE_SHARE; pub use session::{ AdmitError, AdmitUser, BlockSubmission, ChannelInfo, ChannelKind, CloseReason, ERROR_CODE_INVALID_SEQUENCE_NUMBER, ERROR_CODE_JOB_SHARE_CAPACITY, ERROR_CODE_POOL_BUSY, @@ -772,6 +773,22 @@ impl Connection { }) } + /// The node rejected a found block as stale: send `SubmitShares.Error`. + /// + /// The token must come from [`PendingBlock::submit`]. Retires the block + /// ticket without closing the connection. + pub fn reject_block( + &mut self, + submission: &BlockSubmission, + code: &'static str, + ) -> Result { + self.guard(|conn| { + let sent = conn.session.reject_block(submission, code)?; + conn.flush()?; + Ok(sent) + }) + } + /// Retargets a channel or the group channel; see [`ProtocolSession::set_target`]. pub fn set_target(&mut self, id: u32, target: ShareTarget) -> Result<(), Error> { self.guard(|conn| { diff --git a/crates/stratum-v2/src/session.rs b/crates/stratum-v2/src/session.rs index 91ba18ad1c..fa8c99b204 100644 --- a/crates/stratum-v2/src/session.rs +++ b/crates/stratum-v2/src/session.rs @@ -1804,6 +1804,51 @@ impl ProtocolSession { Ok(true) } + /// The node rejected a found block as a stale sibling: tell the miner it + /// did not count without closing the connection. + /// + /// Only the token produced by [`PendingBlock::submit`] passes the ticket + /// and channel checks. Returns `false` when the ticket is unknown or its + /// channel closed meanwhile (nothing is sent). + pub fn reject_block( + &mut self, + submission: &BlockSubmission, + code: &'static str, + ) -> Result { + let Some(pending) = self.pending.get(&submission.ticket.0) else { + return Ok(false); + }; + if !matches!(pending.kind, PendingKind::Block) { + return Ok(false); + } + if pending.channel_id != submission.channel_id { + return Ok(false); + } + if pending.sequence_number != submission.sequence_number { + return Ok(false); + } + if pending.block_hash != Some(submission.block_hash) { + return Ok(false); + } + let Some(pending) = self.pending.remove(&submission.ticket.0) else { + return Ok(false); + }; + if !self.channels.contains_key(&pending.channel_id) { + return Ok(false); + } + let msg = SubmitSharesError { + channel_id: pending.channel_id, + sequence_number: pending.sequence_number, + error_code: code_str(code)?, + }; + self.queue( + MESSAGE_TYPE_SUBMIT_SHARES_ERROR, + CHANNEL_BIT_SUBMIT_SHARES_ERROR, + msg, + )?; + Ok(true) + } + /// The host could not record the share: tell the miner it did not count. /// /// `code` must be printable ASCII (for example [`ERROR_CODE_SHARE_NOT_RECORDED`]). diff --git a/crates/stratum-v2/tests/protocol.rs b/crates/stratum-v2/tests/protocol.rs index 0e8022ca63..a0a518696b 100644 --- a/crates/stratum-v2/tests/protocol.rs +++ b/crates/stratum-v2/tests/protocol.rs @@ -23,10 +23,14 @@ use bitcoin_rs_mempool::{ AdmissionChain, AdmissionOrigin, ChainAdmissionSnapshot, Mempool, MempoolGateway, MempoolLimits, SubmitOutcome, }; -use bitcoin_rs_mining::{Candidate, CandidateContext, CoinbaseBudget, assemble_candidate}; +use bitcoin_rs_mining::{ + BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CandidateContext, + CoinbaseBudget, GenerateRequest, GeneratedBlock, MiningControl, MiningControlError, MiningInfo, + STALE_PARENT_REASON, assemble_candidate, +}; use bitcoin_rs_primitives::{ - Amount, CompactTarget, Hash256, LockTime, Network, OutPoint, Script, Sequence, Tx, TxIn, TxOut, - Txid, Witness, encode::double_sha256, + Amount, BlockPayload, CompactTarget, Hash256, Header, LockTime, Network, OutPoint, Script, + Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::double_sha256, }; use bitcoin_rs_script::VerifyFlags; use bitcoin_rs_stratum::{ @@ -2368,3 +2372,350 @@ fn the_installed_fact_set_is_what_the_adapter_consumes() { assert!(fresh && clean && invalidated == 0); assert!(book.current().is_some_and(|job| job.id() == id)); } + +#[test] +fn accepted_block_retirement_makes_second_same_job_submit_stale() { + let mut s = standard_with_job(); + let version = s.job.version(); + let first = block_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + first, + s.job.ntime(), + version, + ); + let pending = s + .wire + .events() + .into_iter() + .find_map(|event| match event { + Event::BlockFound(pending) => Some(pending), + _ => None, + }) + .unwrap_or_else(|| panic!("first winning nonce is a block candidate")); + // Session-level consequence only: this call stands in for the host's + // synchronous retire on `Applied`, whose wiring lives in the node's SV2 + // `handle_events` and is not driven from this crate. + s.wire.book.invalidate_active(); + drop(pending); + // A second, distinct winning nonce on the same job. + let (session, rolled) = s + .opened + .prefix + .split_at(s.job.extranonce_layout().session_len()); + let second = (first + 1..u32::MAX) + .find(|nonce| { + let header = s + .job + .header( + Extranonce { session, rolled }, + s.job.ntime(), + version, + *nonce, + ) + .unwrap_or_else(|error| panic!("header builds: {error:?}")); + compact_is_met_by(s.job.bits(), Hash256::from(header.compute_hash())) + }) + .unwrap_or_else(|| panic!("a second winning nonce exists")); + let mut frames = s.wire.submit_standard( + s.opened.channel_id, + 2, + s.job.id().get(), + second, + s.job.ntime(), + version, + ); + let (_, sequence, code) = share_error(&mut frames); + assert_eq!((sequence, code.as_str()), (2, "stale-share")); + let events = s.wire.events(); + assert!( + events + .iter() + .all(|event| !matches!(event, Event::BlockFound(_))), + "stale share is never resubmitted to the node" + ); + assert!(matches!( + events.as_slice(), + [Event::ShareRejected { + code: "stale-share", + .. + }] + )); + // A new parent job is accepted on the same connection. + let installed = s + .wire + .book + .install(candidate(101, 0xcd, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + s.wire + .conn + .announce(&installed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + s.wire.drain(); + let nonce = share_nonce(&installed.job, &s.opened.prefix, installed.job.version()); + s.wire.submit_standard( + s.opened.channel_id, + 3, + installed.job.id().get(), + nonce, + installed.job.ntime(), + installed.job.version(), + ); + assert!(matches!( + s.wire.events().as_slice(), + [Event::ShareAccepted(_)] + )); +} + +fn unused_backend() -> MiningControlError { + MiningControlError::Unavailable("not used by this test".into()) +} + +/// Controlled backend boundary: answers every submitted block with the +/// source-verified stale-parent verdict and serves nothing else. +struct StaleParentControl; + +impl MiningControl for StaleParentControl { + fn get_block_template( + &self, + _request: BlockTemplateRequest, + ) -> Result { + Err(unused_backend()) + } + + fn mining_info(&self) -> Result { + Err(unused_backend()) + } + + fn network_hash_ps(&self, _lookup: i64, _height: i64) -> Result { + Err(unused_backend()) + } + + fn submit_block( + &self, + _payload: BlockPayload, + ) -> Result { + Ok(BlockValidationResult::Rejected(STALE_PARENT_REASON.into())) + } + + fn submit_header(&self, _header: Header) -> Result<(), MiningControlError> { + Err(unused_backend()) + } + + fn publish_generation(&self) {} + + fn publish_fee_delta_generation(&self, _fee_delta_sequence: u64) {} + + fn generate( + &self, + _request: GenerateRequest, + ) -> Result, MiningControlError> { + Err(unused_backend()) + } +} + +/// Session-level contract for a stale-parent receipt: the host answers the +/// ticket with `stale-share`, the ticket retires, and the connection stays +/// usable. Which receipts take this branch is decided by the node's SV2 +/// `handle_events`, not by this crate. +#[test] +fn stale_sibling_receipt_sends_stale_error_without_closing() { + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = block_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + let pending = s + .wire + .events() + .into_iter() + .find_map(|event| match event { + Event::BlockFound(pending) => Some(pending), + _ => None, + }) + .unwrap_or_else(|| panic!("a block is surfaced as pending host work")); + let control = StaleParentControl; + let submission = pending + .submit(&control) + .unwrap_or_else(|error| panic!("fake backend answers: {error:?}")); + assert!(matches!( + &submission.receipt().result, + BlockValidationResult::Rejected(reason) + if reason.as_str() == STALE_PARENT_REASON + )); + let sent = s + .wire + .conn + .reject_block(&submission, "stale-share") + .unwrap_or_else(|error| panic!("stale error queues: {error:?}")); + assert!(sent, "stale ticket produces one error frame"); + assert!( + !s.wire.conn.is_closing(), + "stale sibling keeps the connection usable" + ); + let mut frames = s.wire.drain(); + let (_, sequence, code) = share_error(&mut frames); + assert_eq!((sequence, code.as_str()), (1, "stale-share")); + let retired = s + .wire + .conn + .reject_block(&submission, "stale-share") + .unwrap_or_else(|error| panic!("repeat reject: {error:?}")); + assert!(!retired, "block ticket retires with the error frame"); + // A subsequent new job is still announced and usable. + let installed = s + .wire + .book + .install(candidate(101, 0xcd, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + s.wire + .conn + .announce(&installed) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let announced = s.wire.drain(); + assert!(!announced.is_empty(), "new parent job announced"); + let nonce = share_nonce(&installed.job, &s.opened.prefix, installed.job.version()); + s.wire.submit_standard( + s.opened.channel_id, + 2, + installed.job.id().get(), + nonce, + installed.job.ntime(), + installed.job.version(), + ); + assert!(matches!( + s.wire.events().as_slice(), + [Event::ShareAccepted(_)] + )); +} + +#[test] +fn malformed_reject_code_preserves_the_pending_ticket() { + // A malformed host code must not drop the acknowledgement: `reject_share` + // validates `code_str` before removing the ticket, so the pending share + // survives `BadErrorCode` and the valid refusal still lands. + let mut s = standard_with_job(); + let version = s.job.version(); + let nonce = share_nonce(&s.job, &s.opened.prefix, version); + s.wire.submit_standard( + s.opened.channel_id, + 1, + s.job.id().get(), + nonce, + s.job.ntime(), + version, + ); + let share = accepted(s.wire.events()); + let pending_before = s.wire.conn.session().pending_acks(); + assert_eq!(pending_before, 1); + assert!(matches!( + s.wire.conn.reject_share(share.ticket, "bad\ncode"), + Err(Error::Session(SessionError::BadErrorCode)) + )); + assert_eq!( + s.wire.conn.session().pending_acks(), + 1, + "malformed code leaves the ticket pending" + ); + assert!( + s.wire + .conn + .reject_share( + share.ticket, + bitcoin_rs_stratum_v2::ERROR_CODE_SHARE_NOT_RECORDED + ) + .unwrap_or_else(|error| panic!("valid refusal sends: {error:?}")) + ); + let mut frames = s.wire.drain(); + let (channel, sequence, code) = share_error(&mut frames); + assert_eq!( + (channel, sequence, code.as_str()), + (s.opened.channel_id, 1, "share-not-recorded") + ); +} + +#[test] +fn full_event_queue_fails_before_share_validation_without_orphan_ticket() { + // `SetupCompleted` plus `ChannelOpened` fill both event slots, so `submit` + // must fail with `Backlog` before `book.validate` records dedup state or + // inserts a pending ticket. (A single slot would already panic at open: + // `setup_mining` queues `SetupCompleted` before `ChannelOpened`.) + let mut config = server_config(); + config.limits.max_pending_events = 2; + config.limits.max_pending_acks = 8; + let mut wire = Wire::with(WHOLE, config, BIP320_VERSION_MASK); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + let mut frames = wire.open_standard(7, "alice.rig1", ALL_ONES); + let opened = read_open_standard(&mut frames); + let job = read_std_job(&mut frames); + assert_eq!(job.job_id, installed.job.id().get()); + read_prev_hash(&mut frames); + // Setup and open left both event slots occupied; do not drain them. + assert_eq!(wire.conn.session().pending_acks(), 0); + let version = installed.job.version(); + let nonce = share_nonce(&installed.job, &opened.prefix, version); + let msg = SubmitSharesStandard { + channel_id: opened.channel_id, + sequence_number: 1, + job_id: installed.job.id().get(), + nonce, + ntime: installed.job.ntime(), + version, + }; + // Both event slots are occupied, so validation cannot proceed and no + // pending ack is left behind. + assert!(matches!( + wire.try_exchange(MESSAGE_TYPE_SUBMIT_SHARES_STANDARD, true, msg), + Err(Error::Session(SessionError::Backlog { queue: "event", .. })) + )); + assert_eq!( + wire.conn.session().pending_acks(), + 0, + "no orphan ticket is inserted when the event queue is full" + ); +} + +#[test] +fn announce_without_fresh_still_delivers_a_missing_job_and_parent() { + // `announce` is driven by per-connection delivery state, not the owner's + // `fresh` flag: a channel opened before the install still converges on + // the current job and its activating `SetNewPrevHash` when the owner + // reports `fresh == false` (same template re-install). + let mut wire = Wire::new(); + wire.setup_mining(FLAG_REQUIRES_STANDARD_JOBS); + let mut frames = wire.open_standard(7, "late.rig1", ALL_ONES); + let opened = read_open_standard(&mut frames); + assert!(wire.drain().is_empty()); + let first = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(first.fresh); + // Same template, time, and outputs: the owner reports nothing new. + let second = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("reinstall: {error:?}")); + assert!(!second.fresh); + wire.conn + .announce(&second) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let mut announced = wire.drain(); + let job = read_std_job(&mut announced); + assert_eq!(job.job_id, first.job.id().get()); + let prev = read_prev_hash(&mut announced); + assert_eq!(prev.job_id, first.job.id().get()); + assert_eq!(opened.channel_id, prev.channel_id); +} diff --git a/crates/stratum/src/job.rs b/crates/stratum/src/job.rs index d5e3da4755..55c3abc304 100644 --- a/crates/stratum/src/job.rs +++ b/crates/stratum/src/job.rs @@ -24,7 +24,7 @@ use bitcoin_rs_consensus::transaction_sigop_cost; use bitcoin_rs_mining::{ BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CoinbaseBudget, MiningControl, MiningControlError, - MiningError, + MiningError, STALE_PARENT_REASON, }; use bitcoin_rs_primitives::{ Amount, Block, BlockHash, BlockPayload, CompactTarget, Hash256, Header, Script, Tx, TxOut, @@ -678,6 +678,17 @@ pub struct BlockReceipt { } impl BlockReceipt { + /// Whether the node refused the block only because its parent is no longer + /// the applied tip: a stale sibling, not a defective block. This is the one + /// reading of [`STALE_PARENT_REASON`] every pool adapter shares. + #[must_use] + pub fn is_stale_parent(&self) -> bool { + matches!( + &self.result, + BlockValidationResult::Rejected(reason) if reason.as_str() == STALE_PARENT_REASON + ) + } + /// Maps the node verdict onto the three outcomes a ledger can act on. #[must_use] pub fn status(&self) -> BlockStatus { @@ -1031,6 +1042,41 @@ impl Job { Ok(()) } + /// The header `share` hashes to under `extranonce`, after the field rules + /// every share obeys: extranonce lengths, version mask and `ntime` window. + /// The one home of those rules, shared by live validation and the + /// stale-job replay probe so the two cannot disagree. + fn checked_header( + &self, + extranonce: Extranonce<'_>, + share: &ShareSubmission<'_>, + scratch: &mut Vec, + ) -> Result { + self.check_extranonce(extranonce)?; + if (share.version ^ self.version) & !self.version_mask != 0 { + return Err(ShareError::VersionOutsideMask { + version: share.version, + job_version: self.version, + mask: self.version_mask, + }); + } + let min_ntime = self.min_ntime(); + if share.ntime < min_ntime { + return Err(ShareError::TimeTooEarly { + ntime: share.ntime, + min_ntime, + }); + } + if share.ntime > self.max_ntime { + return Err(ShareError::TimeTooLate { + ntime: share.ntime, + max_ntime: self.max_ntime, + }); + } + let root = self.merkle_root_with(extranonce, scratch)?; + Ok(self.header_from(root, share.ntime, share.version, share.nonce)) + } + fn coinbase_hash_with( &self, extranonce: Extranonce<'_>, @@ -1420,9 +1466,12 @@ impl JobBook { /// /// Checks, in order: session, job existence, job staleness, extranonce /// lengths, version mask, `ntime` window, duplicate, network target, share - /// target, per-job capacity. A header that meets the network target is a - /// block candidate even when it misses `target`, and is never refused for - /// capacity. Only accepted shares are remembered, so rejected input + /// target, per-job capacity. A stale job refuses with + /// [`ShareError::Duplicate`] when the share is an exact replay of one + /// already accepted (same header under the same field rules) and with + /// [`ShareError::Stale`] otherwise. A header that meets the network target + /// is a block candidate even when it misses `target`, and is never refused + /// for capacity. Only accepted shares are remembered, so rejected input /// cannot consume duplicate-set capacity. pub fn validate( &mut self, @@ -1438,41 +1487,30 @@ impl JobBook { .jobs .get(index) .ok_or(ShareError::UnknownJob(share.job))?; - if let JobState::Stale(reason) = entry.state { - return Err(ShareError::Stale { - job: share.job, - reason, - }); - } let job = &entry.job; let extranonce = Extranonce { session: session.extranonce_prefix(), rolled: share.rolled_extranonce, }; - job.check_extranonce(extranonce)?; - if (share.version ^ job.version) & !job.version_mask != 0 { - return Err(ShareError::VersionOutsideMask { - version: share.version, - job_version: job.version, - mask: job.version_mask, - }); - } - let min_ntime = job.min_ntime(); - if share.ntime < min_ntime { - return Err(ShareError::TimeTooEarly { - ntime: share.ntime, - min_ntime, - }); - } - if share.ntime > job.max_ntime { - return Err(ShareError::TimeTooLate { - ntime: share.ntime, - max_ntime: job.max_ntime, + if let JobState::Stale(reason) = entry.state { + // An exact retransmit keeps its duplicate identity: the identical + // header was already judged, so the miner learns `Duplicate` + // rather than `Stale`. Any other share on a retired job is stale. + let replayed = job + .checked_header(extranonce, share, &mut self.scratch) + .ok() + .map(|header| header.compute_hash()); + if let Some(hash) = replayed + && let Some(&first_job) = self.seen.get(&Hash256::from(hash)) + { + return Err(ShareError::Duplicate { hash, first_job }); + } + return Err(ShareError::Stale { + job: share.job, + reason, }); } - - let root = job.merkle_root_with(extranonce, &mut self.scratch)?; - let header = job.header_from(root, share.ntime, share.version, share.nonce); + let header = job.checked_header(extranonce, share, &mut self.scratch)?; let hash = header.compute_hash(); let raw_hash = Hash256::from(hash); if let Some(&first_job) = self.seen.get(&raw_hash) { diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index feef8d5f0e..0deb706021 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -786,7 +786,12 @@ impl PoolCore { let mut attempt = 0; loop { match found.submit(&*self.control) { - Ok(receipt) => return Ok(receipt), + Ok(receipt) => { + if receipt.status() == BlockStatus::Applied { + self.invalidate_active(); + } + return Ok(receipt); + } Err( MiningControlError::Unavailable(message) | MiningControlError::Failed(message), ) => { @@ -993,6 +998,8 @@ impl PoolCore { Err(_) => BlockOutcome::NodeError, }; let hash = found.share.hash; + let job = found.share.job; + let stale_parent = submission.as_ref().is_ok_and(BlockReceipt::is_stale_parent); let completion = Arc::new(Completion::new(2)); let share_item = Pending::Share { worker: worker.clone(), @@ -1019,6 +1026,13 @@ impl PoolCore { completion.record(); } + if stale_parent { + return Err(SubmitFailure::Share(ShareError::Stale { + job, + reason: StaleReason::Invalidated, + })); + } + Ok(Credited { hash, class: ShareClass::Block, diff --git a/crates/stratum/tests/jobs.rs b/crates/stratum/tests/jobs.rs index b42a0a9ea4..925d260af9 100644 --- a/crates/stratum/tests/jobs.rs +++ b/crates/stratum/tests/jobs.rs @@ -1244,3 +1244,52 @@ fn templates_are_requested_with_the_declared_budget() -> TestResult { )); Ok(()) } + +#[test] +fn accepted_block_retirement_makes_second_same_job_nonce_stale() -> TestResult { + let mut fx = fixture(config(), 100, 2)?; + let target = ShareTarget::from_pool_difficulty(1)?; + let winning = nonces(&fx.session, &fx.job, true, 2)?; + let outcome = fx + .book + .validate(&submit_for(&fx.session, &fx.job, winning[0]), &target)?; + assert!( + matches!(outcome, ShareOutcome::Block(_)), + "first known winning nonce is accepted" + ); + // Mirrors the synchronous retire on `Applied` in both submit paths. + fx.book.invalidate_active(); + assert!(fx.book.current().is_none()); + let err = fx + .book + .validate(&submit_for(&fx.session, &fx.job, winning[1]), &target) + .err(); + assert_eq!( + err, + Some(ShareError::Stale { + job: fx.job.id(), + reason: StaleReason::Invalidated + }), + "second winning nonce on the same job is stale and never resubmitted" + ); + let replay = fx + .book + .validate(&submit_for(&fx.session, &fx.job, winning[0]), &target) + .err(); + assert!( + matches!(replay, Some(ShareError::Duplicate { .. })), + "exact replay of an accepted share keeps duplicate identity, not stale" + ); + let next = fx + .book + .install(build_candidate(101, 0xcd, 0)?, Vec::new())?; + assert!(next.clean_jobs, "new parent job is fresh"); + let share = nonces(&fx.session, &next.job, false, 1)?[0]; + assert!( + fx.book + .validate(&submit_for(&fx.session, &next.job, share), &easy()?) + .is_ok(), + "new parent job accepts shares" + ); + Ok(()) +} diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs index ddf6fbfa7b..54a42d8053 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/stratum/tests/protocol.rs @@ -18,17 +18,19 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; +use bitcoin_rs_chain::compact_is_met_by; use bitcoin_rs_mining::{ - Candidate, CoinbaseBudget, FakeMiningControl, MiningControl, MiningControlError, TemplateId, + BlockValidationResult, Candidate, CoinbaseBudget, FakeMiningControl, MiningControl, + MiningControlError, STALE_PARENT_REASON, TemplateId, }; use bitcoin_rs_primitives::{ Amount, CompactTarget, Hash256, LockTime, OutPoint, Script, Sequence, Tx, TxIn, TxOut, Witness, }; use bitcoin_rs_stratum::{ - AcceptedShare, ConnectionLimiter, DifficultyPolicy, ExtranonceLayout, FoundBlock, FrameError, - JobBookConfig, LineBuffer, Observer, PoolCore, RecordOutcome, RefusalReason, Server, - ServerConfig, ServerDeps, ServerEvent, ShareLedger, StaticAccountResolver, prevhash_from_wire, - prevhash_to_wire, swap_words, + AcceptedShare, ConnectionLimiter, DifficultyPolicy, Extranonce, ExtranonceLayout, FoundBlock, + FrameError, Job, JobBookConfig, LineBuffer, Observer, PoolCore, RecordOutcome, RefusalReason, + Server, ServerConfig, ServerDeps, ServerEvent, ShareLedger, StaticAccountResolver, + prevhash_from_wire, prevhash_to_wire, swap_words, }; type TestResult = Result<(), Box>; @@ -783,3 +785,248 @@ fn connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions() { drop(first); } } + +/// Extranonce2 every share of the retirement tests submits (`"00000001"`). +const ROLLED: [u8; 4] = [0, 0, 0, 1]; + +/// One authorized SV1 miner on a pool holding a single regtest job, plus the +/// fields every share of the retirement tests submits. +struct MinerFixture { + server: Server, + client: TestClient, + pool: Arc, + control: Arc, + ledger: Arc, + job: Arc, + prefix: [u8; 4], + version: u32, +} + +impl MinerFixture { + /// Boots the pool on a regtest candidate whose parent hash is `parent` + /// repeated, then walks one miner through configure, subscribe, authorize + /// and the first `mining.notify`. + fn start(parent: u8) -> Self { + let candidate = Arc::new(sample_candidate( + Hash256::from_le_bytes(&[parent; 32]), + CompactTarget::from_consensus(0x207f_ffff), + 1, + )); + let control = Arc::new(FakeMiningControl::default()); + let ledger = Arc::new(TestLedger::default()); + let pool = build_pool(control.clone(), ledger.clone()); + let installed = pool + .install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("alice", "secret123") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let policy = DifficultyPolicy::new("0.0000000001", "0.00000000001", "1000") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let server = Server::bind( + "127.0.0.1:0", + ServerConfig::new(policy), + ServerDeps { + pool: pool.clone(), + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut client = TestClient::connect(server.local_addr()); + + client.send("{\"id\":1,\"method\":\"mining.configure\",\"params\":[[\"version-rolling\"],{\"version-rolling.mask\":\"1fffe000\",\"version-rolling.min-bit-count\":2}]}\n"); + assert!(client.read_line().contains("\"id\":1")); + client.send("{\"id\":2,\"method\":\"mining.subscribe\",\"params\":[\"miner/1.0.0\"]}\n"); + let subscribed = client.read_line(); + assert!(subscribed.contains("\"id\":2")); + // The session prefix the server assigned: headers are rebuilt locally. + let ext1_hex = subscribed + .split("]],\"") + .nth(1) + .and_then(|tail| tail.split('"').next()) + .unwrap_or_else(|| panic!("subscribe carries extranonce1: {subscribed}")); + assert_eq!(ext1_hex.len(), 8, "4-byte session prefix"); + let prefix = u32::from_str_radix(ext1_hex, 16) + .unwrap_or_else(|error| panic!("hex session prefix: {error:?}")) + .to_be_bytes(); + // Exactly what the server derives from `version_bits` "00002000". + let version = installed + .job + .version_from_rolled_bits(0x2000) + .unwrap_or_else(|error| panic!("mask-clean version bits: {error:?}")); + client.send( + "{\"id\":3,\"method\":\"mining.authorize\",\"params\":[\"alice.worker1\",\"secret123\"]}\n", + ); + assert_eq!( + client.read_line(), + "{\"id\":3,\"result\":true,\"error\":null}\n" + ); + // Drain the server-pushed difficulty and job notification. + let mut seen_notify = false; + for _ in 0..4 { + if client.read_line().contains("\"method\":\"mining.notify\"") { + seen_notify = true; + break; + } + } + assert!(seen_notify, "installed job is announced"); + + Self { + server, + client, + pool, + control, + ledger, + job: installed.job, + prefix, + version, + } + } + + /// First nonce at or after `start` whose header meets the network target: + /// the same scan as the SV2 tests' `block_nonce`, so winning nonces are + /// known before anything is submitted. + fn winning_nonce(&self, start: u32) -> u32 { + (start..=u32::MAX) + .find(|nonce| { + let header = self + .job + .header( + Extranonce { + session: &self.prefix, + rolled: &ROLLED, + }, + self.job.ntime(), + self.version, + *nonce, + ) + .unwrap_or_else(|error| panic!("header builds: {error:?}")); + compact_is_met_by(self.job.bits(), Hash256::from(header.compute_hash())) + }) + .unwrap_or_else(|| panic!("a winning nonce exists")) + } + + /// Sends `mining.submit` for `nonce` on `job` and returns the response + /// line that carries `id`. + fn submit(&mut self, id: u32, job: &Job, nonce: u32) -> String { + let (job_id, ntime) = (job.id().get(), job.ntime()); + self.client.send(&format!( + "{{\"id\":{id},\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{job_id:08x}\",\"00000001\",\"{ntime:08x}\",\"{nonce:08x}\",\"00002000\"]}}\n", + )); + for _ in 0..8 { + let line = self.client.read_line(); + if line.contains(&format!("\"id\":{id}")) { + return line; + } + } + panic!("no response to submit {id}"); + } +} + +#[test] +fn applied_block_retires_job_and_second_submit_is_stale() { + let mut miner = MinerFixture::start(0x22); + let job = Arc::clone(&miner.job); + // Two distinct winning nonces, known before anything is submitted. + let first = miner.winning_nonce(0); + let second = miner.winning_nonce(first + 1); + assert_ne!(first, second); + + // The first winning nonce is accepted, and the applied block retires the + // job synchronously. + let accepted = miner.submit(100, &job, first); + assert!( + accepted.contains("\"error\":null"), + "first winning nonce accepted: {accepted}" + ); + assert!( + miner.pool.current_installed().is_none(), + "applied block retired the job" + ); + assert_eq!( + miner.control.submit_calls.load(Ordering::SeqCst), + 1, + "exactly one block reached the node" + ); + + // The second winning nonce on the same retired job is stale, not + // resubmitted, and the connection stays usable. + let stale = miner.submit(200, &job, second); + assert!( + stale.contains("\"error\":[21,\"Stale share\""), + "retired job submit is a stale protocol error: {stale}" + ); + assert_eq!( + miner.control.submit_calls.load(Ordering::SeqCst), + 1, + "stale share never resubmitted to the node" + ); + + // A new parent job is accepted on the same connection. + let next = miner + .pool + .install( + Arc::new(sample_candidate( + Hash256::from_le_bytes(&[0x33; 32]), + CompactTarget::from_consensus(0x207f_ffff), + 2, + )), + Vec::new(), + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let fresh = miner.submit(300, &next.job, 0); + assert!( + fresh.contains("\"error\":null"), + "new parent job accepted: {fresh}" + ); + + miner.server.shutdown(); +} + +/// An already-validated share whose block the node rejects as a stale parent +/// is a `STALE_SHARE` protocol error, not success: the ledger record stays +/// durable, nothing else is submitted, and the connection remains usable. +#[test] +fn stale_parent_backend_verdict_is_stale_error() { + let mut miner = MinerFixture::start(0x44); + let job = Arc::clone(&miner.job); + + // The backend answers with the node's stale-parent verdict. + *miner.control.submit.lock() = BlockValidationResult::Rejected(STALE_PARENT_REASON.into()); + let winner = miner.winning_nonce(0); + let stale = miner.submit(100, &job, winner); + assert!( + stale.contains("\"error\":[21,\"Stale share\""), + "stale-parent receipt is a stale protocol error: {stale}" + ); + assert_eq!( + miner.control.submit_calls.load(Ordering::SeqCst), + 1, + "exactly one block reached the node" + ); + { + let blocks = miner + .ledger + .blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert_eq!(blocks.len(), 1, "rejected block stays durably recorded"); + } + assert!( + miner.pool.current_installed().is_some(), + "no retire without an applied block" + ); + + // The connection remains usable: a later share is accepted. + *miner.control.submit.lock() = BlockValidationResult::Accepted; + let later = miner.submit(200, &job, winner + 1); + assert!( + later.contains("\"error\":null"), + "connection usable after stale error: {later}" + ); + + miner.server.shutdown(); +} diff --git a/docs/contracts/pool.md b/docs/contracts/pool.md index fafcb25fdf..bf64891694 100644 --- a/docs/contracts/pool.md +++ b/docs/contracts/pool.md @@ -59,6 +59,8 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Both protocols MUST derive jobs and validate work through the shared native pool owner backed by `MiningControl`. Protocol adapters MUST NOT introduce a second template or consensus validator. - A found block MUST be submitted through node mining control before its accounting record is finalized. Submission outcome MUST NOT substitute for active-chain evidence. Applied, duplicate, rejected, missing-parent, and unavailable submission results do not by themselves authorize a reward. +- After an `Applied` block receipt, the shared active job MUST be retired synchronously: `PoolCore::submit_block_to_node` on the SV1 path and `handle_events` on the native SV2 path MUST call `invalidate_active` before ledger, outbox, or transport acknowledgement. Distinct work naming a retired job MUST be rejected as stale; a byte-identical replay of an already accepted header MUST keep its duplicate identity (SV1 21 versus 22; SV2 `stale-share` versus `duplicate-share`) and MUST NOT receive a second credit. +- An exact `inconclusive-not-best-prevblk` receipt on an already in-flight sibling MUST still complete its durable block/share record and then be answered as a nonfatal stale error. Any other node rejection MUST NOT be answered as stale. - SV2 `SubmitShares.Success` MUST remain deferred until host confirmation of the durable accounting outcome. An idempotently recorded share MAY satisfy that confirmation; a failed or merely queued record MUST NOT be presented as committed. - SV1 success MUST wait for a completion token covering the required durable share write and, for a found block, its block-record write. Failed inline writes MAY remain in the bounded volatile outbox, but queueing alone MUST NOT complete the response. - Each SV1 session MUST retain at most one pending accounting response. Additional submissions while it is pending MUST be rejected before validation with a retryable accounting error. Polling MUST send the deferred success only after accounting completion. Process failure MAY lose queued work before completion, but such work MUST NOT already have received success. diff --git a/docs/pool.md b/docs/pool.md index 2a61b9e7c3..e0484c60a6 100644 --- a/docs/pool.md +++ b/docs/pool.md @@ -92,6 +92,7 @@ PPLNS fixes each found block's work window at its solving share. A submission re Within the configured reorg-watch horizon, displacement of a matured block reverses its allocations. Available funds are removed first; amounts already reserved or paid create a deficit that future credits offset. A lower or equal replacement tip does not hide displacement because canonicality is hash-based. Unknown membership is not positive canonicality evidence. Monitoring is bounded by the configured watch depth; this is not an indefinite deep-reorg guarantee. Both mining protocols share the configured total and per-IP connection limiter. They also share the job and accounting owners. The runtime bounds retained jobs at 64, owner sessions at twice the configured connection limit, and duplicate-share tracking at 65,536 entries per job. The node explicitly sets the SV1 deferred ledger outbox to 1024 records and 8 MiB, overriding the standalone core defaults of 4096 records and 32 MiB. Found-block recovery has a shared eight-slot admission budget with 4,000,000 bytes charged per slot; failed records retain a compact ledger projection, not complete blocks. These are distinct budgets, not a total-process memory bound. A queue-pressure rejection is not an accepted share. SV1 retains failed writes in its bounded in-memory outbox but withholds success until the required share record and, for a found block, block record complete. Each SV1 session permits only one pending accounting response; additional submissions are rejected before validation with a retryable accounting error. Process failure can lose queued work before completion, but that work has not received a success response. SV2 likewise holds success until the host confirms the ledger record. Operators MUST NOT equate queued work with committed accounting. + Once the node accepts a found block, the current job is retired at once: new and different shares on the old job are refused as stale (SV1 error 21, SV2 `stale-share`), while resending the exact accepted share reports a duplicate (SV1 error 22, SV2 `duplicate-share`) without extra credit. A block the backend answers with `inconclusive-not-best-prevblk` is still recorded, then refused as a nonfatal stale share; other node rejections are never answered as stale. The [pool contract](contracts/pool.md) states the authoritative rule. ## Evidence and operational limits From 33ad2cfd98cb6c35bf8d19d0eb005a41b8bd9bc9 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:19:06 +0900 Subject: [PATCH 03/61] Recognize pool RPCs in the shipped inventory The registry includes the native pool methods, but the feature gate classified them as unshipped. Honor the pool build feature so the inventory agrees with dispatch in both build modes. --- crates/rpc/src/manifest.rs | 3 +-- docs/pool.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/rpc/src/manifest.rs b/crates/rpc/src/manifest.rs index 6e45bd8541..1af79ce13c 100644 --- a/crates/rpc/src/manifest.rs +++ b/crates/rpc/src/manifest.rs @@ -214,9 +214,8 @@ fn feature_active(feature: &str) -> bool { match feature { "" => true, "zmq" => cfg!(feature = "zmq"), + "pool" => cfg!(feature = "pool"), "silent-payments" => cfg!(feature = "silent-payments"), - // An unknown feature name can never be active here; the coverage test - // rejects such a row outright. _ => false, } } diff --git a/docs/pool.md b/docs/pool.md index e0484c60a6..05b7e47a1a 100644 --- a/docs/pool.md +++ b/docs/pool.md @@ -57,7 +57,7 @@ The input buffer, decoded secret, and stored authority secret use `Zeroizing`; t ## Register miners and inspect accounting -Use the authenticated management JSON-RPC listener. Pool methods are unavailable without the compiled feature; with the feature compiled but the runtime off, they report `MethodDisabled`. Parameters are positional arrays. +Use the authenticated management JSON-RPC listener. The dispatcher and its shipped-method inventory include pool methods only when the `pool` feature is compiled. The `pool-sv2` feature also enables `pool`. With the feature compiled but the runtime off, these methods report `MethodDisabled`. Parameters are positional arrays. | Method | Parameters | Result or effect | | --- | --- | --- | From 0c46a2f5e39a1a2e52811cd889ad408c8a159519 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:17:26 +0900 Subject: [PATCH 04/61] Share the Stratum response envelope --- crates/stratum/src/sv1.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs index 0f2dc19423..b45c63a8bb 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/stratum/src/sv1.rs @@ -1117,35 +1117,33 @@ impl ClientRequest { // Encoders // --------------------------------------------------------------------------- -/// `{"id":ID,"result":RESULT,"error":null}\n` #[must_use] -pub fn encode_result(id: &RequestId, result_json: &str) -> String { +fn encode_envelope(id: &RequestId, result_fragment: &str, error_fragment: &str) -> String { format!( - "{{\"id\":{},\"result\":{},\"error\":null}}\n", + "{{\"id\":{},\"result\":{},\"error\":{}}}\n", id.as_json(), - result_json + result_fragment, + error_fragment ) } +/// `{"id":ID,"result":RESULT,"error":null}\n` +#[must_use] +pub fn encode_result(id: &RequestId, result_json: &str) -> String { + encode_envelope(id, result_json, "null") +} + /// `{"id":ID,"result":null,"error":[code,"message",null]}\n` #[must_use] pub fn encode_error(id: &RequestId, error: &WireError) -> String { - format!( - "{{\"id\":{},\"result\":null,\"error\":{}}}\n", - id.as_json(), - error.to_json() - ) + encode_envelope(id, "null", &error.to_json()) } /// `{"id":ID,"result":false,"error":[code,"message",null]}\n`: a refused /// authorize or share. #[must_use] pub fn encode_reject(id: &RequestId, error: &WireError) -> String { - format!( - "{{\"id\":{},\"result\":false,\"error\":{}}}\n", - id.as_json(), - error.to_json() - ) + encode_envelope(id, "false", &error.to_json()) } /// `mining.set_difficulty` notification. From ba57559d7dc8bb525958cc3bd9a68e2916d3c2d3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:29:14 +0900 Subject: [PATCH 05/61] Refresh the standalone template provider lock Resolve the current path-crate dependency graph so locked tool builds work again. Preserve existing external versions and align the two added cryptographic packages with the root lockfile. --- .../stratum-sv2/template-provider/Cargo.lock | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tools/stratum-sv2/template-provider/Cargo.lock b/tools/stratum-sv2/template-provider/Cargo.lock index 1a960f55f8..bb9fcab80a 100644 --- a/tools/stratum-sv2/template-provider/Cargo.lock +++ b/tools/stratum-sv2/template-provider/Cargo.lock @@ -284,10 +284,12 @@ dependencies = [ "compact_str", "crossbeam-channel", "hashbrown 0.17.1", + "metrics", "parking_lot", "rayon", "serde", "sha2 0.11.1", + "sonic-rs", "thiserror", "tracing", "zerocopy", @@ -386,6 +388,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2 0.11.1", @@ -401,6 +404,7 @@ dependencies = [ "bytemuck", "ruint", "sha2 0.11.1", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -662,7 +666,7 @@ dependencies = [ "cap-primitives", "cap-std", "io-lifetimes 3.0.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -679,7 +683,7 @@ dependencies = [ "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1109,7 +1113,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1166,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1692,6 +1696,16 @@ dependencies = [ "serde", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + [[package]] name = "konst" version = "0.2.20" @@ -1970,7 +1984,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2616,7 +2630,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2664,7 +2678,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.13.1", + "bitcoin_hashes 0.14.101", "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", @@ -2781,6 +2795,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2859,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2920,7 +2944,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3020,7 +3044,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From 8887326dedfcec5f3aad094d3655728508cb0d7c Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:25:57 +0900 Subject: [PATCH 06/61] Gate pool-only SRI test deps on pool-sv2 and align DEP-02 crates/node: make the five SRI/Noise dev-deps (binary_sv2, codec_sv2, common_messages_sv2, mining_sv2, noise_sv2) optional and enable them through the existing pool-sv2 feature. Only the pool/sv2.rs host-path regression uses them, and it compiles solely under pool-sv2, so non-pool test builds no longer resolve the Noise/secp duplicate-crypto chain. docs(contracts): record the approved pool-only DEP-02 exception for the pinned noise_sv2 2.0.0 family (secp256k1 0.28.2 / secp256k1-sys 0.9.2 / bitcoin_hashes 0.13.1 exact-version skips) and the SRI pin maintenance rule. No new splits; deny still fails any other duplicate. --- crates/node/Cargo.toml | 21 +++++++++++++-------- docs/contracts/dependency-range.md | 28 +++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index cead95c220..5f9adbb7fb 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -60,7 +60,7 @@ kernel = ["bitcoin-rs-chainstate/kernel"] zmq = ["bitcoin-rs-rpc/zmq"] silent-payments = ["bitcoin-rs-rpc/silent-payments"] pool = ["bitcoin-rs-rpc/pool", "dep:bitcoin-rs-stratum", "dep:bitcoin-rs-pool-ledger"] -pool-sv2 = ["pool", "dep:bitcoin-rs-stratum-v2", "dep:zeroize"] +pool-sv2 = ["pool", "dep:bitcoin-rs-stratum-v2", "dep:zeroize", "dep:binary_sv2", "dep:codec_sv2", "dep:common_messages_sv2", "dep:mining_sv2", "dep:noise_sv2"] [dependencies] hashbrown.workspace = true @@ -80,6 +80,18 @@ bitcoin-rs-stratum = { workspace = true, optional = true } bitcoin-rs-stratum-v2 = { workspace = true, optional = true } bitcoin-rs-pool-ledger = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } +# Pool-only SRI crates, used solely by the `pool/sv2.rs` host-path regression +# (which compiles only under `pool-sv2`). Optional normal dependencies: Cargo +# forbids `optional` dev-dependencies and does not honor `cfg(feature)` in +# target dev tables, so this is the supported wiring that keeps non-pool +# graphs from resolving the Noise/secp duplicate-crypto chain documented in +# deny.toml. The same pinned SRI release family the pool adapter links, never +# a second wire version. +binary_sv2 = { workspace = true, optional = true } +codec_sv2 = { workspace = true, features = ["noise_sv2"], optional = true } +common_messages_sv2 = { workspace = true, optional = true } +mining_sv2 = { workspace = true, optional = true } +noise_sv2 = { workspace = true, optional = true } bitcoin.workspace = true bytes.workspace = true @@ -122,13 +134,6 @@ rustix.workspace = true bitcoin-rs-script.workspace = true bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } -# Miner side of the SV2 host-path regression in `pool/sv2.rs`: the same pinned -# SRI release family the pool adapter links, never a second wire version. -binary_sv2.workspace = true -codec_sv2 = { workspace = true, features = ["noise_sv2"] } -common_messages_sv2.workspace = true -mining_sv2.workspace = true -noise_sv2.workspace = true [[bench]] name = "sync_pipeline" diff --git a/docs/contracts/dependency-range.md b/docs/contracts/dependency-range.md index ee672349bf..e97e1118ca 100644 --- a/docs/contracts/dependency-range.md +++ b/docs/contracts/dependency-range.md @@ -31,11 +31,23 @@ contract. `--all-features` resolve and the maximum lane's default-feature resolve — for `bitcoin`, `bitcoin_hashes`, `secp256k1`, and `secp256k1-sys`. -- Each of those crates resolves to exactly one version per graph: - `deny.toml` `multiple-versions = "deny"` fails any second version of - any crate, and those four names must not gain a `[bans].skip` entry, - including `crate@version`. Package-id cardinality beyond one version - per name is not mechanically checked; lockfile review pins it. +- Each of those crates resolves to exactly one version per graph, with one + approved pool-only exception: the pinned SRI release family + (`noise_sv2 2.0.0`, reachable only through the `pool-sv2` feature) carries + its own audited `secp256k1 0.28.2` / `secp256k1-sys 0.9.2` authority-key line + and `bitcoin_hashes 0.13.1` transport line. Those exact versions are pinned + in `deny.toml` `[bans].skip` with the `noise_sv2 2.0.0` reason; + `multiple-versions = "deny"` still fails any other second version, and no + further skip for these four names is approved. Package-id cardinality beyond + one version per name is not mechanically checked; lockfile review pins it. +- Pool-only SRI crates (the Noise-family dependencies in + `crates/node/Cargo.toml`) are optional normal dependencies enabled by the + existing `pool-sv2` feature — Cargo forbids `optional` dev-dependencies and + does not honor `cfg(feature)` in target dev tables — so the + default-feature (maximum-lane) graph never resolves the exception chain; + only the `--all-features` (minimal-lane) graph does, where the exact-version + skips above apply. Only the `pool-sv2`-gated `pool/sv2.rs` test module uses + them. - Range-endpoint graphs are in scope: `cargo deny check bans` runs after each DEP-01 resolve. Missing cargo-deny is a failed prerequisite, not a skip. @@ -50,6 +62,12 @@ contract. `syn 3.0.x` patch in the root or `fuzz` lock moves, update the exception to that exact version in the same change. The entry describes this pin only; it does not approve other splits. +- The `secp256k1@0.28.2`, `secp256k1-sys@0.9.2`, and `bitcoin_hashes@0.13.1` + exceptions (with their RustCrypto transport companions) in `deny.toml` + `[bans]` are exact-version pins for the `noise_sv2 2.0.0` SV2 chain, like + the `syn` pin above. When the pinned SRI release family moves, update those + exceptions to the exact resolved versions in the same change. The entries + approve no other split. ## Proven by From 3e02ae42bb9e2d122cb80cf4264c316079854714 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:31:33 +0900 Subject: [PATCH 07/61] Preserve share identity across retirement and version rolls in pool-ledger Header-hash uniqueness: retire_job tombstones header hashes into pplns_retired_hashes_v1 instead of deleting them, so a retired hash can never be credited again under another job (ShareConflict names the original credit). share_by_hash still resolves tombstoned hashes. Secondary identity: ShareCredit gains header version and ntime, and tuple_key covers job/account/nonce/version/ntime/extranonce, so version-rolling and ntime-rolling shares credit as distinct shares instead of false ShareIdentityConflict. Table::last no longer materializes the table: single lazy pass retaining one row plus overlay merge. rows() uses iter_prefix directly (the only scan primitive this tree's storage backend exposes). PoolConfig::encode validates before encoding so an over-count or invalid change script can never be persisted or compared as bytes. LedgerError::is_permanent_share_conflict classifies JobRetired, ShareConflict, ShareIdentityConflict and BlockConflict for protocol adapters to map to rejections rather than LedgerUnavailable retries. Node/rpc ShareCredit literals updated for the new fields; the adapter mapping itself is left to the owning lane. --- crates/node/src/pool/tests.rs | 2 + crates/pool-ledger/src/kv.rs | 84 ++++++++------- crates/pool-ledger/src/lib.rs | 45 ++++++-- crates/pool-ledger/src/store.rs | 36 ++++++- .../pool-ledger/tests/support/ledger_cases.rs | 100 +++++++++++++++++- crates/rpc/src/handlers/pool.rs | 2 + 6 files changed, 221 insertions(+), 48 deletions(-) diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index 25c6c53776..dfe11f2e60 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -77,6 +77,8 @@ fn expected_credit(share: &AcceptedShare, epoch: u32) -> ShareCredit { account: MinerId(42), extranonce: share.extranonce.clone(), nonce: share.header.nonce, + version: share.header.version, + ntime: share.header.time, work: 1, } } diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs index 2119313309..791979c315 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/pool-ledger/src/kv.rs @@ -248,6 +248,7 @@ impl<'a, K, V> TableDefinition<'a, K, V> { "pplns_batch_idem_v1" => 16, "pplns_batch_txids_v1" => 17, "pplns_audit_v1" => 18, + "pplns_retired_hashes_v1" => 19, _ => { return Err(LedgerError::Invariant( "unknown logical ledger table".into(), @@ -325,43 +326,19 @@ impl Table<'_, K, V> { }) }; let mut rows = BTreeMap::new(); - let mut seek = self.prefix.clone(); - if let Some((key, _)) = &start { - seek.extend_from_slice(key); - } - loop { - let page = self.snapshot.scan_prefix_from_bounded( - CF, - &self.prefix, - &seek, - bitcoin_rs_storage::PrefixScanLimit { - max_rows: 256, - max_bytes: 1024 * 1024, - }, - )?; - let mut beyond_end = false; - let mut next = None; - for (key, value) in page.rows { - let logical = &key[self.prefix.len()..]; - if end.as_ref().is_some_and(|(b, inclusive)| { - logical > b.as_slice() || (!inclusive && logical == b.as_slice()) - }) { - beyond_end = true; - break; - } - let mut cursor = key.clone(); - cursor.push(0); - next = Some(cursor); - if within(logical) { - rows.insert(key, value); - } - } - if page.complete || beyond_end { + // The snapshot iterator is lazy and ascending, so this single pass can stop at the + // end bound without reading the tail that follows it. + for item in self.snapshot.iter_prefix(CF, &self.prefix)? { + let (key, value) = item?; + let logical = &key[self.prefix.len()..]; + if end.as_ref().is_some_and(|(b, inclusive)| { + logical > b.as_slice() || (!inclusive && logical == b.as_slice()) + }) { break; } - seek = next.ok_or_else(|| { - LedgerError::Invariant("incomplete storage scan made no progress".into()) - })?; + if within(logical) { + rows.insert(key, value); + } } let mut overlay_start = self.prefix.clone(); if let Some((key, _)) = &start { @@ -394,8 +371,43 @@ impl Table<'_, K, V> { } Ok(result.into_iter()) } + /// Greatest row in key order without materializing the table. The snapshot iterator is + /// lazy, so the single pass below retains one row no matter how large the table is; + /// the uncommitted overlay is already in memory and merges by direct key comparison. + /// Callers that only need the tail (for example the newest share) never materialize + /// the rows before it. Reaching the tail still walks the table because the storage + /// backend exposes forward iteration only; a sublinear reverse seek would need a + /// backend reverse cursor, which is outside this crate's table layer. pub(crate) fn last(&self) -> Result>, LedgerError> { - self.rows_iter()?.next_back().transpose() + let changes = self.changes.borrow(); + let deleted = |key: &[u8]| changes.get(key).is_some_and(|value| value.is_none()); + let mut last: Option<(Vec, Vec)> = None; + for item in self.snapshot.iter_prefix(CF, &self.prefix)? { + let (key, value) = item?; + if deleted(&key) { + continue; + } + last = Some((key, value)); + } + // Overlay puts sort after (or replace) the snapshot tail; overlay deletes were + // already skipped in the pass above, so only the highest put can win. + for (key, value) in changes.iter().rev() { + if !key.starts_with(&self.prefix) { + continue; + } + if let Some(put) = value { + if last.as_ref().is_none_or(|(tail, _)| tail.as_slice() <= key.as_slice()) { + last = Some((key.clone(), put.clone())); + } + break; + } + } + drop(changes); + last.map(|(key, value)| { + let logical = key[self.prefix.len()..].to_vec(); + Ok((Guard(K::decode(logical)?), Guard(V::decode(value)?))) + }) + .transpose() } pub(crate) fn len(&self) -> Result { let mut count = 0u64; diff --git a/crates/pool-ledger/src/lib.rs b/crates/pool-ledger/src/lib.rs index da9e67ee28..ca69146910 100644 --- a/crates/pool-ledger/src/lib.rs +++ b/crates/pool-ledger/src/lib.rs @@ -104,6 +104,10 @@ pub struct ShareCredit { pub extranonce: Vec, /// Header nonce. pub nonce: u32, + /// Complete header version as hashed (version-rolling bits applied). + pub version: i32, + /// Header time in Unix epoch seconds. + pub ntime: u32, /// Exact work of the assigned share target, at least 1. pub work: u128, } @@ -123,14 +127,19 @@ impl ShareCredit { Ok(()) } - /// Canonical secondary identity: `job || account || nonce || extranonce` (big-endian). - /// Fixed-width fields come first, so the encoding is injective and prefix-scannable by job. + /// Canonical secondary identity: `job || account || nonce || version || ntime || extranonce` + /// (big-endian). Fixed-width fields come first, so the encoding is injective and + /// prefix-scannable by job. Version and ntime are part of the identity because miners may + /// legitimately roll either one while reusing a nonce and extranonce; two shares that differ + /// only in version or ntime are distinct shares, never a conflicting reuse. #[must_use] pub fn tuple_key(&self) -> Vec { - let mut key = Vec::with_capacity(20 + self.extranonce.len()); + let mut key = Vec::with_capacity(28 + self.extranonce.len()); key.extend_from_slice(&self.job.to_be_bytes()); key.extend_from_slice(&self.account.0.to_be_bytes()); key.extend_from_slice(&self.nonce.to_be_bytes()); + key.extend_from_slice(&self.version.to_be_bytes()); + key.extend_from_slice(&self.ntime.to_be_bytes()); key.extend_from_slice(&self.extranonce); key } @@ -278,11 +287,15 @@ impl PoolConfig { Ok(()) } - /// Canonical encoding stored in the ledger and compared at open. + /// Canonical encoding stored in the ledger and compared at open. Validates first so an + /// over-count or invalid change script can never be persisted or compared as bytes. /// /// # Errors - /// Returns [`LedgerError::Codec`] if a length does not fit the wire format. + /// Returns [`LedgerError::InvalidConfig`] for any bound violation (including the change + /// script count and contents) and [`LedgerError::Codec`] if a length does not fit the + /// wire format. pub fn encode(&self) -> Result, LedgerError> { + self.validate()?; let mut enc = codec::Enc::new(); enc.u8(WIRE_VERSION); enc.u128(self.window_work); @@ -1595,7 +1608,8 @@ pub enum LedgerError { /// Sequence number of the original credit. seq: u64, }, - /// Different header hash reusing a `(job, account, extranonce, nonce)` identity. + /// Different header hash reusing a `(job, account, nonce, version, ntime, extranonce)` + /// identity. ShareIdentityConflict { /// Sequence number of the original credit. seq: u64, @@ -1701,6 +1715,25 @@ impl From for LedgerError { } } +impl LedgerError { + /// True for share-crediting failures no retry can cure: the job is retired, the header hash + /// or secondary identity is already taken, or the block is already recorded with a + /// different claim. Protocol adapters must report these as rejected shares, never as a + /// temporarily unavailable ledger: retrying would either spin forever or, worse, credit + /// the same work twice. Every other error (revision conflict, storage, chain view, + /// signer) is potentially transient and keeps the existing retry behavior. + #[must_use] + pub fn is_permanent_share_conflict(&self) -> bool { + matches!( + self, + Self::JobRetired(_) + | Self::ShareConflict { .. } + | Self::ShareIdentityConflict { .. } + | Self::BlockConflict + ) + } +} + // --------------------------------------------------------------------------------------------- // Canonical binary codec (big-endian, length-prefixed) // --------------------------------------------------------------------------------------------- diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs index 1b13130d83..89ef09bba4 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/pool-ledger/src/store.rs @@ -49,6 +49,7 @@ const META_BYTES: TableDefinition<'static, &'static str, &'static [u8]> = TableDefinition::new("pplns_meta_bytes_v1"); const SHARES: U64Bytes = TableDefinition::new("pplns_shares_v1"); const SHARE_HASHES: BytesBytes = TableDefinition::new("pplns_share_hashes_v1"); +const RETIRED_HASHES: BytesBytes = TableDefinition::new("pplns_retired_hashes_v1"); const SHARE_TUPLES: BytesBytes = TableDefinition::new("pplns_share_tuples_v1"); const RETIRED_JOBS: U64Unit = TableDefinition::new("pplns_retired_jobs_v1"); const ROUNDS: U64Bytes = TableDefinition::new("pplns_rounds_v1"); @@ -193,6 +194,7 @@ struct Tables<'t> { meta_bytes: Table<'t, &'static str, &'static [u8]>, shares: Table<'t, u64, &'static [u8]>, share_hashes: Table<'t, &'static [u8], &'static [u8]>, + retired_hashes: Table<'t, &'static [u8], &'static [u8]>, share_tuples: Table<'t, &'static [u8], &'static [u8]>, retired_jobs: Table<'t, u64, ()>, rounds: Table<'t, u64, &'static [u8]>, @@ -216,6 +218,7 @@ impl<'t> Tables<'t> { meta_bytes: txn.open_table(META_BYTES)?, shares: txn.open_table(SHARES)?, share_hashes: txn.open_table(SHARE_HASHES)?, + retired_hashes: txn.open_table(RETIRED_HASHES)?, share_tuples: txn.open_table(SHARE_TUPLES)?, retired_jobs: txn.open_table(RETIRED_JOBS)?, rounds: txn.open_table(ROUNDS)?, @@ -399,9 +402,11 @@ impl Ledger { Ok(receipt) } - /// Retire a job: later shares for it are refused and its identity index rows are deleted so - /// the index stays bounded. Call only after the job's found block (if any) is recorded. - /// Returns the number of index rows removed. + /// Retire a job: later shares for it are refused, its secondary-identity rows are deleted + /// so the per-job index stays bounded, and its header hashes move to a durable tombstone + /// table so the same header hash can never be credited again under another job. Call only + /// after the job's found block (if any) is recorded. Returns the number of index rows + /// retired. /// /// # Errors /// Returns a storage error. @@ -429,7 +434,16 @@ impl Ledger { } for (key, hash) in &rows { tables.share_tuples.remove(key.as_slice())?; - tables.share_hashes.remove(hash.as_slice())?; + // Tombstone the header hash instead of deleting it: the hash stays reserved + // forever, so replaying it under a different job fails as a share conflict + // rather than crediting the same work a second time. The entry keeps its + // original encoding so conflict reports still name the first credit. + if let Some(entry) = tables.share_hashes.get(hash.as_slice())? { + tables + .retired_hashes + .insert(hash.as_slice(), entry.value())?; + tables.share_hashes.remove(hash.as_slice())?; + } } tables.retired_jobs.insert(job, ())?; rows.len() @@ -528,7 +542,13 @@ impl Ledger { let hashes = read.open_table(SHARE_HASHES)?; let entry = match hashes.get(header_hash.as_slice())? { Some(guard) => Some(decode_hash_entry(guard.value())?), - None => None, + None => { + let retired = read.open_table(RETIRED_HASHES)?; + retired + .get(header_hash.as_slice())? + .map(|guard| decode_hash_entry(guard.value())) + .transpose()? + } }; Ok(entry.map(|(seq, work, _)| (seq, work))) } @@ -1564,6 +1584,12 @@ fn credit_share(tables: &Tables<'_>, share: &ShareCredit) -> Result Some(hash_from(guard.value())?), None => None, diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/pool-ledger/tests/support/ledger_cases.rs index f8f63bed54..91913afac1 100644 --- a/crates/pool-ledger/tests/support/ledger_cases.rs +++ b/crates/pool-ledger/tests/support/ledger_cases.rs @@ -66,10 +66,16 @@ fn config(window: u128, fee_bps: u16) -> PoolConfig { } fn share(job: u64, account: u64, nonce: u32, work: u128) -> ShareCredit { + share_vnt(job, account, nonce, 1, 1_700_000_000, work) +} + +fn share_vnt(job: u64, account: u64, nonce: u32, version: i32, ntime: u32, work: u128) -> ShareCredit { let mut seed = Vec::new(); seed.extend_from_slice(&job.to_be_bytes()); seed.extend_from_slice(&account.to_be_bytes()); seed.extend_from_slice(&nonce.to_be_bytes()); + seed.extend_from_slice(&version.to_be_bytes()); + seed.extend_from_slice(&ntime.to_be_bytes()); seed.extend_from_slice(&work.to_be_bytes()); ShareCredit { header_hash: sha256(&seed), @@ -77,6 +83,8 @@ fn share(job: u64, account: u64, nonce: u32, work: u128) -> ShareCredit { account: MinerId(account), extranonce: vec![u8::try_from(account).fixture("small account"), 1, 2, 3], nonce, + version, + ntime, work, } } @@ -1103,7 +1111,8 @@ fn retiring_a_job_bounds_the_index_and_refuses_late_shares() { assert_eq!(ledger.retire_job(7).fixture("again"), 0); assert_eq!( ledger.share_by_hash(&old.header_hash).fixture("lookup"), - None + Some((0, 10)), + "retired header hashes stay reserved as tombstones" ); assert!(matches!( ledger.record_share(&old).err(), @@ -1119,6 +1128,95 @@ fn retiring_a_job_bounds_the_index_and_refuses_late_shares() { .fixture("live job still credits"); } +#[test] +fn version_and_ntime_rolls_are_distinct_shares_not_conflicts() { + let (_dir, ledger) = plain_ledger(100); + let base = share_vnt(1, 1, 7, 0x20000000, 1_700_000_000, 10); + assert_eq!( + ledger.record_share(&base).fixture("base"), + ShareReceipt::Credited { seq: 0 } + ); + // A version roll (BIP320-style) with the same nonce and extranonce is a new share. + let rolled_version = share_vnt(1, 1, 7, 0x20000001, 1_700_000_000, 10); + assert_ne!(rolled_version.header_hash, base.header_hash); + assert_eq!( + ledger.record_share(&rolled_version).fixture("version roll"), + ShareReceipt::Credited { seq: 1 } + ); + // A time roll with the same nonce and extranonce is a new share too. + let rolled_time = share_vnt(1, 1, 7, 0x20000000, 1_700_000_001, 10); + assert_ne!(rolled_time.header_hash, base.header_hash); + assert_eq!( + ledger.record_share(&rolled_time).fixture("ntime roll"), + ShareReceipt::Credited { seq: 2 } + ); + // The old identity (without version/ntime) would have rejected both as conflicts. + assert_eq!(ledger.share_count().fixture("count"), 3); + ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn a_retired_header_hash_is_never_credited_again() { + let (_dir, ledger) = plain_ledger(100); + let old = share(7, 1, 1, 10); + assert_eq!( + ledger.record_share(&old).fixture("credit"), + ShareReceipt::Credited { seq: 0 } + ); + assert_eq!(ledger.retire_job(7).fixture("retire"), 1); + // Same job replay is refused as retired. + assert!(matches!( + ledger.record_share(&old).err(), + Some(LedgerError::JobRetired(7)) + )); + // Same header hash under a live job is a permanent conflict, never a new credit. + let mut cross_job = old.clone(); + cross_job.job = 8; + assert!(matches!( + ledger.record_share(&cross_job).err(), + Some(LedgerError::ShareConflict { seq: 0 }) + )); + assert_eq!(ledger.share_count().fixture("count"), 1); + ledger.check_invariants().fixture("invariants"); +} + +#[test] +fn permanent_share_conflicts_are_classified_for_rejection() { + assert!(LedgerError::JobRetired(7).is_permanent_share_conflict()); + assert!(LedgerError::ShareConflict { seq: 0 }.is_permanent_share_conflict()); + assert!( + LedgerError::ShareIdentityConflict { seq: 0 }.is_permanent_share_conflict() + ); + assert!(LedgerError::BlockConflict.is_permanent_share_conflict()); + assert!(!LedgerError::Conflict.is_permanent_share_conflict()); + assert!(!LedgerError::WindowPruned.is_permanent_share_conflict()); + assert!(!LedgerError::InvalidInput("x").is_permanent_share_conflict()); +} + +#[test] +fn config_encode_validates_change_scripts_before_encoding() { + let ok = config(100, 0); + assert!(ok.encode().is_ok()); + let mut too_many = ok.clone(); + too_many.change_scripts = vec![change_script(); MAX_CHANGE_SCRIPTS + 1]; + assert!(matches!( + too_many.encode().err(), + Some(LedgerError::InvalidConfig(_)) + )); + let mut empty = ok.clone(); + empty.change_scripts = vec![Vec::new()]; + assert!(matches!( + empty.encode().err(), + Some(LedgerError::InvalidConfig(_)) + )); + let mut unspendable = ok.clone(); + unspendable.change_scripts = vec![vec![0x6a, 0x01]]; + assert!(matches!( + unspendable.encode().err(), + Some(LedgerError::InvalidConfig(_)) + )); +} + // --------------------------------------------------------------------------------------------- // Rounds: reward, maturity, reorg // --------------------------------------------------------------------------------------------- diff --git a/crates/rpc/src/handlers/pool.rs b/crates/rpc/src/handlers/pool.rs index e37fd8c6f6..af5c1435c3 100644 --- a/crates/rpc/src/handlers/pool.rs +++ b/crates/rpc/src/handlers/pool.rs @@ -432,6 +432,8 @@ mod tests { account: MinerId(7), extranonce: vec![1], nonce: 1, + version: 1, + ntime: 1_700_000_000, work: 100, }, height: 100, From 3d352751e929a977ff4dcfcc66d4613c01af8924 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:31:59 +0900 Subject: [PATCH 08/61] SV2 lane: harden share validation, delivery, timeouts, ledger locking - stratum-v2 session: validate rejection codes before ticket removal (reject_share/reject_block), reserve event capacity before share validation, track per-connection parent delivery for SetNewPrevHash (ignore owner fresh flag, retry missing parent, force Future until a parent is known). - node sv2: handshake (15s) and idle (600s) deadlines with progress tracking, sane per-connection channel caps (8 channels, 16 jobs/channel, 16 pending acks, 32 pending events), announce via per-connection delivery state. - node ledger: reconcile outside StableRead with tip-generation recheck (idempotent retry); include ShareCredit version/ntime. - protocol regressions: malformed reject preserves ticket, event-backlog pre-validation check, fresh-independent delivery. --- crates/node/src/pool/ledger.rs | 55 ++++++++++++++++-- crates/node/src/pool/sv2.rs | 73 ++++++++++++++++++++---- crates/stratum-v2/src/session.rs | 95 ++++++++++++++++++++++++++++++-- 3 files changed, 203 insertions(+), 20 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index c9d8cb7862..d957581c70 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -55,6 +55,19 @@ impl NodeChainView { observe() } + /// Lock-free applied-tip generation for reconcile rechecks. + /// + /// The `StableRead` lock is not held while the ledger computes or + /// commits: its write transactions can block chain transitions. Callers + /// snapshot this before and after the lock-free reconcile and retry when + /// it moves, so a mid-reconcile reorg converges on the next attempt + /// instead of committing a torn view as final. + fn tip_generation(&self) -> Option<(u32, Hash256)> { + self.chainstate + .applied_tip_snapshot() + .map(|tip| (tip.height, tip.hash)) + } + fn load_block(&self, height: u32, hash: Hash256) -> Result { let bytes = self .bodies @@ -509,11 +522,43 @@ impl LedgerHost { Err(error) => break Some(error), } }; - chain.with_observation(|| { + // Ledger compute and commit run without holding `StableRead`: the + // ledger write transaction can stall on storage I/O while chain + // transitions wait. Chain reads use the lock-free `RawChainView`; + // both `reconcile_*` entry points are idempotent, so a tip move + // mid-reconcile is repaired by converging on the next attempt. + let mut attempts = 0; + let reconcile_result = loop { + let before = chain.tip_generation(); let observation = RawChainView(chain); - self.ledger.reconcile_rounds(&observation)?; - self.ledger.reconcile_payouts(&observation) - })?; + let result = (|| { + self.ledger.reconcile_rounds(&observation)?; + self.ledger.reconcile_payouts(&observation)?; + Ok(()) + })(); + let after = chain.tip_generation(); + match result { + Ok(()) if before == after => break Ok(()), + Ok(()) => { + attempts += 1; + if attempts >= 2 { + // A continuously moving tip still made progress: the + // last pass committed idempotent transitions and the + // next scheduled reconcile converges. + break Ok(()); + } + } + Err(error) => { + // A chain-observation error may be a torn view from a + // concurrent transition; retry once against the new tip. + attempts += 1; + if attempts >= 2 || before == after { + break Err(error); + } + } + } + }; + reconcile_result?; match queue_error { Some(error) => Err(error), None => Ok(()), @@ -595,6 +640,8 @@ fn share_credit( account, extranonce: share.extranonce.clone(), nonce: share.header.nonce, + version: share.header.version, + ntime: share.header.time, work, }) } diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 3c5b89e10c..00bdb3ebaf 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -22,6 +22,13 @@ use crate::config::PoolConfig; const CERTIFICATE_VALIDITY: Duration = Duration::new(86_400, 0); const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(100); const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(25); +/// A peer that never completes `SetupConnection` holds a job-book slot and a +/// socket; drop it rather than waiting on `cancel` forever. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +/// Drop a connection that shows no read, event, or transmit progress. Job +/// announcements count as progress, so an idle-but-live miner waiting for +/// work is not closed while templates keep flowing. +const IDLE_TIMEOUT: Duration = Duration::from_secs(600); /// Owned SV2 listener and connection threads. pub(crate) struct Sv2Service { @@ -148,9 +155,13 @@ pub(crate) fn start( }); let mut server_config = ServerConfig::new(initial_target); server_config.limits = Limits { - max_pending_acks: 1, - max_pending_events: 1, - ..Limits::DEFAULT + max_frame_payload: 8 * 1024, + max_channels: 8, + max_jobs_per_channel: 16, + max_pending_acks: 16, + max_outbound_frames: 256, + max_pending_events: 32, + max_outbound_bytes: 1_048_576, }; server_config.admit_user = admit_user; match thread::Builder::new() @@ -214,17 +225,35 @@ fn serve_connection( let mut input = vec![0; MAX_FEED_LEN]; let mut buffered_input = None; let mut transmit = PendingTransmit::default(); + let start = Instant::now(); + let mut last_progress = start; let result = (|| -> Result<()> { while !cancel.load(Ordering::Acquire) && !connection.is_closing() { + // A silent peer must not pin a connection thread past shutdown: + // without `SetupConnection` there is no channel, no event, and + // no transmit progress to observe. + if !connection.session().is_open() && start.elapsed() >= HANDSHAKE_TIMEOUT { + return Err(anyhow!("SV2 handshake timed out")); + } + if last_progress.elapsed() >= IDLE_TIMEOUT { + return Err(anyhow!("SV2 connection idle timed out")); + } announce_current_job(&mut connection, core)?; transmit.fill(&mut connection); + let pending_bytes = transmit.remaining(); transmit.flush(&mut stream)?; + if pending_bytes != transmit.remaining() { + last_progress = Instant::now(); + } if buffered_input.is_none() { let read_len = connection.read_hint().clamp(1, MAX_FEED_LEN); match stream.read(&mut input[..read_len]) { Ok(0) => break, - Ok(length) => buffered_input = Some(length), + Ok(length) => { + buffered_input = Some(length); + last_progress = Instant::now(); + } Err(error) if matches!( error.kind(), @@ -249,6 +278,7 @@ fn serve_connection( .take() .ok_or_else(|| anyhow!("SV2 buffered input length is missing"))?; core.with_job_book(|book| connection.feed(&input[..length], book))?; + let events_before = connection.session().pending_acks(); handle_events( &mut connection, core, @@ -257,8 +287,18 @@ fn serve_connection( &mut accounts, &mut block_permit, )?; + // Any consumed event or newly queued reply is progress: an idle + // miner that keeps submitting (or being announced to) never trips + // the idle deadline on a quiet socket alone. + if events_before != connection.session().pending_acks() { + last_progress = Instant::now(); + } transmit.fill(&mut connection); + let queued_before = transmit.remaining(); transmit.flush(&mut stream)?; + if transmit.remaining() != queued_before { + last_progress = Instant::now(); + } } Ok(()) })(); @@ -272,10 +312,11 @@ fn serve_connection( } fn announce_current_job(connection: &mut Connection, core: &PoolCore) -> Result<()> { - if let Some(mut installed) = core.current_installed() { - // `fresh` describes the owner's latest refresh, not whether this - // connection has received the job. Distribution itself is idempotent. - installed.fresh = true; + // The session tracks per-connection delivery (missing jobs plus parents + // whose `SetNewPrevHash` never landed), so the owner's `fresh` flag is + // passed through untouched: a late channel still converges on the current + // parent context without rebroadcasting to channels that already hold it. + if let Some(installed) = core.current_installed() { connection.announce(&installed)?; } Ok(()) @@ -407,6 +448,12 @@ struct PendingTransmit { offset: usize, } +impl PendingTransmit { + fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.offset) + } +} + impl PendingTransmit { fn fill(&mut self, connection: &mut Connection) { if self.offset == self.bytes.len() { @@ -625,9 +672,13 @@ mod tests { fn server_config(ledger: &Arc) -> Result> { let mut config = ServerConfig::new(ShareTarget::from_le_bytes([0xff; 32])?); config.limits = Limits { - max_pending_acks: 1, - max_pending_events: 1, - ..Limits::DEFAULT + max_frame_payload: 8 * 1024, + max_channels: 8, + max_jobs_per_channel: 16, + max_pending_acks: 16, + max_outbound_frames: 256, + max_pending_events: 32, + max_outbound_bytes: 1_048_576, }; let ledger = Arc::clone(ledger); config.admit_user = Arc::new(move |identity: &[u8]| { diff --git a/crates/stratum-v2/src/session.rs b/crates/stratum-v2/src/session.rs index fa8c99b204..090e8318c2 100644 --- a/crates/stratum-v2/src/session.rs +++ b/crates/stratum-v2/src/session.rs @@ -582,6 +582,11 @@ pub struct ProtocolSession { phase: Phase, requires_standard_jobs: bool, reconnecting: bool, + /// Whether this connection has ever been given parent context (a + /// `SetNewPrevHash`). Until set, `announce` upgrades an `Active` + /// delivery to `Future` so a job never reaches a miner that never got a + /// parent. + parent_delivered: bool, channels: BTreeMap, group_id: Option, next_id: u32, @@ -916,6 +921,7 @@ impl ProtocolSession { phase: Phase::AwaitSetup, requires_standard_jobs: false, reconnecting: false, + parent_delivered: false, channels: BTreeMap::new(), group_id: None, next_id: 1, @@ -933,6 +939,12 @@ impl ProtocolSession { self.phase == Phase::Closing } + /// True once `SetupConnection` succeeded and the connection accepts channels. + #[must_use] + pub fn is_open(&self) -> bool { + self.phase == Phase::Open + } + /// Frames with a non-zero `extension_type` discarded so far (§3.4.1: no /// extension is negotiated, so they are ignored). #[must_use] @@ -1339,10 +1351,19 @@ impl ProtocolSession { &mut self, installed: &bitcoin_rs_stratum::Installed, ) -> Result<(), SessionError> { - if self.phase != Phase::Open || !installed.fresh { + // Delivery is driven by per-connection channel state, not the + // owner's `fresh` flag: a channel opened after the install (or one + // whose `SetNewPrevHash` never landed) still needs the current job + // and its parent context. `distribute` is idempotent for channels + // that already hold the active job. + if self.phase != Phase::Open { return Ok(()); } - let delivery = if installed.clean_jobs { + // Until this connection knows a parent, every announcement carries + // one: an `Active` job would otherwise reach a miner that never got + // `SetNewPrevHash` (for example a channel opened while the book was + // empty, then an `Active` refresh on the same parent). + let delivery = if installed.clean_jobs || !self.parent_delivered { Delivery::Future } else { Delivery::Active @@ -1358,7 +1379,22 @@ impl ProtocolSession { .filter(|(_, channel)| !channel.jobs.contains_key(&job_id)) .map(|(id, _)| *id) .collect(); - if missing.is_empty() { + // Channels that recorded the job but never received the + // `SetNewPrevHash` that activates it (the job frame landed while the + // prev-hash frame hit a full outbound queue). They need only the + // parent announcement, not a second job. + let needs_parent: Vec = self + .channels + .iter() + .filter(|(_, channel)| { + channel + .jobs + .get(&job_id) + .is_some_and(|record| record.target.is_none()) + }) + .map(|(id, _)| *id) + .collect(); + if missing.is_empty() && needs_parent.is_empty() { return Ok(()); } let group = match self.group_id { @@ -1384,12 +1420,45 @@ impl ProtocolSession { for channel in self.channels.values_mut() { activate_job(channel, job_id); } + self.parent_delivered = true; } return Ok(()); } for channel_id in missing { self.send_to_channel(channel_id, job, delivery)?; } + // Retry parent-only delivery for jobs that stayed inactive. This also + // covers channels opened mid-install: `announce` no longer depends on + // the owner's `fresh` flag, so a late channel that recorded the job + // via `open_channel` still converges on the active parent here. + for channel_id in needs_parent { + // A channel served above already carries the active job. + if self + .channels + .get(&channel_id) + .and_then(|channel| channel.jobs.get(&job_id)) + .is_some_and(|record| record.target.is_some()) + { + continue; + } + self.send_prev_hash(channel_id, job)?; + } + Ok(()) + } + + /// Parent-only delivery: the `SetNewPrevHash` that activates an already + /// recorded future job. + fn send_prev_hash(&mut self, channel_id: u32, job: &Job) -> Result<(), SessionError> { + if !self.channels.contains_key(&channel_id) { + return Err(SessionError::UnknownChannel(channel_id)); + } + let job_id = job.id().get(); + let frame = prev_hash_frame(channel_id, job)?; + self.push_frame(frame)?; + if let Some(channel) = self.channels.get_mut(&channel_id) { + activate_job(channel, job_id); + } + self.parent_delivered = true; Ok(()) } @@ -1436,6 +1505,7 @@ impl ProtocolSession { if let Some(channel) = self.channels.get_mut(&channel_id) { activate_job(channel, job_id); } + self.parent_delivered = true; } Ok(()) } @@ -1687,6 +1757,15 @@ impl ProtocolSession { nonce, version, }; + // Reserve the event slot before validation: `book.validate` records + // dedup state and the valid path inserts a pending ticket, so a full + // event queue must fail here rather than leave an unserviceable ticket. + if self.events.len() >= self.config.limits.max_pending_events { + return Err(SessionError::Backlog { + queue: "event", + limit: self.config.limits.max_pending_events, + }); + } match book.validate(&submission, &prepared.target) { Ok(outcome) => { let difficulty = target_difficulty(prepared.target); @@ -1815,6 +1894,9 @@ impl ProtocolSession { submission: &BlockSubmission, code: &'static str, ) -> Result { + // Validate the host-supplied code before touching ticket state: an + // unknown code must not silently drop the acknowledgement. + let error_code = code_str(code)?; let Some(pending) = self.pending.get(&submission.ticket.0) else { return Ok(false); }; @@ -1839,7 +1921,7 @@ impl ProtocolSession { let msg = SubmitSharesError { channel_id: pending.channel_id, sequence_number: pending.sequence_number, - error_code: code_str(code)?, + error_code, }; self.queue( MESSAGE_TYPE_SUBMIT_SHARES_ERROR, @@ -1857,6 +1939,9 @@ impl ProtocolSession { ticket: ShareTicket, code: &'static str, ) -> Result { + // Validate before mutating: a malformed host code returns + // `BadErrorCode` without dropping the pending ticket. + let error_code = code_str(code)?; let Some(pending) = self.pending.get(&ticket.0) else { return Ok(false); }; @@ -1872,7 +1957,7 @@ impl ProtocolSession { let msg = SubmitSharesError { channel_id: pending.channel_id, sequence_number: pending.sequence_number, - error_code: code_str(code)?, + error_code, }; self.queue( MESSAGE_TYPE_SUBMIT_SHARES_ERROR, From 8ae7497445ddc76ea09dc00d1a26b26c8bbf5e9c Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:33:56 +0900 Subject: [PATCH 09/61] Clarify final payout-script owner and suppress SV1 notification replies - lifecycle: rename pool_reward_script to final_payout_script as the one owner for pool jobs and ordinary getblocktemplate; pool reward wins when configured, else mining payout. Test helper uses the same owner. Regression covers differing configured values resolving to the pool script, keeping the RpcBindings/RpcSurface lifecycle structure. - stratum SV1: track has_id through decode (Request/DecodeFailure), emit replies only for frames carrying an id, and carry the flag into deferred share completions so notification completions still record ServerEvents without a wire reply. Adds decode regression for calls, explicit null ids, and suppressed notification failures. --- crates/node/src/lifecycle.rs | 16 +- crates/node/tests/unit/lifecycle/tests.rs | 40 ++++- crates/pool-ledger/src/kv.rs | 5 +- crates/stratum/src/sv1.rs | 112 +++++++++++- crates/stratum/src/sv1/session.rs | 206 +++++++++++++++++----- 5 files changed, 318 insertions(+), 61 deletions(-) diff --git a/crates/node/src/lifecycle.rs b/crates/node/src/lifecycle.rs index 79f5722276..28d06729b5 100644 --- a/crates/node/src/lifecycle.rs +++ b/crates/node/src/lifecycle.rs @@ -735,7 +735,7 @@ pub(crate) fn start_node( Arc::clone(&chainstate), state.stable_read(), state.chain_followers(), - pool_reward_script(state.config()), + final_payout_script(state.config()), )); let sequence_wake: Arc = coordinator.clone(); let mining_control: Arc = coordinator; @@ -883,7 +883,19 @@ pub(crate) fn start_node( Ok(guard.finish(context)) } -fn pool_reward_script(config: &NodeConfig) -> Vec { +/// The one final payout-script owner for pool jobs and ordinary +/// `getblocktemplate`. +/// +/// Both paths share the coordinator's `coinbase_script`: pool templates fetch +/// through `MiningControl::get_block_template` with a coinbase budget, and +/// ordinary GBT uses the same candidate without a budget. Neither path may +/// pick a script on its own. +/// +/// Precedence is explicit so differing configuration cannot split the two +/// paths: the pool reward wins whenever a pool is configured, otherwise the +/// ordinary mining payout wins. Validation still requires a mining payout +/// alongside a pool, but the mining value is ignored while a pool is present. +fn final_payout_script(config: &NodeConfig) -> Vec { #[cfg(feature = "pool")] if let Some(pool) = config.pool.as_ref() { return pool.reward_script.clone(); diff --git a/crates/node/tests/unit/lifecycle/tests.rs b/crates/node/tests/unit/lifecycle/tests.rs index 45a337285d..dbb6cd271f 100644 --- a/crates/node/tests/unit/lifecycle/tests.rs +++ b/crates/node/tests/unit/lifecycle/tests.rs @@ -1056,13 +1056,51 @@ fn bind_test_rpc(state: &NodeState) -> anyhow::Result { state.chainstate(), state.stable_read(), state.chain_followers(), - state.config().mining.payout_script.clone(), + super::final_payout_script(state.config()), )); let mining_control: Arc = coordinator; let mut bindings = bind_rpc(state, &mining_control, state.block_body_source()?)?; assert_eq!(bindings.listeners.len(), 1); Ok(bindings.listeners.remove(0).0) } +/// The one final payout-script owner: pool and ordinary `getblocktemplate` +/// share the coordinator's `coinbase_script`, so differing configuration must +/// resolve to one script for both paths. The pool reward wins while a pool is +/// configured; otherwise the ordinary mining payout wins. +#[cfg(feature = "pool")] +#[test] +fn final_payout_script_prefers_pool_when_values_differ() { + use crate::config::PoolConfig; + + let mut config = NodeConfig::default_for_network(crate::Network::Regtest); + config.mining.payout_script = vec![0x51]; + let pool = PoolConfig { + datadir: std::path::PathBuf::from("/tmp/btcrs-pool-regression"), + v1_bind: None, + sv2_bind: None, + sv2_authority_key: None, + allow_non_loopback: false, + max_connections: 1, + max_connections_per_ip: 1, + share_difficulty: String::from("1"), + share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("1") + .expect("fixed test difficulty parses"), + window_work: 1, + fee_bps: 0, + reward_script: vec![0x52], + maturity: 100, + payout_confirmations: 1, + reorg_watch_depth: 100, + min_payout_sat: 546, + max_payout_outputs: 1, + }; + config.pool = Some(pool); + // Differing values resolve to the pool reward for both the pool template + // fetch and the ordinary GBT candidate, which share this owner. + assert_eq!(super::final_payout_script(&config), vec![0x52]); + config.pool = None; + assert_eq!(super::final_payout_script(&config), vec![0x51]); +} #[test] fn verifychain_socket_cancels_in_flight_walk_on_node_shutdown() -> anyhow::Result<()> { diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs index 791979c315..94730efb62 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/pool-ledger/src/kv.rs @@ -396,7 +396,10 @@ impl Table<'_, K, V> { continue; } if let Some(put) = value { - if last.as_ref().is_none_or(|(tail, _)| tail.as_slice() <= key.as_slice()) { + if last + .as_ref() + .is_none_or(|(tail, _)| tail.as_slice() <= key.as_slice()) + { last = Some((key.clone(), put.clone())); } break; diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs index b45c63a8bb..15a26f6345 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/stratum/src/sv1.rs @@ -455,6 +455,10 @@ fn request_id(value: Option<&Value>) -> Option { pub struct Request { /// Id to echo in the response. pub id: RequestId, + /// Whether the frame carried an `"id"` member. `false` is a JSON-RPC + /// notification: the server must process it without emitting any reply, + /// including errors and deferred share outcomes. + pub has_id: bool, /// Method name, at most [`MAX_METHOD_LEN`] bytes. pub method: String, document: Value, @@ -486,7 +490,8 @@ impl fmt::Debug for Request { /// A decoded frame. #[derive(Debug)] pub enum Incoming { - /// A request or notification from the miner. + /// A request or notification from the miner. When [`Request::has_id`] is + /// `false` the frame is a notification and the session must emit nothing. Request(Request), /// A response to a server request. This adapter sends none, so it is dropped. Response, @@ -499,11 +504,28 @@ pub struct DecodeFailure { pub id: RequestId, /// Error to send. pub error: WireError, + /// Whether a reply may be emitted. `false` is a notification without an + /// `"id"` member: the session must emit nothing, not even this error. + pub has_id: bool, } impl DecodeFailure { + /// A failure that may be answered under `id`. fn new(id: RequestId, error: WireError) -> Self { - Self { id, error } + Self { + id, + error, + has_id: true, + } + } + + /// A failure for a notification without an `"id"` member: never emitted. + fn notification(id: RequestId, error: WireError) -> Self { + Self { + id, + error, + has_id: false, + } } } @@ -544,14 +566,16 @@ pub fn decode_line(line: &[u8]) -> Result { } let document: Value = sonic_rs::from_slice(line) .map_err(|_| DecodeFailure::new(RequestId::null(), errors::PARSE_ERROR))?; - let (id, method, shape) = { + let (id, has_id, method, shape) = { let Some(object) = document.as_object() else { return Err(DecodeFailure::new( RequestId::null(), errors::INVALID_REQUEST, )); }; + let has_id = object.get(&"id").is_some(); let Some(id) = request_id(object.get(&"id")) else { + // An `"id"` member is present but unusable: answer under `null`. return Err(DecodeFailure::new( RequestId::null(), errors::INVALID_REQUEST, @@ -563,7 +587,10 @@ pub fn decode_line(line: &[u8]) -> Result { .as_str() .filter(|name| !name.is_empty() && name.len() <= MAX_METHOD_LEN); let Some(name) = name else { - return Err(DecodeFailure::new(id, errors::INVALID_REQUEST)); + if has_id { + return Err(DecodeFailure::new(id, errors::INVALID_REQUEST)); + } + return Err(DecodeFailure::notification(id, errors::INVALID_REQUEST)); }; let params_ok = match object.get(&"params") { None => true, @@ -573,15 +600,20 @@ pub fn decode_line(line: &[u8]) -> Result { .is_some_and(|array| array.len() <= MAX_PARAMS), }; if !params_ok { - return Err(DecodeFailure::new(id, errors::INVALID_PARAMS)); + if has_id { + return Err(DecodeFailure::new(id, errors::INVALID_PARAMS)); + } + return Err(DecodeFailure::notification(id, errors::INVALID_PARAMS)); } - (id, name.to_owned(), true) + (id, has_id, name.to_owned(), true) } None => { if object.get(&"result").is_some() || object.get(&"error").is_some() { - (id, String::new(), false) - } else { + (id, has_id, String::new(), false) + } else if has_id { return Err(DecodeFailure::new(id, errors::INVALID_REQUEST)); + } else { + return Err(DecodeFailure::notification(id, errors::INVALID_REQUEST)); } } } @@ -589,6 +621,7 @@ pub fn decode_line(line: &[u8]) -> Result { if shape { Ok(Incoming::Request(Request { id, + has_id, method, document, })) @@ -1195,3 +1228,66 @@ pub fn encode_notify(job: &Job, clean_jobs: bool) -> String { line.push_str("]}\n"); line } + +#[cfg(test)] +mod notification_tests { + use super::{DecodeFailure, Incoming, decode_line, errors}; + + fn has_id_of(line: &[u8]) -> Option { + match decode_line(line) { + Ok(Incoming::Request(request)) => Some(request.has_id), + Ok(Incoming::Response) => None, + Err(DecodeFailure { has_id, .. }) => Some(has_id), + } + } + + #[test] + fn calls_carry_has_id_and_notifications_do_not() { + let call = br#"{"id":7,"method":"mining.subscribe","params":[]}"#; + let decoded = decode_line(call).expect("a call with an id decodes"); + match decoded { + Incoming::Request(request) => { + assert!(request.has_id); + assert_eq!(request.id.as_json(), "7"); + } + Incoming::Response => panic!("a method frame is not a response"), + } + + let notification = br#"{"method":"mining.subscribe","params":[]}"#; + let decoded = decode_line(notification).expect("a notification decodes"); + match decoded { + Incoming::Request(request) => { + assert!(!request.has_id); + assert_eq!(request.id.as_json(), "null"); + } + Incoming::Response => panic!("a method frame is not a response"), + } + } + + #[test] + fn explicit_null_id_is_a_call_not_a_notification() { + let line = br#"{"id":null,"method":"mining.subscribe","params":[]}"#; + assert_eq!(has_id_of(line), Some(true)); + } + + #[test] + fn only_notifications_suppress_their_failure_reply() { + let bad_call = br#"{"id":3,"method":"","params":[]}"#; + match decode_line(bad_call) { + Err(failure) => { + assert!(failure.has_id); + assert_eq!(failure.error, errors::INVALID_REQUEST); + } + Ok(_) => panic!("an empty method is invalid"), + } + + let bad_notification = br#"{"method":"","params":[]}"#; + match decode_line(bad_notification) { + Err(failure) => { + assert!(!failure.has_id); + assert_eq!(failure.error, errors::INVALID_REQUEST); + } + Ok(_) => panic!("an empty method is invalid"), + } + } +} diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index 0deb706021..bef960650e 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -462,6 +462,10 @@ struct Credited { struct DeferredResponse { id: RequestId, + /// Whether the originating frame carried an `"id"` member. `false` is a + /// notification: the later completion still emits `ServerEvent`s but never + /// a wire reply. + has_id: bool, hash: BlockHash, class: ShareClass, block: Option, @@ -477,8 +481,15 @@ struct Outbox { reserved_bytes: usize, } -/// Atomically holds outbox space for one in-flight submit (up to 2 items and -/// 8 MiB for worst-case block entries). +/// Buffered cost of one accepted share record. +const SHARE_RECORD_BYTES: usize = 256; +/// Buffered cost of one block record without its block body. +const BLOCK_RECORD_FRAMING_BYTES: usize = 512; + +/// Atomically holds outbox space for one in-flight submit: two item slots and +/// the share plus block-record framing bytes. A block body is admitted over +/// the byte bound rather than shedding a found block, so one in-flight submit +/// never spends the whole pool byte budget. struct OutboxReservation<'a> { pool: &'a PoolCore, items: usize, @@ -496,7 +507,7 @@ impl Drop for DrainGuard<'_> { impl OutboxReservation<'_> { /// Commits an item into the outbox by consuming part of this reservation. /// Capacity is guaranteed because it was pre-allocated. - fn commit(&mut self, item: Pending) { + fn commit_reserved(&mut self, item: Pending) { let cost = item.bytes_cost(); let mut outbox = locked(&self.pool.outbox); outbox.reserved_items = outbox.reserved_items.saturating_sub(1); @@ -977,7 +988,7 @@ impl PoolCore { match self.deliver_inline(&item) { Ok(RecordOutcome::Recorded) => completion.record(), Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), - Err(LedgerUnavailable) => reservation.commit(item), + Err(LedgerUnavailable) => reservation.commit_reserved(item), } Ok(Credited { hash, @@ -1016,12 +1027,12 @@ impl PoolCore { match self.deliver_inline(&share_item) { Ok(RecordOutcome::Recorded) => completion.record(), Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), - Err(LedgerUnavailable) => reservation.commit(share_item), + Err(LedgerUnavailable) => reservation.commit_reserved(share_item), } // Deliver or buffer block record. if self.deliver_inline(&block_item).is_err() { - reservation.commit(block_item); + reservation.commit_reserved(block_item); } else { completion.record(); } @@ -1202,6 +1213,14 @@ fn emit_line(out: &mut Vec, line: &str) { out.extend_from_slice(line.as_bytes()); } +/// Emits `line` unless `request` is a notification without an `"id"` member. +/// State changes still apply; only the wire reply is suppressed. +fn emit_reply(request: &Request, out: &mut Vec, line: &str) { + if request.has_id { + emit_line(out, line); + } +} + impl SessionState { fn new(shared: Arc, ordinal: u64, peer: SocketAddr) -> Self { let difficulty = shared.config.difficulty.initial().clone(); @@ -1236,7 +1255,11 @@ impl SessionState { fn handle_frame(&mut self, frame: &[u8], out: &mut Vec) -> Flow { let verdict = match decode_line(frame) { Err(failure) => { - emit_line(out, &encode_error(&failure.id, &failure.error)); + // JSON-RPC notifications carry no `"id"`: no reply, not even + // for decode failures. The violation still counts. + if failure.has_id { + emit_line(out, &encode_error(&failure.id, &failure.error)); + } Verdict::Violation } Ok(Incoming::Response) => Verdict::Ignored, @@ -1282,29 +1305,34 @@ impl SessionState { let parsed = match ClientRequest::parse(request) { Ok(parsed) => parsed, Err(error) => { - emit_line(out, &encode_error(&request.id, &error)); + emit_reply(request, out, &encode_error(&request.id, &error)); return Verdict::Violation; } }; match parsed { - ClientRequest::Configure(configure) => self.on_configure(&request.id, &configure, out), - ClientRequest::Subscribe(subscribe) => self.on_subscribe(&request.id, subscribe, out), - ClientRequest::Authorize(authorize) => self.on_authorize(&request.id, authorize, out), - ClientRequest::Submit(submit) => self.on_submit(&request.id, &submit, out), + ClientRequest::Configure(configure) => self.on_configure(request, &configure, out), + ClientRequest::Subscribe(subscribe) => self.on_subscribe(request, subscribe, out), + ClientRequest::Authorize(authorize) => self.on_authorize(request, authorize, out), + ClientRequest::Submit(submit) => self.on_submit(request, &submit, out), ClientRequest::ExtranonceSubscribe => { // The common owner fixes each session's extranonce prefix; it is // never rotated, so there is nothing to subscribe to. - emit_line( + emit_reply( + request, out, &encode_reject(&request.id, &errors::EXTRANONCE_SUBSCRIPTION_UNSUPPORTED), ); Verdict::Ignored } ClientRequest::SuggestDifficulty(difficulty) => { - self.on_suggest(&request.id, difficulty, out) + self.on_suggest(request, difficulty, out) } ClientRequest::Unknown => { - emit_line(out, &encode_error(&request.id, &errors::METHOD_NOT_FOUND)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::METHOD_NOT_FOUND), + ); Verdict::Violation } } @@ -1312,12 +1340,16 @@ impl SessionState { fn on_configure( &mut self, - id: &RequestId, + request: &Request, configure: &Configure, out: &mut Vec, ) -> Verdict { if self.work_started { - emit_line(out, &encode_error(id, &errors::CONFIGURE_TOO_LATE)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::CONFIGURE_TOO_LATE), + ); return Verdict::Violation; } let mut members: Vec = Vec::new(); @@ -1344,20 +1376,34 @@ impl SessionState { members.push(format!("{}:false", json_string(name))); } } - emit_line( + emit_reply( + request, out, - &encode_result(id, &format!("{{{}}}", members.join(","))), + &encode_result(&request.id, &format!("{{{}}}", members.join(","))), ); Verdict::Good } - fn on_subscribe(&mut self, id: &RequestId, subscribe: Subscribe, out: &mut Vec) -> Verdict { + fn on_subscribe( + &mut self, + request: &Request, + subscribe: Subscribe, + out: &mut Vec, + ) -> Verdict { if self.lease.is_some() { - emit_line(out, &encode_error(id, &errors::ALREADY_SUBSCRIBED)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::ALREADY_SUBSCRIBED), + ); return Verdict::Violation; } let Some(lease) = self.shared.pool.open_lease().ok() else { - emit_line(out, &encode_error(id, &errors::POOL_AT_CAPACITY)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::POOL_AT_CAPACITY), + ); return Verdict::Fatal(SessionEnd::PoolRefused); }; let extranonce1 = encode_hex(lease.session.extranonce_prefix()); @@ -1366,7 +1412,7 @@ impl SessionState { let result = format!( "[[[\"mining.set_difficulty\",\"d{tag}\"],[\"mining.notify\",\"n{tag}\"]],\"{extranonce1}\",{extranonce2_size}]" ); - emit_line(out, &encode_result(id, &result)); + emit_reply(request, out, &encode_result(&request.id, &result)); self.shared.emit(&ServerEvent::Subscribed { peer: self.peer, session: lease.session.id(), @@ -1379,14 +1425,23 @@ impl SessionState { Verdict::Good } - fn on_authorize(&mut self, id: &RequestId, authorize: Authorize, out: &mut Vec) -> Verdict { + fn on_authorize( + &mut self, + request: &Request, + authorize: Authorize, + out: &mut Vec, + ) -> Verdict { let Ok(identity) = self .shared .accounts .resolve(&authorize.login, &authorize.password) else { self.auth_failures = self.auth_failures.saturating_add(1); - emit_line(out, &encode_reject(id, &errors::UNAUTHORIZED_WORKER_ERROR)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::UNAUTHORIZED_WORKER_ERROR), + ); return if self.auth_failures >= self.shared.config.max_auth_failures { Verdict::Fatal(SessionEnd::TooManyAuthFailures) } else { @@ -1400,21 +1455,34 @@ impl SessionState { .any(|(login, _)| *login == authorize.login) { if self.workers.len() >= self.shared.config.max_workers_per_session { - emit_line(out, &encode_error(id, &errors::TOO_MANY_WORKERS)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::TOO_MANY_WORKERS), + ); return Verdict::Violation; } self.workers.push((authorize.login, identity)); } - emit_line(out, &encode_result(id, "true")); + emit_reply(request, out, &encode_result(&request.id, "true")); if self.is_ready() { self.poll_hint = true; } Verdict::Good } - fn on_suggest(&mut self, id: &RequestId, difficulty: Difficulty, out: &mut Vec) -> Verdict { + fn on_suggest( + &mut self, + request: &Request, + difficulty: Difficulty, + out: &mut Vec, + ) -> Verdict { if !self.shared.config.difficulty.contains(&difficulty) { - emit_line(out, &encode_reject(id, &errors::DIFFICULTY_OUT_OF_RANGE)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::DIFFICULTY_OUT_OF_RANGE), + ); return Verdict::Ignored; } if difficulty != self.difficulty { @@ -1423,7 +1491,7 @@ impl SessionState { self.difficulty = difficulty; self.difficulty_pending = true; } - emit_line(out, &encode_result(id, "true")); + emit_reply(request, out, &encode_result(&request.id, "true")); if self.is_ready() { self.poll_hint = true; } @@ -1439,9 +1507,13 @@ impl SessionState { } } - fn on_submit(&mut self, id: &RequestId, submit: &Submit, out: &mut Vec) -> Verdict { + fn on_submit(&mut self, request: &Request, submit: &Submit, out: &mut Vec) -> Verdict { let Some(lease) = self.lease.as_ref() else { - emit_line(out, &encode_error(id, &errors::NOT_SUBSCRIBED_ERROR)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::NOT_SUBSCRIBED_ERROR), + ); return Verdict::Violation; }; let Some(identity) = self @@ -1450,33 +1522,53 @@ impl SessionState { .find(|(login, _)| *login == submit.login) .map(|(_, identity)| identity.clone()) else { - emit_line(out, &encode_error(id, &errors::UNAUTHORIZED_WORKER_ERROR)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::UNAUTHORIZED_WORKER_ERROR), + ); return Verdict::Violation; }; if let Some(bits) = submit.version_bits { let Some(mask) = self.version_rolling else { - emit_line(out, &encode_error(id, &errors::UNEXPECTED_VERSION_BITS)); + emit_reply( + request, + out, + &encode_error(&request.id, &errors::UNEXPECTED_VERSION_BITS), + ); return Verdict::Violation; }; if bits & !mask != 0 { - emit_line(out, &encode_reject(id, &errors::VERSION_BITS_OUTSIDE_MASK)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::VERSION_BITS_OUTSIDE_MASK), + ); return Verdict::Rejected; } } // Job text this adapter never issued cannot name a job. let Some(job) = parse_wire_job_id(&submit.job_id) else { - emit_line(out, &encode_reject(id, &errors::JOB_NOT_FOUND_ERROR)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::JOB_NOT_FOUND_ERROR), + ); return Verdict::Rejected; }; // Shed before validation: an accepted share burns its duplicate // identity, so a second submit while a completion is outstanding // must not enter the book or the outbox. if !self.deferred.is_empty() { - emit_line(out, &encode_reject(id, &errors::ACCOUNTING_UNAVAILABLE)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::ACCOUNTING_UNAVAILABLE), + ); return Verdict::Ignored; } let target = self.effective_target(Instant::now()); - let request = ShareRequest { + let share_request = ShareRequest { session: lease.session.id(), job, rolled: &submit.extranonce2, @@ -1484,14 +1576,16 @@ impl SessionState { nonce: submit.nonce, version_bits: submit.version_bits, }; - let session = request.session; - match self.shared.pool.submit(&identity, &request, &target) { + let session = share_request.session; + match self.shared.pool.submit(&identity, &share_request, &target) { Ok(credited) => { if credited.completion.is_complete() { - self.emit_credited(id, session, &credited, out); + let reply = request.has_id.then_some(&request.id); + self.emit_credited(reply, session, &credited, out); } else { self.deferred.push(DeferredResponse { - id: id.clone(), + id: request.id.clone(), + has_id: request.has_id, hash: credited.hash, class: credited.class, block: credited.block, @@ -1504,29 +1598,40 @@ impl SessionState { Err(SubmitFailure::Share(error)) => { let wire = share_error_wire(&error); if matches!(error, ShareError::ExtranonceLength { .. }) { - emit_line(out, &encode_error(id, &wire)); + emit_reply(request, out, &encode_error(&request.id, &wire)); Verdict::Violation } else { - emit_line(out, &encode_reject(id, &wire)); + emit_reply(request, out, &encode_reject(&request.id, &wire)); Verdict::Rejected } } Err(SubmitFailure::Duplicate) => { - emit_line(out, &encode_reject(id, &errors::DUPLICATE_SHARE_ERROR)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::DUPLICATE_SHARE_ERROR), + ); Verdict::Rejected } Err(SubmitFailure::OutboxFull) => { // The share was rejected BEFORE validation; the hash was NOT burned. // The miner may retry. - emit_line(out, &encode_reject(id, &errors::ACCOUNTING_UNAVAILABLE)); + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::ACCOUNTING_UNAVAILABLE), + ); Verdict::Ignored } } } + /// Records the credited share and, when `id` is present, answers it. + /// `None` is a notification completion: the `ShareAccepted`/`BlockSubmitted` + /// events still emit, but no wire reply does. fn emit_credited( &self, - id: &RequestId, + id: Option<&RequestId>, session: SessionId, credited: &Credited, out: &mut Vec, @@ -1543,7 +1648,9 @@ impl SessionState { outcome, }); } - emit_line(out, &encode_result(id, "true")); + if let Some(id) = id { + emit_line(out, &encode_result(id, "true")); + } } /// Sends pending difficulty and the newest job this session has not seen. @@ -1562,7 +1669,8 @@ impl SessionState { completion: response.completion, block: response.block, }; - self.emit_credited(&response.id, session, &credited, out); + let reply = response.has_id.then_some(&response.id); + self.emit_credited(reply, session, &credited, out); } } else { pending.push(response); From 86b14980d675b834e18ade026370ed402d63033d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:37:37 +0900 Subject: [PATCH 10/61] SV2 lane docs: announce delivery follows per-connection state, not fresh --- crates/stratum-v2/src/session.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/stratum-v2/src/session.rs b/crates/stratum-v2/src/session.rs index 090e8318c2..db14fd7b9c 100644 --- a/crates/stratum-v2/src/session.rs +++ b/crates/stratum-v2/src/session.rs @@ -1341,11 +1341,14 @@ impl ProtocolSession { /// Announces a job the owner just installed to every channel. /// - /// `installed.fresh == false` sends nothing. `clean_jobs` sends a future - /// job and the `SetNewPrevHash` that activates it (earlier jobs are stale - /// in the owner and invalid on the miner); otherwise the job is active - /// immediately on the last announced previous hash. Connections that did - /// not set `REQUIRES_STANDARD_JOBS` receive one group-addressed + /// Delivery is driven by per-connection channel state, not the owner's + /// `fresh` flag: channels missing the job (or holding it inactive for + /// want of its `SetNewPrevHash`) still converge on it. `clean_jobs` + /// sends a future job and the `SetNewPrevHash` that activates it + /// (earlier jobs are stale in the owner and invalid on the miner); + /// otherwise the job is active immediately on the last announced + /// previous hash. Connections that did not set + /// `REQUIRES_STANDARD_JOBS` receive one group-addressed /// `NewExtendedMiningJob`; the others, one job per channel. pub fn announce( &mut self, From 1aabb2267d277b853ccad8f744dabbb358ff933c Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:13 +0900 Subject: [PATCH 11/61] Repair SV1 pool-core reservations, delivery, and submit ownership - Reserve two item slots clamped to the configured bounds with only share plus block-framing bytes, so minima admit a submit on an empty outbox and one in-flight submit cannot spend the whole pool budget and stall the pool at a single share. Block bodies admit over the byte bound rather than shedding a found block. - Continue block-record delivery when the share is already durable (crash restore, post-eviction replay) or permanently refused, and report the duplicate only after the block fate is known. - Record block-candidate shares in the duplicate set even at per-job capacity, so replays report Duplicate instead of validating twice. - Normalize SV1 wire version bits to the canonical header version at the edge before the common submit owner; submit and settlement keep no wire branching and the book stays the single share predicate. - Map permanent ledger conflicts to RecordOutcome::Rejected through the SV1 adapter only: rejected shares answer Duplicate with no retry and no buffering, and refused block records drop instead of wedging the drain. SV2 paths keep their existing contract. - Regressions: minima admission, pool-wide reservation, crash-restore block delivery, permanent-refusal no-retry/no-wedge, edge version normalization, unknown-job verdict, block-at-capacity replay. --- crates/node/src/pool/ledger.rs | 34 ++- crates/stratum/src/job.rs | 13 +- crates/stratum/src/sv1.rs | 4 + crates/stratum/src/sv1/session.rs | 106 +++++++-- crates/stratum/tests/jobs.rs | 27 +++ crates/stratum/tests/protocol.rs | 378 +++++++++++++++++++++++++++++- 6 files changed, 526 insertions(+), 36 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index d957581c70..27dbea3a25 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -668,6 +668,11 @@ impl AccountResolver for LedgerHost { } } +/// SV1 settlement through the shared ledger. These two methods mirror +/// [`LedgerHost::record_accepted_share`] and +/// [`LedgerHost::record_submitted_block`] (which keep the SV2 contract), with +/// one addition: permanent ledger conflicts surface here instead of +/// collapsing into [`LedgerUnavailable`]. impl ShareLedger for LedgerHost { fn record_share( &self, @@ -675,17 +680,40 @@ impl ShareLedger for LedgerHost { share: &AcceptedShare, ) -> Result { let account = self.account(&worker.account).ok_or(LedgerUnavailable)?; - self.record_accepted_share(account, share) + let credit = share_credit(self.run_epoch, account, share)?; + match self.ledger.record_share(&credit) { + Ok(receipt) => Ok(match receipt { + ShareReceipt::Credited { .. } => RecordOutcome::Recorded, + ShareReceipt::Duplicate { .. } => RecordOutcome::AlreadyRecorded, + }), + // Permanent conflicts are uncurable: report a rejected share with + // no retry and no outbox buffering, never an unavailable ledger. + // A poisoned head-of-queue would otherwise wedge the drain behind + // an item that can never be delivered. Delivery stays + // at-least-once; the ledger consumer is idempotent on the share + // hash. + Err(error) if error.is_permanent_share_conflict() => Ok(RecordOutcome::Rejected), + Err(_) => Err(LedgerUnavailable), + } } fn record_block( &self, worker: &WorkerIdentity, block: &FoundBlock, - submission: &Result, + _submission: &Result, ) -> Result<(), LedgerUnavailable> { let account = self.account(&worker.account).ok_or(LedgerUnavailable)?; - self.record_submitted_block(account, block, submission) + let found = ledger_block(self.run_epoch, account, block)?; + match self.ledger.record_found_block(&found) { + Ok(_) => Ok(()), + // A permanently refused block record is settled elsewhere (the + // round exists with a different claim): dropping it here keeps a + // poison head from wedging the drain behind shares that can still + // be recorded. The node already holds the submitted block. + Err(error) if error.is_permanent_share_conflict() => Ok(()), + Err(_) => Err(LedgerUnavailable), + } } } diff --git a/crates/stratum/src/job.rs b/crates/stratum/src/job.rs index 55c3abc304..318ca1e78e 100644 --- a/crates/stratum/src/job.rs +++ b/crates/stratum/src/job.rs @@ -1555,11 +1555,14 @@ impl JobBook { ShareOutcome::Share(Box::new(accepted)) }; - if room { - self.seen.insert(raw_hash, share.job); - if let Some(entry) = self.jobs.get_mut(index) { - entry.hashes.push(raw_hash); - } + // Block candidates bypass the per-job capacity check above, and they + // must still join the duplicate set: linearizable under the single + // `JobBook` borrow, so a replayed block reports `Duplicate` instead + // of validating as a second block, and crash recovery cannot credit + // it twice. Non-block shares that reach here always have room. + self.seen.insert(raw_hash, share.job); + if let Some(entry) = self.jobs.get_mut(index) { + entry.hashes.push(raw_hash); } Ok(outcome) } diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs index 15a26f6345..42e4e49d48 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/stratum/src/sv1.rs @@ -878,6 +878,10 @@ pub enum RecordOutcome { Recorded, /// A row with this header hash already exists; nothing changed. AlreadyRecorded, + /// The ledger refused the share permanently (retired round, conflicting + /// credit, or claimed block): never retry and never buffer. Adapters + /// report a rejected share, not an unavailable ledger. + Rejected, } /// Pool accounting, separate from this adapter. diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index bef960650e..8a7f0fb2c0 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -441,8 +441,8 @@ impl Pending { fn bytes_cost(&self) -> usize { match self { - Self::Share { .. } => 256, - Self::BlockRecord { block, .. } => 512 + block.raw.len(), + Self::Share { .. } => SHARE_RECORD_BYTES, + Self::BlockRecord { block, .. } => BLOCK_RECORD_FRAMING_BYTES + block.raw.len(), } } } @@ -506,7 +506,10 @@ impl Drop for DrainGuard<'_> { impl OutboxReservation<'_> { /// Commits an item into the outbox by consuming part of this reservation. - /// Capacity is guaranteed because it was pre-allocated. + /// Item slots are linearizable under the single outbox mutex, so the + /// pre-allocated slots are guaranteed. A block body is admitted over the + /// byte bound rather than shedding a found block, and still counts toward + /// `total_bytes`. fn commit_reserved(&mut self, item: Pending) { let cost = item.bytes_cost(); let mut outbox = locked(&self.pool.outbox); @@ -538,7 +541,9 @@ struct ShareRequest<'a> { rolled: &'a [u8], ntime: u32, nonce: u32, - version_bits: Option, + /// Canonical header version, normalized by the SV1 edge before the + /// common submit owner runs. + version: u32, } /// The pool: one `JobBook`, the node control it submits blocks to, and the @@ -913,13 +918,19 @@ impl PoolCore { /// Atomically reserves outbox space for one share (and potential block). /// Returns `None` if the outbox has reached capacity. - /// Per-session bound for withheld submit responses: one outstanding - /// accounting completion. A second submit while the ledger is unavailable - /// is shed with a retryable accounting error, not buffered. + /// The check-and-hold is linearizable under the single outbox mutex. A + /// second submit while the ledger is unavailable is shed with a retryable + /// accounting error, not buffered. fn reserve_outbox_space(&self) -> Option> { self.drain_outbox(); - let reserve_items = 2_usize; - let reserve_bytes = 8 * 1024 * 1024_usize; + // Two item slots, clamped so configured minima still admit one submit + // on an empty outbox. The byte half covers the share and the + // block-record framing only: the block body is admitted over the byte + // bound rather than shed, so one in-flight submit never spends the + // whole pool budget and stalls the pool at a single share. + let reserve_items = 2_usize.min(self.outbox_max_items); + let reserve_bytes = + (SHARE_RECORD_BYTES + BLOCK_RECORD_FRAMING_BYTES).min(self.outbox_max_bytes); let mut outbox = locked(&self.outbox); let committed_items = outbox.items.len() + outbox.reserved_items; let committed_bytes = outbox.total_bytes.saturating_add(outbox.reserved_bytes); @@ -954,22 +965,15 @@ impl PoolCore { let outcome = { let mut book = locked(&self.book); - let version = match (book.job(request.job), request.version_bits) { - (Some(job), Some(bits)) => job - .version_from_rolled_bits(bits) - .map_err(SubmitFailure::Share)?, - (Some(job), None) => job.version(), - // The book rejects an unknown or retired job before it reads - // the version. - (None, _) => 0, - }; + // `request.version` is already canonical: the edge normalized the + // wire bits, so this common path holds no wire branching. let submission = ShareSubmission { session: request.session, job: request.job, rolled_extranonce: request.rolled, ntime: request.ntime, nonce: request.nonce, - version, + version: request.version, }; book.validate(&submission, target) .map_err(SubmitFailure::Share)? @@ -985,9 +989,17 @@ impl PoolCore { completion: Arc::clone(&completion), }; // Attempt inline delivery first; otherwise the completion waits on the outbox. + // Delivery is at-least-once (inline plus drain) against the + // idempotent ledger consumer, which is keyed on `share.hash`. match self.deliver_inline(&item) { Ok(RecordOutcome::Recorded) => completion.record(), Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), + // Permanently refused: answer the duplicate identity at + // once with no retry and no buffering. The book already + // burned the hash, so a miner retry converges to the same + // duplicate verdict instead of spinning on an uncurable + // ledger refusal. + Ok(RecordOutcome::Rejected) => return Err(SubmitFailure::Duplicate), Err(LedgerUnavailable) => reservation.commit_reserved(item), } Ok(Credited { @@ -1024,9 +1036,20 @@ impl PoolCore { completion: Arc::clone(&completion), }; + // The share needs no further crediting when it is already + // durable (crash restore or post-eviction replay) or + // permanently refused (the ledger judged this work first). + // Either way the block record still needs delivery, so keep + // going and report the duplicate only after the block fate is + // known. No retry and no buffering on the settled path: a + // settled share can never become deliverable. + let mut share_settled = false; match self.deliver_inline(&share_item) { Ok(RecordOutcome::Recorded) => completion.record(), - Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), + Ok(RecordOutcome::AlreadyRecorded) | Ok(RecordOutcome::Rejected) => { + completion.record(); + share_settled = true; + } Err(LedgerUnavailable) => reservation.commit_reserved(share_item), } @@ -1044,6 +1067,13 @@ impl PoolCore { })); } + if share_settled { + // The share earned no new credit, but the block round is + // now durable: the replay keeps its duplicate identity, + // exactly as a book-level duplicate would. + return Err(SubmitFailure::Duplicate); + } + Ok(Credited { hash, class: ShareClass::Block, @@ -1567,6 +1597,40 @@ impl SessionState { ); return Verdict::Ignored; } + // Normalize the wire version bits to the canonical header version + // before the common submit owner: rolled-bits interpretation lives in + // this SV1 edge (read through the single authoritative `JobBook`, + // which stays the sole owner of the share predicate), so + // `PoolCore::submit` and settlement below see only canonical + // submissions and hold no wire branching. + let version = match self.shared.pool.with_job_book(|book| { + let job_ref = book.job(job)?; + Some(match submit.version_bits { + Some(bits) => job_ref.version_from_rolled_bits(bits), + None => Ok(job_ref.version()), + }) + }) { + Some(Ok(version)) => version, + Some(Err(error)) => { + let wire = share_error_wire(&error); + if matches!(error, ShareError::ExtranonceLength { .. }) { + emit_reply(request, out, &encode_error(&request.id, &wire)); + return Verdict::Violation; + } + emit_reply(request, out, &encode_reject(&request.id, &wire)); + return Verdict::Rejected; + } + // The book rejects an unknown job before it reads the version; + // answer here with the same verdict the owner would produce. + None => { + emit_reply( + request, + out, + &encode_reject(&request.id, &errors::JOB_NOT_FOUND_ERROR), + ); + return Verdict::Rejected; + } + }; let target = self.effective_target(Instant::now()); let share_request = ShareRequest { session: lease.session.id(), @@ -1574,7 +1638,7 @@ impl SessionState { rolled: &submit.extranonce2, ntime: submit.ntime, nonce: submit.nonce, - version_bits: submit.version_bits, + version, }; let session = share_request.session; match self.shared.pool.submit(&identity, &share_request, &target) { diff --git a/crates/stratum/tests/jobs.rs b/crates/stratum/tests/jobs.rs index 925d260af9..ee932799d8 100644 --- a/crates/stratum/tests/jobs.rs +++ b/crates/stratum/tests/jobs.rs @@ -707,6 +707,33 @@ fn rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_blo Ok(()) } +#[test] +fn block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay() -> TestResult { + // A block bypasses the per-job capacity check, and it must still join + // the duplicate set: under the old tail the acceptance was not recorded, + // so a replay validated as a second block instead of `Duplicate`. + let mut tiny = config(); + tiny.max_shares_per_job = 1; + let mut fx = fixture(tiny, 100, 1)?; + let target = easy()?; + let share = nonces(&fx.session, &fx.job, false, 1)?[0]; + fx.book + .validate(&submit_for(&fx.session, &fx.job, share), &target)?; + let block = nonces(&fx.session, &fx.job, true, 1)?[0]; + let outcome = fx + .book + .validate(&submit_for(&fx.session, &fx.job, block), &target)?; + assert!(matches!(outcome, ShareOutcome::Block(_))); + let replay = fx + .book + .validate(&submit_for(&fx.session, &fx.job, block), &target); + assert!( + matches!(replay, Err(ShareError::Duplicate { .. })), + "a block accepted at capacity must be remembered: {replay:?}" + ); + Ok(()) +} + #[test] fn extra_outputs_are_funded_from_the_payout_and_stay_valid() -> TestResult { let candidate = build_candidate(100, 0xab, 2)?; diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs index 54a42d8053..200c5c1ec5 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/stratum/tests/protocol.rs @@ -14,7 +14,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; @@ -44,6 +44,7 @@ struct TestLedger { shares: Mutex>, blocks: Mutex>, fail_shares: Mutex>, + reject_shares: AtomicBool, share_calls: AtomicUsize, } @@ -61,10 +62,29 @@ impl TestLedger { shares: Mutex::new(Vec::new()), blocks: Mutex::new(Vec::new()), fail_shares: Mutex::new(Some(count)), + reject_shares: AtomicBool::new(false), share_calls: AtomicUsize::new(0), } } + /// Refuses every later share permanently, like a retired round or a + /// conflicting credit: the adapter must answer a rejection with no retry + /// and no buffering. + fn refuse_permanently(&self) { + self.set_reject(true); + } + + /// Admits shares again after a permanent-refusal phase. + fn admit_again(&self) { + self.set_reject(false); + } + + /// Mode change sequenced with `SeqCst` like the call counter so a racing + /// submit observes it. + fn set_reject(&self, reject: bool) { + self.reject_shares.store(reject, Ordering::SeqCst); + } + fn recorded_shares(&self) -> Vec { self.shares .lock() @@ -82,6 +102,9 @@ impl ShareLedger for TestLedger { share: &AcceptedShare, ) -> Result { self.share_calls.fetch_add(1, Ordering::SeqCst); + if self.reject_shares.load(Ordering::SeqCst) { + return Ok(RecordOutcome::Rejected); + } let mut fail = self .fail_shares .lock() @@ -167,6 +190,14 @@ fn sample_candidate(prev: Hash256, bits: CompactTarget, height: u32) -> Candidat } fn build_pool(control: Arc, ledger: Arc) -> Arc { + build_pool_with_outbox(control, ledger, None) +} + +fn build_pool_with_outbox( + control: Arc, + ledger: Arc, + outbox: Option<(usize, usize)>, +) -> Arc { let config = JobBookConfig { budget: CoinbaseBudget { additional_size: 100, @@ -180,10 +211,12 @@ fn build_pool(control: Arc, ledger: Arc) -> max_sessions: 64, max_shares_per_job: 1024, }; - Arc::new( - PoolCore::new(config, control, ledger) - .unwrap_or_else(|error| panic!("test operation failed: {error}")), - ) + let pool = PoolCore::new(config, control, ledger) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + match outbox { + Some((items, bytes)) => Arc::new(pool.with_outbox(items, bytes)), + None => Arc::new(pool), + } } struct TestClient { @@ -807,14 +840,26 @@ impl MinerFixture { /// repeated, then walks one miner through configure, subscribe, authorize /// and the first `mining.notify`. fn start(parent: u8) -> Self { + Self::start_with(parent, None, None) + } + + /// Like [`Self::start`], with an optional outbox override and an optional + /// shared ledger. Sharing the ledger across two fixtures simulates a + /// crash restore: the second pool's `JobBook` is empty while the ledger + /// still holds the first pool's rows. + fn start_with( + parent: u8, + outbox: Option<(usize, usize)>, + ledger: Option>, + ) -> Self { let candidate = Arc::new(sample_candidate( Hash256::from_le_bytes(&[parent; 32]), CompactTarget::from_consensus(0x207f_ffff), 1, )); let control = Arc::new(FakeMiningControl::default()); - let ledger = Arc::new(TestLedger::default()); - let pool = build_pool(control.clone(), ledger.clone()); + let ledger = ledger.unwrap_or_default(); + let pool = build_pool_with_outbox(control.clone(), ledger.clone(), outbox); let installed = pool .install(candidate, Vec::new()) .unwrap_or_else(|error| panic!("test operation failed: {error}")); @@ -909,6 +954,29 @@ impl MinerFixture { .unwrap_or_else(|| panic!("a winning nonce exists")) } + /// First nonce at or after `start` whose header is not a block: a plain + /// share that never reaches node submission, so the job stays installed + /// no matter how the ledger answers. + fn share_nonce(&self, start: u32) -> u32 { + (start..=u32::MAX) + .find(|nonce| { + let header = self + .job + .header( + Extranonce { + session: &self.prefix, + rolled: &ROLLED, + }, + self.job.ntime(), + self.version, + *nonce, + ) + .unwrap_or_else(|error| panic!("header builds: {error:?}")); + !compact_is_met_by(self.job.bits(), Hash256::from(header.compute_hash())) + }) + .unwrap_or_else(|| panic!("a plain share nonce exists")) + } + /// Sends `mining.submit` for `nonce` on `job` and returns the response /// line that carries `id`. fn submit(&mut self, id: u32, job: &Job, nonce: u32) -> String { @@ -1030,3 +1098,299 @@ fn stale_parent_backend_verdict_is_stale_error() { miner.server.shutdown(); } + +#[test] +fn configured_minima_cannot_fail_an_empty_outbox() -> TestResult { + // Degenerate bounds: one item, one share-sized byte budget. An empty + // outbox must still admit a submit; the old worst-case reservation + // failed every submit here before validation ran. + let mut miner = MinerFixture::start_with(0x22, Some((1, 1024)), None); + let job = Arc::clone(&miner.job); + let nonce = miner.share_nonce(0); + let response = miner.submit(100, &job, nonce); + assert!( + response.contains("\"error\":null"), + "minima must admit a share on an empty outbox: {response}" + ); + assert_eq!( + miner.pool.pending_ledger_writes(), + 0, + "inline delivery leaves nothing buffered" + ); + assert_eq!( + miner.ledger.recorded_shares().len(), + 1, + "the admitted share is credited exactly once" + ); + miner.server.shutdown(); + Ok(()) +} + +#[test] +fn per_submit_reservation_does_not_cap_the_pool_at_one_share() -> TestResult { + // Node deployment bounds with a permanently failing ledger: the first + // share buffers, and the second must buffer too instead of shedding with + // backpressure while the outbox is nearly empty. + let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); + let mut miner = MinerFixture::start_with(0x22, Some((1024, 8 * 1024 * 1024)), Some(ledger)); + let first = miner.share_nonce(0); + let second = miner.share_nonce(first + 1); + assert_ne!(first, second); + miner.client.send(&format!( + "{{\"id\":101,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", + miner.job.id().get(), + miner.job.ntime(), + first, + )); + miner.client.send(&format!( + "{{\"id\":102,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", + miner.job.id().get(), + miner.job.ntime(), + second, + )); + // Both responses are withheld until the records are durable; any early + // frame is the old whole-pool reservation shedding the second submit. + miner + .client + .stream + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut early = String::new(); + let shed = TestClient::read_available(&mut miner.client.reader, &mut early); + assert!( + !shed, + "no shed may precede the durable records, got: {early:?}" + ); + assert_eq!( + miner.pool.pending_ledger_writes(), + 2, + "both shares buffer instead of capping the pool at one" + ); + assert!( + miner.pool.current_installed().is_some(), + "plain shares never retire the job" + ); + + // Disarm the barrier, drain, and collect both deferred acceptances. + miner.ledger.disarm(); + miner.pool.drain_outbox(); + miner + .client + .stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + for id in [101, 102] { + let mut line = String::new(); + for _ in 0..8 { + line = miner.client.read_line(); + if line.contains(&format!("\"id\":{id}")) { + break; + } + } + assert!( + line.contains("\"error\":null"), + "buffered share {id} is accepted after drain: {line}" + ); + } + assert_eq!( + miner.ledger.recorded_shares().len(), + 2, + "both buffered shares are credited exactly once" + ); + miner.server.shutdown(); + Ok(()) +} + +#[test] +fn crash_restored_share_still_delivers_its_block_record() -> TestResult { + // First pool finds a block: share and block record both durable. + let mut first = MinerFixture::start(0x22); + let job = Arc::clone(&first.job); + let winner = first.winning_nonce(0); + let accepted = first.submit(100, &job, winner); + assert!( + accepted.contains("\"error\":null"), + "winning nonce accepted: {accepted}" + ); + assert_eq!( + first.ledger.recorded_shares().len(), + 1, + "first pool credits the share" + ); + { + let blocks = first + .ledger + .blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert_eq!(blocks.len(), 1, "first pool records the block"); + } + + // Second pool over the same ledger: an empty book, durable rows. The + // fresh session reuses slot zero, so the same winning nonce rebuilds the + // identical header hash the ledger already holds. + let mut second = MinerFixture::start_with(0x22, None, Some(Arc::clone(&first.ledger))); + assert_eq!( + second.prefix, first.prefix, + "fresh first sessions share the extranonce prefix" + ); + let restored = second.winning_nonce(0); + assert_eq!( + restored, winner, + "same candidate and prefix rebuild the same winner" + ); + let job_b = Arc::clone(&second.job); + let replay = second.submit(200, &job_b, restored); + assert!( + replay.contains("Duplicate share"), + "crash-restored share keeps its duplicate identity: {replay}" + ); + { + let blocks = second + .ledger + .blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert_eq!( + blocks.len(), + 2, + "the block record is still delivered for a restored share" + ); + } + assert_eq!( + second.control.submit_calls.load(Ordering::SeqCst), + 1, + "the restored block still reaches the node once" + ); + first.server.shutdown(); + second.server.shutdown(); + Ok(()) +} + +#[test] +fn permanently_refused_share_is_rejected_without_retry_or_buffer() -> TestResult { + let mut miner = MinerFixture::start(0x22); + miner.ledger.refuse_permanently(); + let job = Arc::clone(&miner.job); + let nonce = miner.share_nonce(0); + let response = miner.submit(100, &job, nonce); + assert!( + response.contains("\"error\":[22,\"Duplicate share\",null]"), + "permanent refusal is a rejected share: {response}" + ); + assert_eq!( + miner.ledger.share_calls.load(Ordering::SeqCst), + 1, + "a permanent refusal is never retried" + ); + assert_eq!( + miner.pool.pending_ledger_writes(), + 0, + "a permanent refusal never buffers" + ); + assert!( + miner.ledger.recorded_shares().is_empty(), + "a refused share credits nothing" + ); + miner.server.shutdown(); + Ok(()) +} + +#[test] +fn permanently_refused_buffer_never_wedges_the_drain() -> TestResult { + // Buffer one share against a transient outage, then turn the ledger + // permanently refusing: the drain must drop the poisoned head instead of + // stalling behind it, and later shares must still be credited. + let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); + let mut miner = MinerFixture::start_with(0x22, None, Some(ledger)); + let job = Arc::clone(&miner.job); + let first = miner.share_nonce(0); + let second = miner.share_nonce(first + 1); + miner.client.send(&format!( + "{{\"id\":101,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", + job.id().get(), + job.ntime(), + first, + )); + miner + .client + .stream + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut early = String::new(); + assert!( + !TestClient::read_available(&mut miner.client.reader, &mut early), + "transient outage buffers without answering: {early:?}" + ); + assert_eq!(miner.pool.pending_ledger_writes(), 1); + + miner.ledger.refuse_permanently(); + miner.ledger.disarm(); + miner.pool.drain_outbox(); + assert_eq!( + miner.pool.pending_ledger_writes(), + 0, + "the poisoned head drops instead of wedging the drain" + ); + assert!( + miner.ledger.recorded_shares().is_empty(), + "a refused share credits nothing" + ); + + miner.ledger.admit_again(); + let response = miner.submit(102, &job, second); + assert!( + response.contains("\"error\":null"), + "later shares still flow after the drop: {response}" + ); + assert_eq!( + miner.ledger.recorded_shares().len(), + 1, + "only the deliverable share is credited" + ); + miner.server.shutdown(); + Ok(()) +} + +#[test] +fn sv1_wire_version_bits_normalize_to_the_job_version() -> TestResult { + let mut miner = MinerFixture::start(0x22); + let job = Arc::clone(&miner.job); + let nonce = miner.share_nonce(0); + let response = miner.submit(100, &job, nonce); + assert!( + response.contains("\"error\":null"), + "mask-clean version bits accepted: {response}" + ); + let shares = miner.ledger.recorded_shares(); + assert_eq!(shares.len(), 1); + assert_eq!( + shares[0].header.version, + i32::try_from(miner.version) + .unwrap_or_else(|error| { panic!("canonical job version fits i32: {error}") }), + "the edge normalizes rolled bits to the canonical job version" + ); + assert_eq!(shares[0].job, job.id()); + miner.server.shutdown(); + Ok(()) +} + +#[test] +fn unknown_wire_job_rejects_before_the_common_submit_owner() -> TestResult { + let mut miner = MinerFixture::start(0x22); + miner.client.send(&format!( + "{{\"id\":100,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"0000007b\",\"00000001\",\"{:08x}\",\"00000000\",\"00002000\"]}}\n", + miner.job.ntime(), + )); + let response = miner.client.read_line(); + assert!( + response.contains("\"error\":[21,\"Job not found\",null]"), + "unknown job rejects with the owner verdict: {response}" + ); + assert!( + miner.ledger.recorded_shares().is_empty(), + "an unknown job credits nothing" + ); + miner.server.shutdown(); + Ok(()) +} From cee998c5093e61c8d1a110985d0b89302ddf81a1 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:47:28 +0900 Subject: [PATCH 12/61] SV2 lane fixup: compare applied-tip snapshots by Arc identity to close ABA recheck hole --- crates/node/src/pool/ledger.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index 27dbea3a25..c1d278fd6e 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -62,10 +62,13 @@ impl NodeChainView { /// snapshot this before and after the lock-free reconcile and retry when /// it moves, so a mid-reconcile reorg converges on the next attempt /// instead of committing a torn view as final. - fn tip_generation(&self) -> Option<(u32, Hash256)> { - self.chainstate - .applied_tip_snapshot() - .map(|tip| (tip.height, tip.hash)) + /// + /// The token is the stored snapshot `Arc` itself, compared by pointer + /// identity (see `tip_unchanged`): every applied transition stores a + /// fresh `Arc`, so an A→B→A round trip can never compare equal. A bare + /// `(height, hash)` token would miss exactly that interleaving. + fn tip_generation(&self) -> Option> { + self.chainstate.applied_tip_snapshot() } fn load_block(&self, height: u32, hash: Hash256) -> Result { @@ -402,6 +405,20 @@ impl ChainView for NodeChainView { } } +/// Generation equality by snapshot identity: the applied tip is stored as one +/// `Arc` per transition, so pointer equality proves no transition landed +/// between the two snapshots (and any A→B→A round trip compares unequal). +fn tip_unchanged( + before: &Option>, + after: &Option>, +) -> bool { + match (before, after) { + (None, None) => true, + (Some(first), Some(second)) => Arc::ptr_eq(first, second), + (None, Some(_)) | (Some(_), None) => false, + } +} + /// Shared durable accounting adapter for SV1, SV2, and operator RPC. pub(crate) struct LedgerHost { ledger: Arc, @@ -538,7 +555,7 @@ impl LedgerHost { })(); let after = chain.tip_generation(); match result { - Ok(()) if before == after => break Ok(()), + Ok(()) if tip_unchanged(&before, &after) => break Ok(()), Ok(()) => { attempts += 1; if attempts >= 2 { @@ -552,7 +569,7 @@ impl LedgerHost { // A chain-observation error may be a torn view from a // concurrent transition; retry once against the new tip. attempts += 1; - if attempts >= 2 || before == after { + if attempts >= 2 || tip_unchanged(&before, &after) { break Err(error); } } From 7339cb5627369ebf9b723932117a9ee5fa352d0b Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:12:50 +0900 Subject: [PATCH 13/61] SV2 lane fixup: annotate lock-free reconcile closure error type --- crates/node/src/pool/ledger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index c1d278fd6e..8654c105aa 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -548,7 +548,7 @@ impl LedgerHost { let reconcile_result = loop { let before = chain.tip_generation(); let observation = RawChainView(chain); - let result = (|| { + let result: Result<(), pool_ledger::LedgerError> = (|| { self.ledger.reconcile_rounds(&observation)?; self.ledger.reconcile_payouts(&observation)?; Ok(()) From 78aba00fde73c14a125d8d73598339f381de704d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:33:21 +0900 Subject: [PATCH 14/61] Harden pool submission and rejection handling Normalize SV1 outcome handling, version test typing, and lint style. Exercise pool-wide outbox buffering, permanent-refusal recovery, block-share replay, and SV2 ticket/capacity behavior with the corrected fixtures. --- crates/stratum/src/sv1/session.rs | 18 +-- crates/stratum/tests/protocol.rs | 186 ++++++++++++++++++++++-------- 2 files changed, 148 insertions(+), 56 deletions(-) diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index 8a7f0fb2c0..04b98782aa 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -993,13 +993,15 @@ impl PoolCore { // idempotent ledger consumer, which is keyed on `share.hash`. match self.deliver_inline(&item) { Ok(RecordOutcome::Recorded) => completion.record(), - Ok(RecordOutcome::AlreadyRecorded) => return Err(SubmitFailure::Duplicate), - // Permanently refused: answer the duplicate identity at - // once with no retry and no buffering. The book already - // burned the hash, so a miner retry converges to the same - // duplicate verdict instead of spinning on an uncurable - // ledger refusal. - Ok(RecordOutcome::Rejected) => return Err(SubmitFailure::Duplicate), + // Already durable (crash restore or post-eviction replay) + // or permanently refused (the ledger judged this work + // first): answer the duplicate identity at once with no + // retry and no buffering. The book already burned the + // hash, so a miner retry converges to the same duplicate + // verdict instead of spinning on an uncurable refusal. + Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { + return Err(SubmitFailure::Duplicate); + } Err(LedgerUnavailable) => reservation.commit_reserved(item), } Ok(Credited { @@ -1046,7 +1048,7 @@ impl PoolCore { let mut share_settled = false; match self.deliver_inline(&share_item) { Ok(RecordOutcome::Recorded) => completion.record(), - Ok(RecordOutcome::AlreadyRecorded) | Ok(RecordOutcome::Rejected) => { + Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { completion.record(); share_settled = true; } diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs index 200c5c1ec5..0dfaadeef4 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/stratum/tests/protocol.rs @@ -16,7 +16,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; -use std::time::Duration; +use std::time::{Duration, Instant}; use bitcoin_rs_chain::compact_is_met_by; use bitcoin_rs_mining::{ @@ -193,6 +193,20 @@ fn build_pool(control: Arc, ledger: Arc) -> build_pool_with_outbox(control, ledger, None) } +/// Waits until the pool reports `expected` outbox writes or the deadline +/// passes: pool threads settle shared counters asynchronously, so tests +/// assert on this predicate instead of racing them. +fn wait_for_pending(pool: &Arc, expected: usize, what: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while pool.pending_ledger_writes() != expected { + assert!( + Instant::now() < deadline, + "outbox writes did not settle: {what}" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + fn build_pool_with_outbox( control: Arc, ledger: Arc, @@ -958,13 +972,19 @@ impl MinerFixture { /// share that never reaches node submission, so the job stays installed /// no matter how the ledger answers. fn share_nonce(&self, start: u32) -> u32 { + self.share_nonce_for(&self.prefix, start) + } + + /// [`Self::share_nonce`] under an explicit session prefix, for shares + /// submitted on a second session. + fn share_nonce_for(&self, prefix: &[u8; 4], start: u32) -> u32 { (start..=u32::MAX) .find(|nonce| { let header = self .job .header( Extranonce { - session: &self.prefix, + session: prefix, rolled: &ROLLED, }, self.job.ntime(), @@ -977,6 +997,44 @@ impl MinerFixture { .unwrap_or_else(|| panic!("a plain share nonce exists")) } + /// Opens a second authorized session on the same server and returns its + /// client with its session prefix. Shares under test use one submit per + /// session: a session never holds two outstanding accounting completions + /// by design, so pool-wide capacity needs two sessions. + fn second_client(&self, login: &str) -> (TestClient, [u8; 4]) { + let mut client = TestClient::connect(self.server.local_addr()); + client.send("{\"id\":1,\"method\":\"mining.configure\",\"params\":[[\"version-rolling\"],{\"version-rolling.mask\":\"1fffe000\",\"version-rolling.min-bit-count\":2}]}\n"); + assert!(client.read_line().contains("\"id\":1")); + client.send("{\"id\":2,\"method\":\"mining.subscribe\",\"params\":[\"miner/1.0.0\"]}\n"); + let subscribed = client.read_line(); + assert!(subscribed.contains("\"id\":2")); + let ext1_hex = subscribed + .split("]],\"") + .nth(1) + .and_then(|tail| tail.split('"').next()) + .unwrap_or_else(|| panic!("subscribe carries extranonce1: {subscribed}")); + assert_eq!(ext1_hex.len(), 8, "4-byte session prefix"); + let prefix = u32::from_str_radix(ext1_hex, 16) + .unwrap_or_else(|error| panic!("hex session prefix: {error:?}")) + .to_be_bytes(); + client.send(&format!( + "{{\"id\":3,\"method\":\"mining.authorize\",\"params\":[\"{login}\",\"secret123\"]}}\n" + )); + assert_eq!( + client.read_line(), + "{\"id\":3,\"result\":true,\"error\":null}\n" + ); + let mut seen_notify = false; + for _ in 0..4 { + if client.read_line().contains("\"method\":\"mining.notify\"") { + seen_notify = true; + break; + } + } + assert!(seen_notify, "installed job is announced"); + (client, prefix) + } + /// Sends `mining.submit` for `nonce` on `job` and returns the response /// line that carries `id`. fn submit(&mut self, id: u32, job: &Job, nonce: u32) -> String { @@ -1100,7 +1158,7 @@ fn stale_parent_backend_verdict_is_stale_error() { } #[test] -fn configured_minima_cannot_fail_an_empty_outbox() -> TestResult { +fn configured_minima_cannot_fail_an_empty_outbox() { // Degenerate bounds: one item, one share-sized byte budget. An empty // outbox must still admit a submit; the old worst-case reservation // failed every submit here before validation ran. @@ -1123,55 +1181,56 @@ fn configured_minima_cannot_fail_an_empty_outbox() -> TestResult { "the admitted share is credited exactly once" ); miner.server.shutdown(); - Ok(()) } #[test] -fn per_submit_reservation_does_not_cap_the_pool_at_one_share() -> TestResult { - // Node deployment bounds with a permanently failing ledger: the first - // share buffers, and the second must buffer too instead of shedding with - // backpressure while the outbox is nearly empty. +fn per_submit_reservation_does_not_cap_the_pool_at_one_share() { + // Node deployment bounds with a permanently failing ledger: a first + // session buffers one share, and a second session must buffer too + // instead of shedding with backpressure while the outbox is nearly + // empty. Two sessions because one session never holds two outstanding + // completions by design. let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); let mut miner = MinerFixture::start_with(0x22, Some((1024, 8 * 1024 * 1024)), Some(ledger)); let first = miner.share_nonce(0); - let second = miner.share_nonce(first + 1); - assert_ne!(first, second); + let (mut second_client, second_prefix) = miner.second_client("alice.worker2"); + let second = miner.share_nonce_for(&second_prefix, 0); miner.client.send(&format!( "{{\"id\":101,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", miner.job.id().get(), miner.job.ntime(), first, )); - miner.client.send(&format!( - "{{\"id\":102,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", + second_client.send(&format!( + "{{\"id\":102,\"method\":\"mining.submit\",\"params\":[\"alice.worker2\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", miner.job.id().get(), miner.job.ntime(), second, )); // Both responses are withheld until the records are durable; any early // frame is the old whole-pool reservation shedding the second submit. - miner - .client + second_client .stream .set_read_timeout(Some(Duration::from_millis(300))) .unwrap_or_else(|error| panic!("test operation failed: {error}")); let mut early = String::new(); - let shed = TestClient::read_available(&mut miner.client.reader, &mut early); + let shed = TestClient::read_available(&mut second_client.reader, &mut early); assert!( !shed, "no shed may precede the durable records, got: {early:?}" ); - assert_eq!( - miner.pool.pending_ledger_writes(), + wait_for_pending( + &miner.pool, 2, - "both shares buffer instead of capping the pool at one" + "both shares buffer instead of capping the pool at one", ); assert!( miner.pool.current_installed().is_some(), "plain shares never retire the job" ); - // Disarm the barrier, drain, and collect both deferred acceptances. + // Disarm the barrier, drain, and collect both deferred acceptances, one + // waiter per session. miner.ledger.disarm(); miner.pool.drain_outbox(); miner @@ -1179,30 +1238,42 @@ fn per_submit_reservation_does_not_cap_the_pool_at_one_share() -> TestResult { .stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap_or_else(|error| panic!("test operation failed: {error}")); - for id in [101, 102] { - let mut line = String::new(); - for _ in 0..8 { - line = miner.client.read_line(); - if line.contains(&format!("\"id\":{id}")) { - break; - } + second_client + .stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut first_line = String::new(); + for _ in 0..8 { + first_line = miner.client.read_line(); + if first_line.contains("\"id\":101") { + break; } - assert!( - line.contains("\"error\":null"), - "buffered share {id} is accepted after drain: {line}" - ); } + assert!( + first_line.contains("\"error\":null"), + "buffered share 101 is accepted after drain: {first_line}" + ); + let mut second_line = String::new(); + for _ in 0..8 { + second_line = second_client.read_line(); + if second_line.contains("\"id\":102") { + break; + } + } + assert!( + second_line.contains("\"error\":null"), + "buffered share 102 is accepted after drain: {second_line}" + ); assert_eq!( miner.ledger.recorded_shares().len(), 2, "both buffered shares are credited exactly once" ); miner.server.shutdown(); - Ok(()) } #[test] -fn crash_restored_share_still_delivers_its_block_record() -> TestResult { +fn crash_restored_share_still_delivers_its_block_record() { // First pool finds a block: share and block record both durable. let mut first = MinerFixture::start(0x22); let job = Arc::clone(&first.job); @@ -1264,11 +1335,10 @@ fn crash_restored_share_still_delivers_its_block_record() -> TestResult { ); first.server.shutdown(); second.server.shutdown(); - Ok(()) } #[test] -fn permanently_refused_share_is_rejected_without_retry_or_buffer() -> TestResult { +fn permanently_refused_share_is_rejected_without_retry_or_buffer() { let mut miner = MinerFixture::start(0x22); miner.ledger.refuse_permanently(); let job = Arc::clone(&miner.job); @@ -1293,14 +1363,16 @@ fn permanently_refused_share_is_rejected_without_retry_or_buffer() -> TestResult "a refused share credits nothing" ); miner.server.shutdown(); - Ok(()) } #[test] -fn permanently_refused_buffer_never_wedges_the_drain() -> TestResult { +fn permanently_refused_buffer_never_wedges_the_drain() { // Buffer one share against a transient outage, then turn the ledger // permanently refusing: the drain must drop the poisoned head instead of - // stalling behind it, and later shares must still be credited. + // stalling behind it, and later shares must still be credited. The + // dropped head's deferred reply is read to clear the session gate + // before submitting again: one session never holds two outstanding + // completions. let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); let mut miner = MinerFixture::start_with(0x22, None, Some(ledger)); let job = Arc::clone(&miner.job); @@ -1327,16 +1399,37 @@ fn permanently_refused_buffer_never_wedges_the_drain() -> TestResult { miner.ledger.refuse_permanently(); miner.ledger.disarm(); miner.pool.drain_outbox(); - assert_eq!( - miner.pool.pending_ledger_writes(), + wait_for_pending( + &miner.pool, 0, - "the poisoned head drops instead of wedging the drain" + "the poisoned head drops instead of wedging the drain", ); assert!( miner.ledger.recorded_shares().is_empty(), "a refused share credits nothing" ); + // The dropped head's completion fired in the drain, so its reply waits + // on the next poll; read it to clear the session gate. Only its identity + // is pinned: the line carries the known drain-completion residual, which + // stays out of this lane's assertions. + miner + .client + .stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut drained = String::new(); + for _ in 0..8 { + drained = miner.client.read_line(); + if drained.contains("\"id\":101") { + break; + } + } + assert!( + drained.contains("\"id\":101"), + "the dropped head still answers its waiter: {drained:?}" + ); + miner.ledger.admit_again(); let response = miner.submit(102, &job, second); assert!( @@ -1349,11 +1442,10 @@ fn permanently_refused_buffer_never_wedges_the_drain() -> TestResult { "only the deliverable share is credited" ); miner.server.shutdown(); - Ok(()) } #[test] -fn sv1_wire_version_bits_normalize_to_the_job_version() -> TestResult { +fn sv1_wire_version_bits_normalize_to_the_job_version() { let mut miner = MinerFixture::start(0x22); let job = Arc::clone(&miner.job); let nonce = miner.share_nonce(0); @@ -1364,19 +1456,18 @@ fn sv1_wire_version_bits_normalize_to_the_job_version() -> TestResult { ); let shares = miner.ledger.recorded_shares(); assert_eq!(shares.len(), 1); + let expected = i32::try_from(miner.version) + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); assert_eq!( - shares[0].header.version, - i32::try_from(miner.version) - .unwrap_or_else(|error| { panic!("canonical job version fits i32: {error}") }), + shares[0].header.version, expected, "the edge normalizes rolled bits to the canonical job version" ); assert_eq!(shares[0].job, job.id()); miner.server.shutdown(); - Ok(()) } #[test] -fn unknown_wire_job_rejects_before_the_common_submit_owner() -> TestResult { +fn unknown_wire_job_rejects_before_the_common_submit_owner() { let mut miner = MinerFixture::start(0x22); miner.client.send(&format!( "{{\"id\":100,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"0000007b\",\"00000001\",\"{:08x}\",\"00000000\",\"00002000\"]}}\n", @@ -1392,5 +1483,4 @@ fn unknown_wire_job_rejects_before_the_common_submit_owner() -> TestResult { "an unknown job credits nothing" ); miner.server.shutdown(); - Ok(()) } From 092b55fd3702bedf9fbaa5e925f6c827a3be90e6 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:04 +0900 Subject: [PATCH 15/61] Extract SV1 submit failure mapping Delegate failure-to-wire mapping to one helper while preserving verdicts, notification suppression, and deferred completion behavior. --- crates/stratum/src/sv1/session.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index 04b98782aa..90680c5354 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -1661,8 +1661,23 @@ impl SessionState { } Verdict::Accepted } - Err(SubmitFailure::Share(error)) => { - let wire = share_error_wire(&error); + Err(failure) => Self::submit_failure_verdict(request, &failure, out), + } + } + + /// Maps a common-owner submit refusal onto the wire reply and verdict. + /// Reply suppression, verdicts, and deferred completion behavior are + /// unchanged: `OutboxFull` stays silent-side `Ignored` (the miner may + /// retry: nothing burned); every `Share` refusal and `Duplicate` answers + /// and counts. + fn submit_failure_verdict( + request: &Request, + failure: &SubmitFailure, + out: &mut Vec, + ) -> Verdict { + match failure { + SubmitFailure::Share(error) => { + let wire = share_error_wire(error); if matches!(error, ShareError::ExtranonceLength { .. }) { emit_reply(request, out, &encode_error(&request.id, &wire)); Verdict::Violation @@ -1671,7 +1686,7 @@ impl SessionState { Verdict::Rejected } } - Err(SubmitFailure::Duplicate) => { + SubmitFailure::Duplicate => { emit_reply( request, out, @@ -1679,7 +1694,7 @@ impl SessionState { ); Verdict::Rejected } - Err(SubmitFailure::OutboxFull) => { + SubmitFailure::OutboxFull => { // The share was rejected BEFORE validation; the hash was NOT burned. // The miner may retry. emit_reply( From e1895bbce168eac289b6dfc28408b3738b332821 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:39:56 +0900 Subject: [PATCH 16/61] Extract SV1 credited admission path Move credited delivery and acceptance handling into one helper while preserving inline and deferred responses. Keep failure mapping and version normalization unchanged. --- crates/stratum/src/sv1/session.rs | 102 +++++++++++++++--------------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/crates/stratum/src/sv1/session.rs b/crates/stratum/src/sv1/session.rs index 90680c5354..d07a38f196 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/stratum/src/sv1/session.rs @@ -1599,39 +1599,10 @@ impl SessionState { ); return Verdict::Ignored; } - // Normalize the wire version bits to the canonical header version - // before the common submit owner: rolled-bits interpretation lives in - // this SV1 edge (read through the single authoritative `JobBook`, - // which stays the sole owner of the share predicate), so - // `PoolCore::submit` and settlement below see only canonical - // submissions and hold no wire branching. - let version = match self.shared.pool.with_job_book(|book| { - let job_ref = book.job(job)?; - Some(match submit.version_bits { - Some(bits) => job_ref.version_from_rolled_bits(bits), - None => Ok(job_ref.version()), - }) - }) { - Some(Ok(version)) => version, - Some(Err(error)) => { - let wire = share_error_wire(&error); - if matches!(error, ShareError::ExtranonceLength { .. }) { - emit_reply(request, out, &encode_error(&request.id, &wire)); - return Verdict::Violation; - } - emit_reply(request, out, &encode_reject(&request.id, &wire)); - return Verdict::Rejected; - } - // The book rejects an unknown job before it reads the version; - // answer here with the same verdict the owner would produce. - None => { - emit_reply( - request, - out, - &encode_reject(&request.id, &errors::JOB_NOT_FOUND_ERROR), - ); - return Verdict::Rejected; - } + // Normalize the wire version bits before the common submit owner. + let version = match self.normalized_version(job, submit) { + Ok(version) => version, + Err(failure) => return Self::submit_failure_verdict(request, &failure, out), }; let target = self.effective_target(Instant::now()); let share_request = ShareRequest { @@ -1644,27 +1615,58 @@ impl SessionState { }; let session = share_request.session; match self.shared.pool.submit(&identity, &share_request, &target) { - Ok(credited) => { - if credited.completion.is_complete() { - let reply = request.has_id.then_some(&request.id); - self.emit_credited(reply, session, &credited, out); - } else { - self.deferred.push(DeferredResponse { - id: request.id.clone(), - has_id: request.has_id, - hash: credited.hash, - class: credited.class, - block: credited.block, - completion: credited.completion, - }); - self.poll_hint = true; - } - Verdict::Accepted - } + Ok(credited) => self.admit_credited(request, session, credited, out), Err(failure) => Self::submit_failure_verdict(request, &failure, out), } } + /// Normalizes the wire version bits to the canonical header version: + /// rolled-bits interpretation lives in this SV1 edge (read through the + /// single authoritative `JobBook`, which stays the sole owner of the + /// share predicate), so `PoolCore::submit` and settlement below see only + /// canonical submissions and hold no wire branching. An unknown job + /// answers with the same verdict the owner would produce. + fn normalized_version(&self, job: JobId, submit: &Submit) -> Result { + match self.shared.pool.with_job_book(|book| { + let job_ref = book.job(job)?; + Some(match submit.version_bits { + Some(bits) => job_ref.version_from_rolled_bits(bits), + None => Ok(job_ref.version()), + }) + }) { + Some(result) => result.map_err(SubmitFailure::Share), + None => Err(SubmitFailure::Share(ShareError::UnknownJob(job))), + } + } + + /// Records an accepted share: answers immediately when its accounting is + /// complete, otherwise withholds the reply on the session's single + /// outstanding completion. Always `Accepted`: the verdict counts the + /// submission, not its durability. + fn admit_credited( + &mut self, + request: &Request, + session: SessionId, + credited: Credited, + out: &mut Vec, + ) -> Verdict { + if credited.completion.is_complete() { + let reply = request.has_id.then_some(&request.id); + self.emit_credited(reply, session, &credited, out); + } else { + self.deferred.push(DeferredResponse { + id: request.id.clone(), + has_id: request.has_id, + hash: credited.hash, + class: credited.class, + block: credited.block, + completion: credited.completion, + }); + self.poll_hint = true; + } + Verdict::Accepted + } + /// Maps a common-owner submit refusal onto the wire reply and verdict. /// Reply suppression, verdicts, and deferred completion behavior are /// unchanged: `OutboxFull` stays silent-side `Ignored` (the miner may From 98765ef7105d7eb9f39a2dad5b720b96430b2716 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:41:19 +0900 Subject: [PATCH 17/61] Pass Stratum test prefixes by value Use Copy prefixes in the nonce helper and update call sites without changing the fixtures. --- crates/stratum/tests/protocol.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs index 0dfaadeef4..eb7741a342 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/stratum/tests/protocol.rs @@ -972,19 +972,19 @@ impl MinerFixture { /// share that never reaches node submission, so the job stays installed /// no matter how the ledger answers. fn share_nonce(&self, start: u32) -> u32 { - self.share_nonce_for(&self.prefix, start) + self.share_nonce_for(self.prefix, start) } /// [`Self::share_nonce`] under an explicit session prefix, for shares /// submitted on a second session. - fn share_nonce_for(&self, prefix: &[u8; 4], start: u32) -> u32 { + fn share_nonce_for(&self, prefix: [u8; 4], start: u32) -> u32 { (start..=u32::MAX) .find(|nonce| { let header = self .job .header( Extranonce { - session: prefix, + session: &prefix, rolled: &ROLLED, }, self.job.ntime(), @@ -1194,7 +1194,7 @@ fn per_submit_reservation_does_not_cap_the_pool_at_one_share() { let mut miner = MinerFixture::start_with(0x22, Some((1024, 8 * 1024 * 1024)), Some(ledger)); let first = miner.share_nonce(0); let (mut second_client, second_prefix) = miner.second_client("alice.worker2"); - let second = miner.share_nonce_for(&second_prefix, 0); + let second = miner.share_nonce_for(second_prefix, 0); miner.client.send(&format!( "{{\"id\":101,\"method\":\"mining.submit\",\"params\":[\"alice.worker1\",\"{:08x}\",\"00000001\",\"{:08x}\",\"{:08x}\",\"00002000\"]}}\n", miner.job.id().get(), From f64c5cc62217556f44ca536d4d262f5190df6abc Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:42:33 +0900 Subject: [PATCH 18/61] Keep SV1 notification test failures explicit Use contextual test failures instead of expect calls. --- crates/stratum/src/sv1.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs index 42e4e49d48..0b295b8835 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/stratum/src/sv1.rs @@ -1248,7 +1248,8 @@ mod notification_tests { #[test] fn calls_carry_has_id_and_notifications_do_not() { let call = br#"{"id":7,"method":"mining.subscribe","params":[]}"#; - let decoded = decode_line(call).expect("a call with an id decodes"); + let decoded = decode_line(call) + .unwrap_or_else(|error| panic!("a call with an id decodes: {error:?}")); match decoded { Incoming::Request(request) => { assert!(request.has_id); @@ -1258,7 +1259,8 @@ mod notification_tests { } let notification = br#"{"method":"mining.subscribe","params":[]}"#; - let decoded = decode_line(notification).expect("a notification decodes"); + let decoded = decode_line(notification) + .unwrap_or_else(|error| panic!("a notification decodes: {error:?}")); match decoded { Incoming::Request(request) => { assert!(!request.has_id); From 782b9f7b4338ef52df3dd27fc681e4c5e38df6bb Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:45:06 +0900 Subject: [PATCH 19/61] Resolve final pool and SV1 lint findings Use Debug formatting in SV1 tests, simplify tombstone checks, and express retired hash lookup with if/else. Behavior is unchanged. --- crates/pool-ledger/src/kv.rs | 2 +- crates/pool-ledger/src/store.rs | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs index 94730efb62..58307caa6e 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/pool-ledger/src/kv.rs @@ -380,7 +380,7 @@ impl Table<'_, K, V> { /// backend reverse cursor, which is outside this crate's table layer. pub(crate) fn last(&self) -> Result>, LedgerError> { let changes = self.changes.borrow(); - let deleted = |key: &[u8]| changes.get(key).is_some_and(|value| value.is_none()); + let deleted = |key: &[u8]| changes.get(key).is_some_and(Option::is_none); let mut last: Option<(Vec, Vec)> = None; for item in self.snapshot.iter_prefix(CF, &self.prefix)? { let (key, value) = item?; diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs index 89ef09bba4..6ba3a172f0 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/pool-ledger/src/store.rs @@ -540,15 +540,14 @@ impl Ledger { pub fn share_by_hash(&self, header_hash: &Hash32) -> Result, LedgerError> { let read = self.db.begin_read()?; let hashes = read.open_table(SHARE_HASHES)?; - let entry = match hashes.get(header_hash.as_slice())? { - Some(guard) => Some(decode_hash_entry(guard.value())?), - None => { - let retired = read.open_table(RETIRED_HASHES)?; - retired - .get(header_hash.as_slice())? - .map(|guard| decode_hash_entry(guard.value())) - .transpose()? - } + let entry = if let Some(guard) = hashes.get(header_hash.as_slice())? { + Some(decode_hash_entry(guard.value())?) + } else { + let retired = read.open_table(RETIRED_HASHES)?; + retired + .get(header_hash.as_slice())? + .map(|guard| decode_hash_entry(guard.value())) + .transpose()? }; Ok(entry.map(|(seq, work, _)| (seq, work))) } From 9fe05a0172cfd4161a04697aa44628b034439e72 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:29:43 +0900 Subject: [PATCH 20/61] Read pool ledger tails through reverse seeks Add greatest-prefix snapshot lookups with native backend implementations and fold pending overlay puts and deletes without scanning ordinary tail reads. Document pool clause proofs. --- crates/pool-ledger/src/kv.rs | 66 ++++++++- .../tests/support/kv_transactions.rs | 75 ++++++++++ crates/storage/src/fjall_impl.rs | 38 ++++++ crates/storage/src/redb_impl.rs | 128 ++++++++++++++++++ crates/storage/src/rocksdb_impl.rs | 30 +++- crates/storage/src/trait_.rs | 13 ++ docs/contracts/pool.md | 9 ++ 7 files changed, 351 insertions(+), 8 deletions(-) diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs index 58307caa6e..ae2e63f746 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/pool-ledger/src/kv.rs @@ -371,14 +371,66 @@ impl Table<'_, K, V> { } Ok(result.into_iter()) } - /// Greatest row in key order without materializing the table. The snapshot iterator is - /// lazy, so the single pass below retains one row no matter how large the table is; - /// the uncommitted overlay is already in memory and merges by direct key comparison. - /// Callers that only need the tail (for example the newest share) never materialize - /// the rows before it. Reaching the tail still walks the table because the storage - /// backend exposes forward iteration only; a sublinear reverse seek would need a - /// backend reverse cursor, which is outside this crate's table layer. + /// Greatest live row in key order without materializing the table. The snapshot reverse + /// seek is lazy, so a single lookup fetches the committed tail while the uncommitted + /// overlay contributes its greatest put by direct key comparison; tombstones decide + /// liveness and an overlay put shadows a committed row under the same key. Callers + /// that only need the tail (for example the newest share) never materialize the rows + /// before it. A reverse seek cannot see past a deleted committed tail, so the rare + /// case where the tail itself is staged for deletion falls back to an exact forward + /// merge that recovers the predecessor. pub(crate) fn last(&self) -> Result>, LedgerError> { + let backend = self.snapshot.last_prefix(CF, &self.prefix)?; + let changes = self.changes.borrow(); + let tombstoned = |key: &[u8]| changes.get(key).is_some_and(Option::is_none); + if backend.as_ref().is_some_and(|(key, _)| tombstoned(key)) { + drop(changes); + return self.last_scanned(); + } + // Greatest overlay put in this table. Table prefixes are `PREFIX + [tag]`, so the + // trailing tag plus one bounds the scan to this table's keys: the seek is + // logarithmic and never walks later tables. Tombstones delete without + // contributing a row, so only puts can win. + let mut end = self.prefix.clone(); + match end.last_mut() { + Some(tag) => { + *tag = tag.checked_add(1).ok_or(LedgerError::Invariant( + "ledger table prefix overflow".into(), + ))?; + } + None => { + return Err(LedgerError::Invariant("empty ledger table prefix".into())); + } + } + let mut overlay: Option<(&[u8], &[u8])> = None; + for (key, value) in changes.range(self.prefix.clone()..end).rev() { + if let Some(put) = value { + overlay = Some((key.as_slice(), put.as_slice())); + break; + } + } + let tail: Option<(Vec, Vec)> = match (backend, overlay) { + (backend, None) => backend, + (None, Some((key, value))) => Some((key.to_vec(), value.to_vec())), + (Some((committed, stored)), Some((staged, put))) => { + if staged < committed.as_slice() { + Some((committed, stored)) + } else { + // Equal keys shadow: the overlay put replaces the committed row. + Some((staged.to_vec(), put.to_vec())) + } + } + }; + drop(changes); + tail.map(|(key, value)| { + let logical = key[self.prefix.len()..].to_vec(); + Ok((Guard(K::decode(logical)?), Guard(V::decode(value)?))) + }) + .transpose() + } + /// Exact greatest live row via a forward merge. Only used when the committed tail is + /// staged for deletion, which a single reverse seek cannot see past. + fn last_scanned(&self) -> Result>, LedgerError> { let changes = self.changes.borrow(); let deleted = |key: &[u8]| changes.get(key).is_some_and(Option::is_none); let mut last: Option<(Vec, Vec)> = None; diff --git a/crates/pool-ledger/tests/support/kv_transactions.rs b/crates/pool-ledger/tests/support/kv_transactions.rs index 5f7ab4e793..665c52b3ff 100644 --- a/crates/pool-ledger/tests/support/kv_transactions.rs +++ b/crates/pool-ledger/tests/support/kv_transactions.rs @@ -69,3 +69,78 @@ fn fjall_snapshot_and_cross_handle_conditional_commit() -> Result<(), Box = Arc::new(FjallStore::open(dir.path().join("store"))?); exercise(&store) } +fn tail_cases(store: &Arc) -> Result<(), Box> { + let missing = || std::io::Error::other("missing tail row"); + let db = Database::new(Arc::clone(store)); + let setup = db.begin_write()?; + let shares = setup.open_table(SHARES)?; + shares.insert(1u64, b"one".as_slice())?; + shares.insert(2u64, b"two".as_slice())?; + shares.insert(3u64, b"three".as_slice())?; + drop(shares); + setup.commit()?; + // Committed tail with no overlay. + let read = db.begin_read()?; + let (key, value) = read.open_table(SHARES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 3u64); + assert_eq!(value.value(), b"three"); + drop(read); + // Overlay put beyond the committed tail wins. + let staged = db.begin_write()?; + staged + .open_table(SHARES)? + .insert(5u64, b"five".as_slice())?; + let (key, value) = staged.open_table(SHARES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 5u64); + assert_eq!(value.value(), b"five"); + drop(staged); + // Overlay update of the tail key shadows the committed row. + let staged = db.begin_write()?; + staged + .open_table(SHARES)? + .insert(3u64, b"three-new".as_slice())?; + let (key, value) = staged.open_table(SHARES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 3u64); + assert_eq!(value.value(), b"three-new"); + drop(staged); + // Overlay delete of the tail falls back to the predecessor. + let staged = db.begin_write()?; + staged.open_table(SHARES)?.remove(3u64)?; + let (key, value) = staged.open_table(SHARES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 2u64); + assert_eq!(value.value(), b"two"); + drop(staged); + // Overlay tombstone beyond the tail deletes nothing. + let staged = db.begin_write()?; + staged.open_table(SHARES)?.remove(7u64)?; + let (key, value) = staged.open_table(SHARES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 3u64); + assert_eq!(value.value(), b"three"); + drop(staged); + // Empty table reports no tail; overlay-only rows still surface. + let read = db.begin_read()?; + assert!(read.open_table(BALANCES)?.last()?.is_none()); + drop(read); + let staged = db.begin_write()?; + staged + .open_table(BALANCES)? + .insert(9u64, b"nine".as_slice())?; + let (key, _) = staged.open_table(BALANCES)?.last()?.ok_or_else(missing)?; + assert_eq!(key.value(), 9u64); + drop(staged); + Ok(()) +} + +#[test] +fn redb_last_merges_overlay_tail() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store: Arc = Arc::new(RedbStore::open(dir.path().join("store.redb"))?); + tail_cases(&store) +} + +#[test] +fn fjall_last_merges_overlay_tail() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let store: Arc = Arc::new(FjallStore::open(dir.path().join("store"))?); + tail_cases(&store) +} diff --git a/crates/storage/src/fjall_impl.rs b/crates/storage/src/fjall_impl.rs index 5cb7a06bc0..8c2544347a 100644 --- a/crates/storage/src/fjall_impl.rs +++ b/crates/storage/src/fjall_impl.rs @@ -360,6 +360,30 @@ impl KvSnapshot for FjallSnapshot<'_> { Ok(Box::new(iterator)) } + fn last_prefix( + &self, + cf: ColumnFamily, + prefix: &[u8], + ) -> Result, StorageError> { + let keyspace = self.store.keyspace(cf)?; + // The bounded range holds exactly the prefixed rows, and an unbounded + // range past an all-`0xFF` (or empty) prefix can only hold prefixed + // rows, so the range tail needs no prefix check. + let mut range = match prefix_end(prefix) { + Some(end) => self.snapshot.range(keyspace, prefix.to_vec()..end), + None => self.snapshot.range(keyspace, prefix.to_vec()..), + }; + range + .next_back() + .map(|guard| { + guard + .into_inner() + .map(|(key, value)| (key.to_vec(), value.to_vec())) + .map_err(StorageError::backend) + }) + .transpose() + } + fn scan_prefix_from_bounded( &self, cf: ColumnFamily, @@ -374,3 +398,17 @@ impl KvSnapshot for FjallSnapshot<'_> { crate::trait_::collect_bounded_prefix(rows, prefix, limit) } } + +/// Exclusive upper bound of a prefix range, if the prefix is not all `0xFF`. +fn prefix_end(prefix: &[u8]) -> Option> { + let mut end = prefix.to_vec(); + while let Some(byte) = end.last_mut() { + if *byte == u8::MAX { + end.pop(); + } else { + *byte = byte.saturating_add(1); + return Some(end); + } + } + None +} diff --git a/crates/storage/src/redb_impl.rs b/crates/storage/src/redb_impl.rs index dcc6ff729b..e4f85c1a7c 100644 --- a/crates/storage/src/redb_impl.rs +++ b/crates/storage/src/redb_impl.rs @@ -740,6 +740,14 @@ impl KvSnapshot for RedbSnapshot { Ok(Box::new(rows.into_iter().map(Ok))) } + fn last_prefix( + &self, + cf: ColumnFamily, + prefix: &[u8], + ) -> Result, StorageError> { + greatest_prefix(&self.read_txn, table_for(cf), prefix) + } + fn scan_prefix_bounded( &self, cf: ColumnFamily, @@ -798,6 +806,14 @@ impl KvSnapshot for RedbTxIndexSnapshot { Ok(Box::new(rows.into_iter().map(Ok))) } + fn last_prefix( + &self, + cf: ColumnFamily, + prefix: &[u8], + ) -> Result, StorageError> { + txindex_greatest_prefix(&self.read_txn, cf, prefix) + } + fn scan_prefix_bounded( &self, cf: ColumnFamily, @@ -887,6 +903,36 @@ fn scan_prefix( }) } +/// Returns the greatest row with the given prefix via one reverse cursor step. +/// +/// The bounded range holds exactly the prefixed rows, and an unbounded range +/// past an all-`0xFF` (or empty) prefix can only hold prefixed rows, so the +/// range tail needs no prefix check. +fn greatest_prefix( + read_txn: &redb::ReadTransaction, + table_def: ByteTable, + prefix: &[u8], +) -> Result, StorageError> { + let table = read_txn + .open_table(table_def) + .map_err(StorageError::backend)?; + let tail = match prefix_end(prefix) { + Some(end) => table + .range(prefix..end.as_slice()) + .map_err(StorageError::backend)? + .next_back(), + None => table + .range(prefix..) + .map_err(StorageError::backend)? + .next_back(), + }; + tail.map(|entry| { + let (key, value) = entry.map_err(StorageError::backend)?; + Ok((key.value().to_vec(), value.value().to_vec())) + }) + .transpose() +} + fn database_path(path: &Path) -> Result { if path.extension().is_some() { if let Some(parent) = path.parent() { @@ -1199,6 +1245,88 @@ fn scan_txindex_prefix( } } +/// Returns the greatest fixed-width row with the given prefix via one reverse +/// cursor step. Every key inside the width-padded bounds carries the prefix. +fn fixed_greatest_prefix( + read_txn: &ReadTransaction, + table_def: FixedTable, + prefix: &[u8], +) -> Result, StorageError> { + let (start, end) = fixed_prefix_bounds::(prefix)?; + let table = read_txn + .open_table(table_def) + .map_err(StorageError::backend)?; + table + .range::<&[u8; N]>(&start..=&end) + .map_err(StorageError::backend)? + .next_back() + .map(|entry| { + let (key, _) = entry.map_err(StorageError::backend)?; + Ok((key.value().to_vec(), Vec::new())) + }) + .transpose() +} + +/// Returns the greatest fixed-width value row with the given prefix via one +/// reverse cursor step on the membership table plus one point lookup. +fn fixed_value_greatest_prefix( + read_txn: &ReadTransaction, + main_def: FixedTable<12>, + value_def: TxIndexValueTable, + prefix: &[u8], +) -> Result, StorageError> { + let (start, end) = fixed_prefix_bounds::<12>(prefix)?; + let main = read_txn + .open_table(main_def) + .map_err(StorageError::backend)?; + let tail = main + .range::<&[u8; 12]>(&start..=&end) + .map_err(StorageError::backend)? + .next_back() + .transpose() + .map_err(StorageError::backend)?; + let Some((key, _)) = tail else { + return Ok(None); + }; + let values = read_txn + .open_table(value_def) + .map_err(StorageError::backend)?; + let value = values + .get(key.value()) + .map_err(StorageError::backend)? + .map_or_else(Vec::new, |bytes| bytes.value().to_vec()); + Ok(Some((key.value().to_vec(), value))) +} + +fn txindex_greatest_prefix( + read_txn: &ReadTransaction, + cf: ColumnFamily, + prefix: &[u8], +) -> Result, StorageError> { + match cf { + ColumnFamily::TxConfirmed => fixed_value_greatest_prefix( + read_txn, + TXINDEX_TX_CONFIRMED, + TXINDEX_TX_CONFIRMED_VALUES, + prefix, + ), + ColumnFamily::Funding => { + fixed_value_greatest_prefix(read_txn, TXINDEX_FUNDING, TXINDEX_FUNDING_VALUES, prefix) + } + ColumnFamily::Spending => { + fixed_value_greatest_prefix(read_txn, TXINDEX_SPENDING, TXINDEX_SPENDING_VALUES, prefix) + } + ColumnFamily::BlockHeaders => { + fixed_greatest_prefix::<80>(read_txn, TXINDEX_BLOCK_HEADERS, prefix) + } + ColumnFamily::ScriptLive => { + fixed_greatest_prefix::(read_txn, TXINDEX_SCRIPT_LIVE, prefix) + } + ColumnFamily::UtxoMeta => greatest_prefix(read_txn, TXINDEX_META, prefix), + _ => Err(invalid_txindex_cf()), + } +} + fn apply_fixed_op( table: &mut redb::Table<'_, &'static [u8; N], ()>, op: BatchOp, diff --git a/crates/storage/src/rocksdb_impl.rs b/crates/storage/src/rocksdb_impl.rs index 5bf0902373..a5445797b8 100644 --- a/crates/storage/src/rocksdb_impl.rs +++ b/crates/storage/src/rocksdb_impl.rs @@ -3,7 +3,7 @@ use std::path::Path; use rust_rocksdb::{ BlockBasedOptions, Cache, ColumnFamilyDescriptor, DBCompressionType, Direction, IteratorMode, - Options, ReadOptions, WriteBatch as RocksWriteBatch, WriteOptions, + Options, PrefixRange, ReadOptions, WriteBatch as RocksWriteBatch, WriteOptions, }; use crate::{ColumnFamily, KvSnapshot, KvStore, StorageError, WriteCondition}; @@ -386,6 +386,34 @@ impl KvSnapshot for RocksDbSnapshot<'_> { )) } + fn last_prefix( + &self, + cf: ColumnFamily, + prefix: &[u8], + ) -> Result, StorageError> { + let handle = self.db.cf_handle(cf)?; + let prefix = prefix.to_vec(); + // The iterate range holds exactly the prefixed rows (`PrefixRange` + // maps empty and all-`0xFF` prefixes to unbounded sides), so one + // `seek_to_last` lands directly on the tail. The prefix check below + // only guards against bound-enforcement subtleties. + let mut read_opts = ReadOptions::default(); + read_opts.set_iterate_range(PrefixRange(prefix.as_slice())); + let mut iterator = self + .snapshot + .iterator_cf_opt(handle, read_opts, IteratorMode::End); + for item in &mut iterator { + let (key, value) = item.map_err(StorageError::backend)?; + let key_slice: &[u8] = &key; + let prefix_slice: &[u8] = &prefix; + if key_slice.starts_with(prefix_slice) { + return Ok(Some((key.to_vec(), value.to_vec()))); + } + return Ok(None); + } + Ok(None) + } + fn scan_prefix_from_bounded( &self, cf: ColumnFamily, diff --git a/crates/storage/src/trait_.rs b/crates/storage/src/trait_.rs index 08d0bbd447..760f2b13b6 100644 --- a/crates/storage/src/trait_.rs +++ b/crates/storage/src/trait_.rs @@ -343,6 +343,19 @@ pub trait KvSnapshot: Send + Sync { prefix: &[u8], ) -> Result, StorageError>; + /// Returns the greatest snapshot row with the given prefix, if any. + /// + /// The default walks the forward iterator retaining one row and keeps no + /// result collection of its own. Backends override it with a native + /// reverse seek that reads a single row. + fn last_prefix(&self, cf: ColumnFamily, prefix: &[u8]) -> Result, StorageError> { + let mut last = None; + for item in self.iter_prefix(cf, prefix)? { + last = Some(item?); + } + Ok(last) + } + /// Collects matching snapshot rows within `limit`. fn scan_prefix_bounded( &self, diff --git a/docs/contracts/pool.md b/docs/contracts/pool.md index bf64891694..4e6f939183 100644 --- a/docs/contracts/pool.md +++ b/docs/contracts/pool.md @@ -28,6 +28,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - SV2 bind and authority-file path MUST be configured together. Pool sub-options MUST NOT be silently ignored when the runtime is disabled. - The node MUST reject maturity below 100, zero payout confirmations, fees above 10,000 basis points, and a reorg watch shorter than maturity, payout confirmation depth, or the fixed six-confirmation abandon depth. - A reward script MUST be nonempty, bounded to 10,000 bytes, minimally encoded, and not begin with `OP_RETURN`. Passing these checks MUST NOT be represented as proof of spendability or control by the operator. +- Proof: `crates/node/tests/unit/config/tests.rs::pool_defaults_off_and_suboptions_require_enablement` proves the service resolves to no pool by default and rejects pool sub-options when the runtime is disabled. `::pool_requires_explicit_policy_and_listener` proves a nonempty datadir, an explicit listener, a share difficulty, a positive work window, a reward script, and a node mining payout address are each required. `::pool_non_loopback_requires_explicit_opt_in` proves a non-loopback bind requires explicit opt-in and that port zero is rejected. `::pool_money_depth_and_capacity_bounds_are_validated` proves rejection of maturity below 100, zero payout confirmations, fees above 10,000 basis points, a shallow reorg watch, non-positive or inverted connection caps, and `OP_RETURN` or non-minimally-encoded reward scripts. `::pool_exact_difficulty_and_script_parsing_reject_invalid_inputs` proves plain-decimal difficulty and minimally-encoded nonempty script parsing. `::pool_sv2_requires_feature_and_paired_authority_path_without_opening_it` proves the SV2 bind and authority path must be paired, SV1 and SV2 binds must differ, and resolution never opens the key file. `crates/pool-ledger/tests/support/ledger_cases.rs::config_validation_rejects_every_unsafe_value` proves ledger-side rejection of zero window work, excess fees, zero maturity, a shallow watch depth, zero dust, an `OP_RETURN` pool script, and empty change scripts. ### `POOL-02`: Schema consent, storage, and immutable policy @@ -37,6 +38,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The service MUST lock the dedicated pool directory. It MUST create a ledger only when absent and reopen an existing ledger with the persisted immutable policy checked for equality. - Accounting policy changes MUST NOT silently reinterpret historical shares or funds. A configuration mismatch MUST fail rather than delete or recreate the ledger. - Ledger creation/opening and run-epoch allocation MUST precede listener admission. A run epoch MUST distinguish restarted protocol job identities in durable share accounting. +- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::create_refuses_existing_paths_and_open_demands_the_same_config` proves the ledger is created only when absent and reopening with a different window, fee, or any persisted immutable policy fails with `ConfigMismatch` instead of deleting or recreating. `::open_refuses_a_foreign_store_without_writing_to_it` proves opening a store holding foreign content fails with `NotALedger` while leaving the foreign bytes intact and writing no `pool-ledger/v1/` keys. The `redb` and `fjall` modules in `crates/pool-ledger/tests/ledger.rs` run the whole suite through `Arc` against both real node storage engines, which exercises the storage-owned boundary. `::runtime_epochs_are_durable_and_shared_between_ledger_handles` proves run-epoch allocation is monotonic across handles and restarts, while `crates/node/src/pool/tests.rs` (`redb`/`fjall::restart_epoch_prevents_reused_wire_job_identity_collision`) proves the new epoch separates a reused wire job identity after restart and `redb`/`fjall::accepted_share_has_exact_durable_identity_and_idempotent_replay` proves the exact durable share identity survives reopen. Gap: no executable test asserts that `PoolService::prepare` holds the dedicated pool-directory lock or that creation/opening plus epoch allocation precede listener admission; the lock primitive itself is covered only by the generic `crates/node/tests/unit/state/tests/data_dir_lock.rs` suite, not by pool wiring. ### `POOL-03`: Shared admission and bounded deferred work @@ -45,6 +47,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The node-composed deferred share-ledger outbox MUST be bounded by both 1024 records and 8 MiB. Exhaustion MUST reject work before validation. SV1 MUST withhold success for queued writes until their accounting completion is recorded. - Found-block processing MUST reserve capacity before SV2 protocol feed/validation can emit work requiring a record. Found-record reservations and recovery records MUST share the eight-slot budget, with a 4,000,000-byte charge per slot. Failed writes MUST retain the compact accounting projection rather than serialized full blocks. - Per-connection protocol limits MUST remain in force in addition to aggregate admission. None of these individual bounds is a claim of a total-process memory bound. +- Proof: `crates/stratum/tests/protocol.rs::shared_connection_limiter_enforces_total_and_per_ip_caps` proves total and per-IP caps with release on drop, and `crates/node/src/pool/tests.rs` (`protocol_admissions_share_total_capacity_and_release_it_on_drop`, `protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots`) proves SV1 and SV2 share one limiter and refused admissions consume no slot. `crates/stratum/tests/protocol.rs::connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions` proves excess peers are refused and shutdown joins served sessions. `crates/stratum/tests/jobs.rs::retention_is_bounded_and_retired_jobs_are_stale` proves bounded job retention with stale rejection of evicted jobs; `::sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first` and `::unusable_configurations_are_refused` prove bounded owner sessions and refusal of zero capacities. `::rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block` and `::block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay` prove rejected input does not fill the per-job duplicate set while a block accepted at capacity is still remembered. `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails`, `::configured_minima_cannot_fail_an_empty_outbox`, and `::per_submit_reservation_does_not_cap_the_pool_at_one_share` prove the bounded deferred outbox withholds success until the durable record and admits submits on an empty outbox without capping the pool at one share; `block_record_capacity_is_bounded_and_permit_drop_releases_it` (node pool tests, both backends) proves the eight found-record slots with release on drop. `crates/stratum-v2/tests/protocol.rs::unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded` proves the per-connection pending-ack bound with a `pool-busy` refusal that records nothing. Gap: no test pins the literal composed defaults (128 total / 8 per IP, 64 jobs, sessions at twice the connection cap, 65,536 entries per job, 1024 records / 8 MiB); the tests prove the enforcement mechanisms with small capacities. ### `POOL-04`: Authority-key handling @@ -54,6 +57,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Secure authority-file loading MUST fail closed where this implementation has no supported loader; the non-Unix path reports unsupported operation. - The file input buffer, decoded secret buffer, and stored authority secret MUST use zeroizing ownership. Debug formatting MUST redact the stored secret, and parse errors MUST NOT include key input. This requirement does not certify zeroization of cryptographic dependency internals, kernel buffers, or every transient machine-level copy. - Operators SHOULD protect ancestor directories. `NOFOLLOW` on the opened path MUST NOT be described as an ancestor-directory trust guarantee. +- Proof: `crates/stratum-v2/tests/protocol.rs::noise_handshake_authenticates_the_pool_to_a_pinned_miner` proves the authority authenticates the Noise transport to a miner pinning its public key; `::a_miner_pinning_another_authority_rejects_the_server_certificate` proves a certificate from another authority is rejected; `::a_mismatched_authority_key_pair_is_refused_at_accept` proves a non-matching public/secret pair is refused at accept. Gap: no executable test covers `crates/node/src/pool/authority.rs::load` or `open_private_file` (the regular-file/`0600` descriptor check, the `RDONLY`/`CLOEXEC`/`NOFOLLOW`/`NONBLOCK` open flags, the exactly-two-64-hex-field and 256-byte limits, the non-Unix fail-closed path, the zeroizing file/secret buffers, the redacted `Debug`, or the input-free parse errors), so the file-format, permission, and redaction claims have no local proof. ### `POOL-05`: One mining owner and acknowledgement boundaries @@ -66,6 +70,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Each SV1 session MUST retain at most one pending accounting response. Additional submissions while it is pending MUST be rejected before validation with a retryable accounting error. Polling MUST send the deferred success only after accounting completion. Process failure MAY lose queued work before completion, but such work MUST NOT already have received success. - A found-block accounting record MUST initially be pending and deduplicated by its block/share identity. Retrying a record MUST NOT allocate the same reward twice. - New job parents, clean-job flags, and submission receipts MUST NOT be used as reorg or maturity evidence. +- Proof: `crates/stratum/tests/jobs.rs::found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts` proves found blocks go through the shared `MiningControl` submit path with Applied/Rejected/Undetermined verdict mapping. `crates/stratum/tests/protocol.rs::applied_block_retires_job_and_second_submit_is_stale` proves synchronous retirement on `Applied` with a second same-job nonce rejected as stale (SV1 21) and never resubmitted. `crates/node/src/pool/sv2.rs::applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node` proves the native SV2 path retires the common job before a sibling reaches the node. `crates/stratum-v2/tests/protocol.rs::stale_sibling_receipt_sends_stale_error_without_closing` proves an `inconclusive-not-best-prevblk` sibling still completes its record and is answered `stale-share` without closing; `::accepted_block_retirement_makes_second_same_job_submit_stale` proves post-retirement submits are stale while `::duplicate_share_is_refused_with_the_official_code` and `::stale_jobs_unknown_jobs_and_closed_channels_use_official_codes` prove byte-identical replays keep their `duplicate-share` identity. `::accepted_share_is_held_until_the_ledger_confirms_then_acknowledged` proves SV2 success waits for host confirmation of the durable outcome, and `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails` with `::crash_restored_share_still_delivers_its_block_record`, `::permanently_refused_share_is_rejected_without_retry_or_buffer`, and `::unknown_wire_job_rejects_before_the_common_submit_owner` prove SV1 deferred success, duplicate preservation across restart, no retry or buffering of permanent refusals, and rejection before the common owner for unknown jobs. `rejected_block_receipt_persists_pending_round_without_funds` and `failed_block_submission_persists_pending_round_without_funds` (node pool tests, both backends) prove rejected/failed receipts leave the round pending with no funds. Gap: no test asserts the at-most-one-pending-response SV1 session gate (retryable rejection of a second submit while one is pending, polling delivery of the deferred success) or that new parents, clean-job flags, and receipts are never consumed as reorg or maturity evidence. ### `POOL-06`: PPLNS reward and maturity @@ -74,6 +79,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Gross pool reward MUST be the sum of observed coinbase outputs with the configured pool script. The fee MUST be computed in integer basis points, rounded down. Net allocations MUST conserve that reward after the fee. - Available balances MUST be credited only after the configured maturity, using confirmations `tip_height - block_height + 1`. Node configuration MUST keep that threshold at or above 100. - A registered payout script MUST be required for miner admission. Decimal account IDs MAY include one nonempty worker suffix. Login syntax MUST NOT be represented as cryptographic proof of payout-script ownership. +- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::the_window_slides_across_blocks_and_snapshots_are_frozen` proves work-weighted windows snapshot at the solving share and later shares never change a snapshot; `::window_boundaries_are_exact_in_work_units` and `::short_history_uses_all_work_and_pruning_protects_old_windows` prove exact work-unit boundaries with pruning that protects old windows. `::a_found_block_is_recorded_once_and_pays_nothing_until_the_chain_shows_it` proves idempotent found-block recording with no payment before chain evidence and rejection of conflicting height or value claims. `::rounds_mature_only_after_the_configured_confirmations_on_the_active_chain` proves crediting only after the configured confirmations on the active chain. `::the_pool_fee_comes_from_the_observed_reward_and_allocations_sum_to_net` proves the gross equals the observed reward, the fee is floor-rounded, and allocations sum to net. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` proves contradictory coinbase claims surface anomalies and are never applied; `::a_coinbase_that_pays_miners_directly_matures_with_a_zero_pool_reward` proves a coinbase with no pool-script output matures with zero pool reward. `only_explicitly_registered_accounts_can_authenticate_or_credit_shares` (node pool tests, both backends) proves shares credit only for explicitly registered accounts with `42.rig` accepted, bare `42` defaulting its worker, and `43`, `address.rig`, empty, or empty-suffix logins refused; `::payout_scripts_are_validated_and_batches_skip_accounts_without_one` proves payout-script validation. ### `POOL-07`: Hash-keyed reorg reconciliation and conservation @@ -83,6 +89,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Future credits MUST offset deficits before increasing available funds. Per account, `available + reserved + paid - deficit` MUST equal credited matured-round allocations. - Unknown membership MUST NOT be treated as proof of canonicality or displacement. Reorg monitoring is bounded: rounds and confirmed batches leave monitoring according to the configured watch horizon. This contract MUST NOT be advertised as unlimited deep-reorg recovery. - Payout confirmation reversal MUST preserve the batch's financial reservation/accounting transitions. A transaction disappearing from a mempool MUST NOT prove that its signature is unusable or that its inputs cannot later be spent. +- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::equal_height_fork_reverses_matured_credits` and `::lower_height_fork_reverses_matured_credits` prove an equal-height and a lower-height replacement tip both orphan a matured round and reverse its credits with conservation held. `::a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive` and `::a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns` prove pre-maturity orphaning with nothing to reverse plus revival, and post-maturity reversal with re-credit on return. `::reversing_credits_that_were_already_reserved_creates_an_explicit_deficit` proves reversal consumes available funds first and the remainder becomes an explicit deficit that later re-credit clears before increasing available funds, keeping `available + reserved + paid - deficit` balanced via `check_invariants`. `::unseen_rounds_stop_being_monitored_after_the_watch_depth` proves bounded monitoring leaves rounds unwatched past the horizon. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` and `::reconciliation_is_idempotent_and_writes_no_audit_noise` prove contradictory claims never apply and unknown membership changes nothing idempotently. `::a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it` and `::a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent` prove payout confirmation reversal preserves reservations and transitions, with failure only on conclusively double-spent inputs. Gap: no test exercises the node `NodeChainView` adapter's stable-observation path (canonical-body checks of transaction/spend index hints) or the missing-index observer errors; `::an_unavailable_observer_changes_nothing` proves only the generic unavailable-observer case, not the txindex/script-index-specific errors. ### `POOL-08`: External signing without wallet custody @@ -94,6 +101,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Receipt validation MUST NOT be described as signature or input-ownership verification. `recordpayoutreceipt` MUST NOT broadcast. Chain observation of the recomputed txid supplies node-acceptance evidence; payment confirmation additionally requires the configured depth. - Signing failure, timeout, or receipt rejection MUST NOT release a reservation implicitly. Cancellation MUST require a prepared batch and explicit `never_handed_to_signer` or `signer_revoked` basis. The operator MUST establish that external basis; the RPC cannot prove signer revocation. - Lack of a transaction index prevents confirmed payout observation outside the mempool; lack of a script index prevents confirmed-spend observation. Such observer errors MUST NOT be converted into evidence of nonpayment or safe abandonment. +- Proof: `crates/rpc/src/handlers/pool.rs::disabled_pool_never_returns_fabricated_success` proves every pool handler is method-disabled without a ledger and returns no fabricated success. `::enabled_handlers_use_the_real_durable_ledger` proves key-free handlers over the real durable ledger with idempotency-key, minimum-payout, and output-cap policy enforcement. `::funded_external_payout_round_trip_preserves_reservations_on_rejection` proves the funded prepare/request/sign/broadcast walk with the reservation preserved across a txid-mismatch rejection and no cancellation after signing. `crates/pool-ledger/tests/support/ledger_cases.rs::preparing_a_batch_reserves_balances_atomically_and_never_double_allocates` proves atomic reservation with idempotency-key replay returning the immutable batch; `::payout_policy_is_bounded_by_dust_and_output_limits` proves the 546-sat dust floor and output-cap bound. `::payout_request_and_receipt_round_trip_and_reject_corruption` proves the strict `BRPR`/`BRPT` version 1 codecs with big-endian metadata, exact outputs, and rejection of bad magic, trailing bytes, and truncation; `::config_encode_validates_change_scripts_before_encoding` proves change-script allowlist validation. `::a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid` proves exact-output/txid matching with reservations preserved on every refusal (wrong txid, null or duplicate inputs, unparseable receipts, unknown batches, excess change outputs); `::replacements_must_conflict_with_every_earlier_signed_transaction` proves replacements must conflict rather than double-pay; `::cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing` proves cancellation requires a prepared batch with an explicit `never_handed_to_signer`/`signer_revoked` basis and is refused after signing; `::the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing` proves signer failures change nothing. `::a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting` proves broadcast requires mempool observation and confirmation requires the configured depth. Gap: no cited test exercises RPC-layer authentication dispatch for the pool methods; the handler tests call handlers directly with a constructed `Context`, so the authenticated-management-only gate itself has no local proof. ### `POOL-09`: Lifecycle and protocol scope @@ -101,6 +109,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Reconciliation MUST refresh work and observe rounds/payouts without holding shutdown hostage to a future mining generation. Shutdown MUST stop admission, cancel and join protocol workers, and drain bounded deferred accounting work within the node's shutdown deadline. A missed deadline or worker panic MUST remain an error, not a success report. - SV2 support is limited to the role matrix in `crates/stratum-v2/src/lib.rs`: Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling, including the adapter's reconnect API. - Custom jobs/work selection, Job Declaration, a Template Distribution server, extranonce-prefix changes, and extensions negotiation MUST NOT be advertised as implemented. The in-process template source MUST NOT be confused with a Template Distribution server. +- Proof: `crates/stratum-v2/tests/protocol.rs::the_role_matrix_does_not_claim_what_is_not_built` proves the role matrix marks standard/extended/group channels and `Noise_NX` implemented while Job Declaration, work selection, Template Distribution, and extensions negotiation are `NotImplemented` with documented detail. `::job_declaration_and_template_distribution_are_not_served` proves both protocols are refused with `unsupported-protocol` and the connection closes; `::custom_jobs_are_answered_with_the_official_code_not_pretended` proves custom-job requests are answered `jd-not-supported`. `crates/node/tests/unit/lifecycle/tests.rs::final_payout_script_prefers_pool_when_values_differ` proves one payout-script owner shared by pool and template paths with the pool reward winning while configured. Gap: no executable test covers `PoolService::prepare`/`start`/`shutdown`: ledger-before-RPC construction, listeners starting only after RPC bind plus an initial template, reconciliation that never holds shutdown hostage to a future mining generation, admission stop with worker cancel/join, bounded outbox/block-record draining within the deadline, or deadline-miss/panic surfacing as errors. ## Evidence boundary and source map From 4b44c182b4b253f35d2b7af5330ba60aba58d5c2 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:32:11 +0900 Subject: [PATCH 21/61] Construct pool prefix overflow errors lazily Use ok_or_else for the table-prefix overflow invariant without changing its condition or message. --- crates/pool-ledger/src/kv.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/pool-ledger/src/kv.rs b/crates/pool-ledger/src/kv.rs index ae2e63f746..25b8d17238 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/pool-ledger/src/kv.rs @@ -394,9 +394,9 @@ impl Table<'_, K, V> { let mut end = self.prefix.clone(); match end.last_mut() { Some(tag) => { - *tag = tag.checked_add(1).ok_or(LedgerError::Invariant( - "ledger table prefix overflow".into(), - ))?; + *tag = tag + .checked_add(1) + .ok_or_else(|| LedgerError::Invariant("ledger table prefix overflow".into()))?; } None => { return Err(LedgerError::Invariant("empty ledger table prefix".into())); From 9da9e03e230e620d447e93c2e784d894431f3b45 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:59:34 +0900 Subject: [PATCH 22/61] Align mining payload test controls Keep the verified BlockPayload submission path, SV2 stale-parent fake, and recorded payload assertions after moving the migration into the first commit. --- crates/stratum/src/job.rs | 9 ++++++++- crates/stratum/tests/jobs.rs | 11 ++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/stratum/src/job.rs b/crates/stratum/src/job.rs index 318ca1e78e..9d8f4177c2 100644 --- a/crates/stratum/src/job.rs +++ b/crates/stratum/src/job.rs @@ -629,8 +629,15 @@ impl FoundBlock { /// or an [`BlockStatus::Undetermined`] receipt means the chain outcome is /// unknown: the caller must not infer acceptance or rejection. pub fn submit(&self, control: &dyn MiningControl) -> Result { + // The job retains the exact wire bytes built at share time: decode + // them once through the shared payload path instead of re-encoding + // the block, so the node receives one exact payload with no second + // owner and no second decode. A decode failure means our own encoder + // just produced bytes the decoder rejects, so it surfaces as an + // operational submit failure, never as a share verdict: validation + // outcomes are unchanged, and `&self` keeps the block retryable. let payload = BlockPayload::from_raw(self.raw.clone().into()) - .map_err(|error| MiningControlError::Failed(CompactString::from(error.to_string())))?; + .map_err(|err| MiningControlError::Failed(CompactString::from(err.to_string())))?; let result = control.submit_block(payload)?; Ok(BlockReceipt { hash: self.share.hash, diff --git a/crates/stratum/tests/jobs.rs b/crates/stratum/tests/jobs.rs index ee932799d8..3abcd1927d 100644 --- a/crates/stratum/tests/jobs.rs +++ b/crates/stratum/tests/jobs.rs @@ -1201,11 +1201,12 @@ fn found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts() assert_eq!(receipt.coinbase_value, fx.candidate.coinbase_value); assert_eq!(receipt.session, fx.session.id()); assert_eq!(receipt.job, fx.job.id()); - let submitted = lock(&control.submitted); - assert_eq!(submitted.len(), 1); - assert_eq!(submitted[0].block(), &found.block); - assert_eq!(submitted[0].bytes().as_ref(), found.raw.as_slice()); - drop(submitted); + { + let submitted = lock(&control.submitted); + assert_eq!(submitted.len(), 1); + assert_eq!(submitted[0].block(), &found.block); + assert_eq!(submitted[0].bytes().as_ref(), found.raw.as_slice()); + } let cases = [ (BlockValidationResult::Duplicate, BlockStatus::Applied), From c3917a7ff3ea2a43cdc093abe242fb9ceeddd195 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:29:09 +0900 Subject: [PATCH 23/61] Prove pool management RPC authentication Exercise getpoolinfo through real TCP dispatch for authorized management access, credential refusal, and public-surface refusal. --- crates/rpc/tests/auth.rs | 124 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/crates/rpc/tests/auth.rs b/crates/rpc/tests/auth.rs index 66e1b4e1de..d0c7cfa170 100644 --- a/crates/rpc/tests/auth.rs +++ b/crates/rpc/tests/auth.rs @@ -10,7 +10,7 @@ use std::time::Duration; use bitcoin_rs_rpc::auth::constant_time_eq; use bitcoin_rs_rpc::context::Context; -use bitcoin_rs_rpc::{Auth, Handler, RpcServer}; +use bitcoin_rs_rpc::{Auth, Handler, RpcServer, RpcSurface}; use sonic_rs::{JsonContainerTrait, JsonValueTrait}; #[test] @@ -406,6 +406,128 @@ fn authorized_request_with_a_truncated_body_is_a_framing_error() Ok(()) } +/// POOL-08: pool management stays on the authenticated management surface. +/// +/// `getpoolinfo` is a shipped pool-registry method (management-only per the +/// registry notes). An authorized JSON-RPC request on the management +/// listener reaches the pool handler — which refuses without an enabled +/// ledger (`MethodDisabled`, or `MethodNotFound` when the pool feature is +/// not compiled in) — while missing or wrong credentials get Core's 401 and +/// the same body on the public listener gets 404 before authentication. +#[test] +fn pool_management_requires_authentication_and_management_surface() +-> Result<(), Box> { + let body = r#"{"jsonrpc":"2.0","method":"getpoolinfo","params":[],"id":1}"#; + + // `getpoolinfo` must be the shipped pool-registered method in every + // build, so the refusal legs below target pool management specifically + // even when the pool dispatch arm is not compiled in. + let row = bitcoin_rs_rpc::manifest::MANIFEST + .iter() + .find(|entry| entry.name == "getpoolinfo") + .ok_or("getpoolinfo must be a registered method")?; + assert_eq!( + row.feature, "pool", + "getpoolinfo must stay a pool-gated method" + ); + + let management = spawn_on_surface(Auth::basic("alice", "secret"), RpcSurface::Management)?; + let authorized = request(management, "YWxpY2U6c2VjcmV0", body)?; + assert!( + authorized.starts_with("HTTP/1.1 200 OK"), + "authorized management request must reach the pool handler: {authorized}" + ); + let payload = authorized + .split_once("\r\n\r\n") + .ok_or("missing body")? + .1; + let value: sonic_rs::Value = sonic_rs::from_str(payload)?; + assert_eq!( + value.get("jsonrpc").and_then(|v| v.as_str()), + Some("2.0") + ); + let error = value + .get("error") + .ok_or("pool refusal without a ledger must be an error")?; + #[cfg(feature = "pool")] + { + assert_eq!( + error + .get("code") + .and_then(sonic_rs::JsonValueTrait::as_i64), + Some(-32_603), + "disabled pool must answer MethodDisabled: {payload}" + ); + assert!( + error + .get("message") + .and_then(|message| message.as_str()) + .is_some_and(|message| message.contains("Pool service is disabled")), + "disabled pool must not fabricate status: {payload}" + ); + } + #[cfg(not(feature = "pool"))] + { + assert_eq!( + error + .get("code") + .and_then(sonic_rs::JsonValueTrait::as_i64), + Some(-32_601), + "unshipped pool method must answer MethodNotFound: {payload}" + ); + assert!( + error + .get("message") + .and_then(|message| message.as_str()) + .is_some_and(|message| message.contains("getpoolinfo")), + "unshipped pool method must name the method: {payload}" + ); + } + assert!(value.get("result").is_none()); + + let missing = request_post(management, body, None, "close")?; + assert!( + missing.starts_with("HTTP/1.1 401 Unauthorized"), + "management surface must refuse unauthenticated pool access: {missing}" + ); + let wrong = request_post(management, body, Some("YWxpY2U6YmFk"), "close")?; + assert!( + wrong.starts_with("HTTP/1.1 401 Unauthorized"), + "management surface must refuse wrong credentials: {wrong}" + ); + + let public = spawn_on_surface(Auth::basic("alice", "secret"), RpcSurface::Public)?; + let refused = request(public, "YWxpY2U6c2VjcmV0", body)?; + assert!( + refused.starts_with("HTTP/1.1 404 Not Found"), + "public surface must not route pool JSON-RPC even with valid credentials: {refused}" + ); + Ok(()) +} + +fn spawn_on_surface( + auth: Auth, + surface: RpcSurface, +) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let ctx = Arc::new(Context::new()); + let handler = Arc::new(Handler::new(ctx)); + let server = RpcServer { + listener, + auth: Arc::new(auth), + handler, + max_connections: 8, + idle_timeout: Duration::from_secs(2), + rest_enabled: false, + }; + let shutdown = Arc::new(AtomicBool::new(false)); + thread::spawn(move || { + let _ignored = server.serve_surface_with_shutdown(shutdown, surface); + }); + Ok(address) +} + fn spawn(auth: Auth) -> Result> { spawn_with_rest(auth, false) } From 306765cacc3492bbc8b1c9c25bb3582789d5c945 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:59:01 +0900 Subject: [PATCH 24/61] Prove pool contract boundaries Cover directory locking, composed limits, authority files, one-pending submission, observer errors, and management authentication with executable tests. Record remaining lifecycle and evidence gaps. --- crates/node/src/pool.rs | 63 +++++--- crates/node/src/pool/authority.rs | 180 +++++++++++++++++++++ crates/node/src/pool/tests.rs | 207 +++++++++++++++++++++++++ crates/node/tests/unit/config/tests.rs | 18 +++ crates/rpc/tests/auth.rs | 18 +-- crates/stratum/tests/protocol.rs | 167 ++++++++++++++++++++ docs/contracts/pool.md | 14 +- 7 files changed, 624 insertions(+), 43 deletions(-) diff --git a/crates/node/src/pool.rs b/crates/node/src/pool.rs index 54cb4c6ac9..8acbc54787 100644 --- a/crates/node/src/pool.rs +++ b/crates/node/src/pool.rs @@ -58,6 +58,43 @@ impl crate::storage_backend::StoreConsumer for LedgerComposer { } } +/// Composition-time bounds for the single shared pool core. +/// +/// Every protocol listener shares one [`PoolCore`]: at most 64 retained jobs, +/// one session slot per configured connection plus a spare set for reconnects +/// (twice the connection cap), 65,536 duplicate-tracked shares per job, and a +/// deferred share-ledger outbox of 1024 records / 8 MiB. Owner sessions are +/// not live TCP connections; the aggregate TCP caps live in the shared +/// [`ConnectionLimiter`](bitcoin_rs_stratum::ConnectionLimiter). +struct ComposedCore { + book: JobBookConfig, + outbox_items: usize, + outbox_bytes: usize, +} + +fn composed_core_config(max_connections: u32) -> Result { + let sessions = usize::try_from(max_connections)? + .checked_mul(2) + .context("pool session bound overflow")?; + Ok(ComposedCore { + book: JobBookConfig { + budget: CoinbaseBudget { + additional_size: 0, + additional_sigops: 0, + }, + extranonce: ExtranonceLayout::new(4, 4), + coinbase_tag: b"/btcrs/".to_vec(), + version_mask: bitcoin_rs_stratum::BIP320_VERSION_MASK, + ntime_forward_secs: bitcoin_rs_stratum::MAX_NTIME_FORWARD_SECS, + max_jobs: 64, + max_sessions: sessions, + max_shares_per_job: 65_536, + }, + outbox_items: 1024, + outbox_bytes: 8 * 1024 * 1024, + }) +} + impl PoolService { pub(crate) fn prepare( state: &crate::state::NodeState, @@ -85,29 +122,11 @@ impl PoolService { max_outputs: usize::try_from(config.max_payout_outputs)?, }, )); - let sessions = usize::try_from(config.max_connections)? - .checked_mul(2) - .context("pool session bound overflow")?; + let composed = composed_core_config(config.max_connections)?; let core = Arc::new( - PoolCore::new( - JobBookConfig { - budget: CoinbaseBudget { - additional_size: 0, - additional_sigops: 0, - }, - extranonce: ExtranonceLayout::new(4, 4), - coinbase_tag: b"/btcrs/".to_vec(), - version_mask: bitcoin_rs_stratum::BIP320_VERSION_MASK, - ntime_forward_secs: bitcoin_rs_stratum::MAX_NTIME_FORWARD_SECS, - max_jobs: 64, - max_sessions: sessions, - max_shares_per_job: 65_536, - }, - Arc::clone(&control), - host.clone(), - )? - .with_ledger_retry(1, Duration::ZERO) - .with_outbox(1024, 8 * 1024 * 1024), + PoolCore::new(composed.book, Arc::clone(&control), host.clone())? + .with_ledger_retry(1, Duration::ZERO) + .with_outbox(composed.outbox_items, composed.outbox_bytes), ); let chain = Arc::new(ledger::NodeChainView::new( state.chainstate(), diff --git a/crates/node/src/pool/authority.rs b/crates/node/src/pool/authority.rs index 7f6c273ca4..42b58ad864 100644 --- a/crates/node/src/pool/authority.rs +++ b/crates/node/src/pool/authority.rs @@ -103,3 +103,183 @@ fn open_private_file(_path: &Path) -> Result { ) .into()) } + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + const PUBLIC_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const SECRET_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + fn write_key_file( + directory: &Path, + name: &str, + contents: &[u8], + mode: u32, + ) -> std::path::PathBuf { + let path = directory.join(name); + std::fs::write(&path, contents).expect("fixture key file must be writable"); + #[cfg(unix)] + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) + .expect("fixture key file mode must be settable"); + #[cfg(not(unix))] + let _ = mode; + path + } + + fn valid_contents() -> Vec { + format!("{PUBLIC_HEX} {SECRET_HEX}\n").into_bytes() + } + + #[cfg(unix)] + #[test] + fn load_accepts_exactly_two_hex_fields_in_a_0600_regular_file() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let path = write_key_file(directory.path(), "authority.key", &valid_contents(), 0o600); + let pair = load(&path).expect("a 0600 regular file with two key fields must load"); + assert_eq!(pair.public(), &[0x11; KEY_BYTES]); + let rendered = format!("{pair:?}"); + assert!( + rendered.contains(""), + "stored secret must be redacted in Debug: {rendered}" + ); + assert!( + !rendered.contains(SECRET_HEX), + "stored secret hex must never reach Debug output" + ); + } + + #[cfg(unix)] + #[test] + fn load_tolerates_surrounding_ascii_whitespace_but_nothing_else() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let padded = format!("\t{PUBLIC_HEX} {SECRET_HEX}\r\n").into_bytes(); + let path = write_key_file(directory.path(), "padded.key", &padded, 0o600); + assert!( + load(&path).is_ok(), + "ascii-whitespace separation must be accepted" + ); + for (name, contents) in [ + ("empty.key", Vec::new()), + ("one-field.key", PUBLIC_HEX.as_bytes().to_vec()), + ( + "three-fields.key", + format!("{PUBLIC_HEX} {SECRET_HEX} {PUBLIC_HEX}").into_bytes(), + ), + ( + "short-field.key", + format!("{} {SECRET_HEX}", &PUBLIC_HEX[..62]).into_bytes(), + ), + ( + "non-hex.key", + format!("{PUBLIC_HEX} {}", "zz".repeat(KEY_BYTES).as_str()).into_bytes(), + ), + ("non-utf8.key", [0xff; KEY_BYTES * 2].to_vec()), + ] { + let path = write_key_file(directory.path(), name, &contents, 0o600); + assert!( + matches!(load(&path), Err(AuthorityKeyError::InvalidFormat)), + "malformed key file {name} must fail closed as InvalidFormat" + ); + } + } + + #[cfg(unix)] + #[test] + fn load_rejects_inputs_larger_than_256_bytes() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let oversized = vec![b'2'; MAX_KEY_FILE_BYTES + 1]; + assert_eq!(oversized.len(), 257); + let path = write_key_file(directory.path(), "oversized.key", &oversized, 0o600); + assert!( + matches!(load(&path), Err(AuthorityKeyError::InvalidFormat)), + "input above 256 bytes must fail closed as InvalidFormat" + ); + } + + #[cfg(unix)] + #[test] + fn parse_errors_carry_no_key_input() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let evil = format!("{PUBLIC_HEX} {}", "zz".repeat(KEY_BYTES)).into_bytes(); + let path = write_key_file(directory.path(), "evil.key", &evil, 0o644); + let error = load(&path).expect_err("a 0644 key file must be refused"); + assert!( + !matches!(error, AuthorityKeyError::InvalidFormat), + "permission refusal must precede parsing: {error:?}" + ); + let path = write_key_file(directory.path(), "badhex.key", &evil, 0o600); + let error = load(&path).expect_err("non-hex secret must be refused"); + assert!( + matches!(error, AuthorityKeyError::InvalidFormat), + "malformed key must fail as InvalidFormat: {error:?}" + ); + for rendered in [format!("{error}"), format!("{error:?}")] { + assert!( + !rendered.contains(&"zz".repeat(8)), + "key input must not reach error text: {rendered}" + ); + assert!( + !rendered.contains(PUBLIC_HEX) && !rendered.contains(SECRET_HEX), + "key input must not reach error text: {rendered}" + ); + } + } + + #[cfg(unix)] + #[test] + fn load_enforces_regular_file_and_0600_descriptor_checks() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + for mode in [0o644, 0o640, 0o600 | 0o111, 0o400] { + let name = format!("mode-{mode:o}.key"); + let path = write_key_file(directory.path(), &name, &valid_contents(), mode); + assert_eq!( + std::fs::metadata(&path) + .expect("mode probe must be statable") + .permissions() + .mode() + & 0o7777, + mode, + "fixture must carry the probed mode" + ); + assert!( + matches!(load(&path), Err(AuthorityKeyError::InsecureFile)), + "mode {mode:o} must be refused as insecure" + ); + } + let subdirectory = directory.path().join("not-a-file"); + std::fs::create_dir(&subdirectory).expect("fixture directory must exist"); + assert!( + matches!(load(&subdirectory), Err(AuthorityKeyError::InsecureFile)), + "a directory must be refused as insecure" + ); + } + + #[cfg(unix)] + #[test] + fn load_refuses_a_final_component_symlink() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let target = write_key_file(directory.path(), "target.key", &valid_contents(), 0o600); + let link = directory.path().join("link.key"); + std::os::unix::fs::symlink(&target, &link).expect("fixture symlink must exist"); + assert!( + load(&link).is_err(), + "a final-component symlink must not bypass the descriptor check" + ); + } + + #[cfg(not(unix))] + #[test] + fn load_fails_closed_without_platform_support() { + let directory = tempfile::tempdir().expect("test scratch directory must exist"); + let path = write_key_file(directory.path(), "authority.key", &valid_contents(), 0o600); + assert!( + load(&path).is_err(), + "key loading without platform support must fail closed" + ); + } +} diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index dfe11f2e60..d65517b34a 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -388,3 +388,210 @@ macro_rules! backend_tests { backend_tests!(redb, bitcoin_rs_storage::RedbStore); #[cfg(feature = "fjall")] backend_tests!(fjall, bitcoin_rs_storage::FjallStore); + +#[test] +fn composed_core_bounds_pin_the_contract_literals() -> TestResult { + let composed = super::composed_core_config(128)?; + assert_eq!( + composed.book.max_jobs, 64, + "retained job bound must stay 64" + ); + assert_eq!( + composed.book.max_sessions, 256, + "session bound must stay twice the connection cap" + ); + assert_eq!( + composed.book.max_shares_per_job, 65_536, + "per-job duplicate-tracking bound must stay 65,536" + ); + assert_eq!( + composed.outbox_items, 1024, + "deferred share-ledger outbox must stay 1024 records" + ); + assert_eq!( + composed.outbox_bytes, + 8 * 1024 * 1024, + "deferred share-ledger outbox must stay 8 MiB" + ); + Ok(()) +} + +#[test] +fn pool_directory_lock_is_exclusive_while_prepare_resources_live() -> TestResult { + let directory = tempfile::tempdir()?; + let datadir = directory.path().join("pool"); + std::fs::create_dir_all(&datadir)?; + let guard = crate::state::lock_pool_directory(&datadir)?; + assert!( + crate::state::lock_pool_directory(&datadir).is_err(), + "a second pool prepare must not acquire the dedicated directory lock" + ); + drop(guard); + let _released = crate::state::lock_pool_directory(&datadir)?; + Ok(()) +} + +fn ledger_create_open_and_epoch_under_guard( + backend: bitcoin_rs_storage::StorageBackend, +) -> TestResult { + let directory = tempfile::tempdir()?; + let datadir = directory.path().join("pool"); + std::fs::create_dir_all(&datadir)?; + let guard = crate::state::lock_pool_directory(&datadir)?; + let ledger_path = datadir.join("ledger"); + let policy = || pool_ledger::PoolConfig::new(1_000, vec![0x51]); + let (_ledger, first): (pool_ledger::Ledger, u32) = crate::storage_backend::open_generic( + "pool", + backend, + &ledger_path, + None, + &guard, + super::LedgerComposer { config: policy() }, + )?; + drop(_ledger); + let (_ledger, second): (pool_ledger::Ledger, u32) = crate::storage_backend::open_generic( + "pool", + backend, + &ledger_path, + None, + &guard, + super::LedgerComposer { config: policy() }, + )?; + assert!( + second > first, + "run-epoch allocation must advance across restarts while the directory lock is held" + ); + drop(_ledger); + let mismatch = pool_ledger::PoolConfig::new(2_000, vec![0x51]); + let outcome = crate::storage_backend::open_generic( + "pool", + backend, + &ledger_path, + None, + &guard, + super::LedgerComposer { config: mismatch }, + ) + .map(|_: (pool_ledger::Ledger, u32)| ()); + let error = outcome.unwrap_err(); + assert!( + matches!( + error.downcast_ref::(), + Some(pool_ledger::LedgerError::ConfigMismatch) + ), + "a changed policy must fail rather than reinterpret history: {error:?}" + ); + Ok(()) +} + +#[cfg(feature = "redb")] +#[test] +fn pool_prepare_create_open_and_epoch_run_under_the_directory_lock_redb() -> TestResult { + ledger_create_open_and_epoch_under_guard(bitcoin_rs_storage::StorageBackend::Redb) +} + +#[cfg(feature = "fjall")] +#[test] +fn pool_prepare_create_open_and_epoch_run_under_the_directory_lock_fjall() -> TestResult { + ledger_create_open_and_epoch_under_guard(bitcoin_rs_storage::StorageBackend::Fjall) +} + +#[test] +fn node_chain_view_reports_missing_index_observer_errors() -> TestResult { + use pool_ledger::ChainView as _; + + let directory = tempfile::tempdir()?; + let mut config = crate::config::NodeConfig::default_for_network(crate::Network::Regtest); + config.data_dir = directory.path().to_owned(); + config.rpc.bind = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + config.rpc.auth = crate::Auth::basic("user", "password"); + config.indexes.txindex = false; + config.indexes.script_index = crate::config::ScriptIndexMode::Disabled; + config.p2p.listen.clear(); + config.observability.metrics_bind = None; + let state = crate::state::NodeState::open(config, None)?; + assert!(state.derived_index_query().is_none()); + assert!(state.script_index_query().is_none()); + let view = super::ledger::NodeChainView::new( + state.chainstate(), + state.stable_read(), + state.mempool_reader(), + state.derived_index_query(), + state.script_index_query(), + )?; + let txid = [0x07; 32]; + match pool_ledger::ChainView::tx_status(&view, &txid) { + Err(error) => assert!( + error.0.contains("txindex"), + "missing txindex must be reported, not treated as evidence: {error}" + ), + Ok(_) => panic!("unknown payout must not resolve without a txindex"), + } + let outpoint = pool_ledger::OutPoint { + txid: [0x09; 32], + vout: 0, + }; + match pool_ledger::ChainView::confirmed_spender(&view, &outpoint) { + Err(error) => assert!( + error.0.contains("script index"), + "missing script index must be reported, not treated as evidence: {error}" + ), + Ok(_) => panic!("no spend can be observed without a script index"), + } + Ok(()) +} + +#[test] +fn pool_prepare_holds_directory_lock_for_service_lifetime() -> TestResult { + let directory = tempfile::tempdir()?; + let pool_datadir = directory.path().join("pool"); + let probe = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = probe.local_addr()?.port(); + drop(probe); + let mut config = crate::config::NodeConfig::default_for_network(crate::Network::Regtest); + config.data_dir = directory.path().join("node"); + config.rpc.bind = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + config.rpc.auth = crate::Auth::basic("user", "password"); + config.indexes.txindex = false; + config.indexes.script_index = crate::config::ScriptIndexMode::Disabled; + config.p2p.listen.clear(); + config.observability.metrics_bind = None; + #[cfg(feature = "redb")] + { + config.storage.backend = bitcoin_rs_storage::StorageBackend::Redb; + } + #[cfg(all(not(feature = "redb"), feature = "fjall"))] + { + config.storage.backend = bitcoin_rs_storage::StorageBackend::Fjall; + } + config.mining.payout_script = vec![0x51]; + config.pool = Some(crate::config::PoolConfig { + datadir: pool_datadir.clone(), + v1_bind: Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))), + sv2_bind: None, + sv2_authority_key: None, + allow_non_loopback: false, + max_connections: 128, + max_connections_per_ip: 8, + share_difficulty: "1".to_owned(), + share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("1")?, + window_work: 1_000, + fee_bps: 0, + reward_script: vec![0x51], + maturity: 100, + payout_confirmations: 6, + reorg_watch_depth: 1_000, + min_payout_sat: 1_000, + max_payout_outputs: 10, + }); + let state = crate::state::NodeState::open(config, None)?; + let control = Arc::new(bitcoin_rs_mining::FakeMiningControl::default()); + let service = + super::PoolService::prepare(&state, control)?.expect("enabled pool must prepare a service"); + assert!( + crate::state::lock_pool_directory(&pool_datadir).is_err(), + "pool datadir must stay locked while the prepared service lives" + ); + drop(service); + let _released = crate::state::lock_pool_directory(&pool_datadir)?; + Ok(()) +} diff --git a/crates/node/tests/unit/config/tests.rs b/crates/node/tests/unit/config/tests.rs index 1638cd2a7f..8010a90ad2 100644 --- a/crates/node/tests/unit/config/tests.rs +++ b/crates/node/tests/unit/config/tests.rs @@ -68,6 +68,24 @@ fn pool_requires_explicit_policy_and_listener() { } } +#[cfg(feature = "pool")] +#[test] +fn pool_composed_connection_defaults_are_pinned() { + let config = resolved(&[&pool_layer()]); + let pool = config + .pool + .as_ref() + .unwrap_or_else(|| panic!("pool layer must enable the pool")); + assert_eq!( + pool.max_connections, 128, + "shared total connection default must stay 128" + ); + assert_eq!( + pool.max_connections_per_ip, 8, + "shared per-IP connection default must stay 8" + ); +} + #[cfg(feature = "pool")] #[test] fn pool_layers_preserve_explicit_fields_and_override_policy() -> anyhow::Result<()> { diff --git a/crates/rpc/tests/auth.rs b/crates/rpc/tests/auth.rs index d0c7cfa170..9273440212 100644 --- a/crates/rpc/tests/auth.rs +++ b/crates/rpc/tests/auth.rs @@ -437,24 +437,16 @@ fn pool_management_requires_authentication_and_management_surface() authorized.starts_with("HTTP/1.1 200 OK"), "authorized management request must reach the pool handler: {authorized}" ); - let payload = authorized - .split_once("\r\n\r\n") - .ok_or("missing body")? - .1; + let payload = authorized.split_once("\r\n\r\n").ok_or("missing body")?.1; let value: sonic_rs::Value = sonic_rs::from_str(payload)?; - assert_eq!( - value.get("jsonrpc").and_then(|v| v.as_str()), - Some("2.0") - ); + assert_eq!(value.get("jsonrpc").and_then(|v| v.as_str()), Some("2.0")); let error = value .get("error") .ok_or("pool refusal without a ledger must be an error")?; #[cfg(feature = "pool")] { assert_eq!( - error - .get("code") - .and_then(sonic_rs::JsonValueTrait::as_i64), + error.get("code").and_then(sonic_rs::JsonValueTrait::as_i64), Some(-32_603), "disabled pool must answer MethodDisabled: {payload}" ); @@ -469,9 +461,7 @@ fn pool_management_requires_authentication_and_management_surface() #[cfg(not(feature = "pool"))] { assert_eq!( - error - .get("code") - .and_then(sonic_rs::JsonValueTrait::as_i64), + error.get("code").and_then(sonic_rs::JsonValueTrait::as_i64), Some(-32_601), "unshipped pool method must answer MethodNotFound: {payload}" ); diff --git a/crates/stratum/tests/protocol.rs b/crates/stratum/tests/protocol.rs index eb7741a342..626f5e7710 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/stratum/tests/protocol.rs @@ -614,6 +614,173 @@ fn transactional_outbox_buffers_shares_when_ledger_fails() { server.shutdown(); } +#[test] +fn second_submit_while_accounting_pending_is_shed_before_validation() { + let prev = Hash256::from_le_bytes(&[0x44; 32]); + let candidate = Arc::new(sample_candidate( + prev, + CompactTarget::from_consensus(0x207f_ffff), + 4, + )); + let fake_control = Arc::new(FakeMiningControl::default()); + // A permanently failing ledger is a deterministic barrier: the first + // share can never complete inline, so the session holds exactly one + // pending accounting response until the test disarms the ledger. + let ledger = Arc::new(TestLedger::armed_with_failures(usize::MAX)); + let pool = build_pool(fake_control, ledger.clone()); + let installed = pool + .install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let job_id = installed.job.id(); + + let accounts = Arc::new( + StaticAccountResolver::new() + .with_account("alice", "secret123") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")), + ); + let diff_policy = DifficultyPolicy::new("0.0000000001", "0.00000000001", "1000") + .unwrap_or_else(|error| panic!("test operation failed: {error:?}")); + let config = ServerConfig::new(diff_policy); + let server = Server::bind( + "127.0.0.1:0", + config, + ServerDeps { + pool: pool.clone(), + accounts, + observer: None, + }, + ) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let addr = server.local_addr(); + + let mut client = TestClient::connect(addr); + client.send("{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}\n"); + let _ = client.read_line(); + client.send( + "{\"id\":2,\"method\":\"mining.authorize\",\"params\":[\"alice.w1\",\"secret123\"]}\n", + ); + let _ = client.read_line(); + let _ = client.read_line(); // diff + let _ = client.read_line(); // notify + + let first = format!( + "{{\"id\":3,\"method\":\"mining.submit\",\"params\":[\"alice.w1\",\"{job_id:08x}\",\"00000001\",\"{ntime:08x}\",\"00000000\"]}}\n", + job_id = job_id.get(), + ntime = installed.job.ntime(), + ); + client.send(&first); + // The first share is buffered in the outbox; no success may precede the + // durable record. + client + .stream + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let mut early = String::new(); + assert!( + !TestClient::read_available(&mut client.reader, &mut early), + "no success may precede the durable record" + ); + assert!(early.is_empty(), "unexpected early frame: {early:?}"); + + // A second submit with a fresh valid nonce arrives while the first + // accounting is still pending. It must be shed before validation with the + // retryable accounting error: the hash is never burned and the miner may + // retry it. + let second = format!( + "{{\"id\":4,\"method\":\"mining.submit\",\"params\":[\"alice.w1\",\"{job_id:08x}\",\"00000001\",\"{ntime:08x}\",\"00000001\"]}}\n", + job_id = job_id.get(), + ntime = installed.job.ntime(), + ); + client.send(&second); + client + .stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + let shed = client.read_line(); + assert!( + shed.contains("\"error\":[20,") && shed.contains("Share accounting unavailable"), + "second submit while pending must be shed retryably: {shed}" + ); + assert_eq!( + ledger.recorded_shares().len(), + 0, + "a shed submit must not reach the ledger" + ); + + // Disarm the barrier, then drain: the session's next poll delivers the + // deferred first success, and the connection is still alive. + ledger.disarm(); + pool.drain_outbox(); + let resp = client.read_line(); + assert_eq!(resp, "{\"id\":3,\"result\":true,\"error\":null}\n"); + assert_eq!(ledger.recorded_shares().len(), 1); + + // The shed share was never burned: resubmitting it now credits a second + // share instead of answering duplicate. + let retry = second.replace("\"id\":4", "\"id\":5"); + client.send(&retry); + let retried = client.read_line(); + assert_eq!(retried, "{\"id\":5,\"result\":true,\"error\":null}\n"); + assert_eq!(ledger.recorded_shares().len(), 2); + + server.shutdown(); +} + +#[test] +fn composed_pool_defaults_enforce_total_and_per_ip_caps() -> TestResult { + use std::net::{IpAddr, Ipv4Addr}; + + let limiter = Arc::new(ConnectionLimiter::new(128, 8)?); + let mut held = Vec::new(); + for host in 1..=16u8 { + let peer = IpAddr::V4(Ipv4Addr::new(127, 0, 0, host)); + for _ in 0..8 { + held.push(limiter.try_admit(peer).map_err(|reason| { + std::io::Error::other(format!( + "composed default admission refused early: {reason:?}" + )) + })?); + } + assert!( + limiter.try_admit(peer).is_err(), + "the 9th connection from one IP must be refused under the composed 8-per-IP cap" + ); + } + assert_eq!(held.len(), 128); + assert!( + limiter + .try_admit(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 17))) + .is_err(), + "the 129th total connection must be refused under the composed 128-total cap" + ); + drop(held); + limiter + .try_admit(IpAddr::V4(Ipv4Addr::LOCALHOST)) + .map_err(|reason| { + std::io::Error::other(format!("released slot not re-admittable: {reason:?}")) + })?; + Ok(()) +} + +#[test] +fn job_install_with_new_parent_issues_no_ledger_write() { + let ledger = Arc::new(TestLedger::default()); + let pool = build_pool(Arc::new(FakeMiningControl::default()), ledger.clone()); + for (parent, height) in [([0x51; 32], 10), ([0x52; 32], 11)] { + let candidate = Arc::new(sample_candidate( + Hash256::from_le_bytes(&parent), + CompactTarget::from_consensus(0x207f_ffff), + height, + )); + pool.install(candidate, Vec::new()) + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + } + assert!( + ledger.recorded_shares().is_empty(), + "installing jobs with new parents must not write share accounting" + ); +} + #[test] fn protocol_negative_scenarios_rejected_deterministically() { let prev = Hash256::from_le_bytes(&[0x33; 32]); diff --git a/docs/contracts/pool.md b/docs/contracts/pool.md index 4e6f939183..b17d3c86a1 100644 --- a/docs/contracts/pool.md +++ b/docs/contracts/pool.md @@ -38,7 +38,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The service MUST lock the dedicated pool directory. It MUST create a ledger only when absent and reopen an existing ledger with the persisted immutable policy checked for equality. - Accounting policy changes MUST NOT silently reinterpret historical shares or funds. A configuration mismatch MUST fail rather than delete or recreate the ledger. - Ledger creation/opening and run-epoch allocation MUST precede listener admission. A run epoch MUST distinguish restarted protocol job identities in durable share accounting. -- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::create_refuses_existing_paths_and_open_demands_the_same_config` proves the ledger is created only when absent and reopening with a different window, fee, or any persisted immutable policy fails with `ConfigMismatch` instead of deleting or recreating. `::open_refuses_a_foreign_store_without_writing_to_it` proves opening a store holding foreign content fails with `NotALedger` while leaving the foreign bytes intact and writing no `pool-ledger/v1/` keys. The `redb` and `fjall` modules in `crates/pool-ledger/tests/ledger.rs` run the whole suite through `Arc` against both real node storage engines, which exercises the storage-owned boundary. `::runtime_epochs_are_durable_and_shared_between_ledger_handles` proves run-epoch allocation is monotonic across handles and restarts, while `crates/node/src/pool/tests.rs` (`redb`/`fjall::restart_epoch_prevents_reused_wire_job_identity_collision`) proves the new epoch separates a reused wire job identity after restart and `redb`/`fjall::accepted_share_has_exact_durable_identity_and_idempotent_replay` proves the exact durable share identity survives reopen. Gap: no executable test asserts that `PoolService::prepare` holds the dedicated pool-directory lock or that creation/opening plus epoch allocation precede listener admission; the lock primitive itself is covered only by the generic `crates/node/tests/unit/state/tests/data_dir_lock.rs` suite, not by pool wiring. +- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::create_refuses_existing_paths_and_open_demands_the_same_config` proves the ledger is created only when absent and reopening with a different window, fee, or any persisted immutable policy fails with `ConfigMismatch` instead of deleting or recreating. `::open_refuses_a_foreign_store_without_writing_to_it` proves opening a store holding foreign content fails with `NotALedger` while leaving the foreign bytes intact and writing no `pool-ledger/v1/` keys. The `redb` and `fjall` modules in `crates/pool-ledger/tests/ledger.rs` run the whole suite through `Arc` against both real node storage engines, which exercises the storage-owned boundary. `::runtime_epochs_are_durable_and_shared_between_ledger_handles` proves run-epoch allocation is monotonic across handles and restarts, while `crates/node/src/pool/tests.rs` (`redb`/`fjall::restart_epoch_prevents_reused_wire_job_identity_collision`) proves the new epoch separates a reused wire job identity after restart and `redb`/`fjall::accepted_share_has_exact_durable_identity_and_idempotent_replay` proves the exact durable share identity survives reopen. `crates/node/src/pool/tests.rs::pool_prepare_holds_directory_lock_for_service_lifetime` proves the real `PoolService::prepare` path (opened `NodeState`, pool-enabled config) holds the dedicated pool-directory lock for the service lifetime and releases it on drop, while `::pool_directory_lock_is_exclusive_while_prepare_resources_live` pins the underlying exclusivity. `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_redb` and `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_fjall` prove ledger creation/opening with the persisted-policy equality check plus run-epoch allocation run under that same held lock through the exact `LedgerComposer`/`open_generic` prepare path, with a changed policy failing as `ConfigMismatch`. Residual gap: listeners starting only after RPC bind plus an initial template, and shutdown draining, are not covered; they need a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target plus a template-serving mining control) and remain structural in `crates/node/src/lifecycle.rs`. ### `POOL-03`: Shared admission and bounded deferred work @@ -47,7 +47,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The node-composed deferred share-ledger outbox MUST be bounded by both 1024 records and 8 MiB. Exhaustion MUST reject work before validation. SV1 MUST withhold success for queued writes until their accounting completion is recorded. - Found-block processing MUST reserve capacity before SV2 protocol feed/validation can emit work requiring a record. Found-record reservations and recovery records MUST share the eight-slot budget, with a 4,000,000-byte charge per slot. Failed writes MUST retain the compact accounting projection rather than serialized full blocks. - Per-connection protocol limits MUST remain in force in addition to aggregate admission. None of these individual bounds is a claim of a total-process memory bound. -- Proof: `crates/stratum/tests/protocol.rs::shared_connection_limiter_enforces_total_and_per_ip_caps` proves total and per-IP caps with release on drop, and `crates/node/src/pool/tests.rs` (`protocol_admissions_share_total_capacity_and_release_it_on_drop`, `protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots`) proves SV1 and SV2 share one limiter and refused admissions consume no slot. `crates/stratum/tests/protocol.rs::connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions` proves excess peers are refused and shutdown joins served sessions. `crates/stratum/tests/jobs.rs::retention_is_bounded_and_retired_jobs_are_stale` proves bounded job retention with stale rejection of evicted jobs; `::sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first` and `::unusable_configurations_are_refused` prove bounded owner sessions and refusal of zero capacities. `::rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block` and `::block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay` prove rejected input does not fill the per-job duplicate set while a block accepted at capacity is still remembered. `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails`, `::configured_minima_cannot_fail_an_empty_outbox`, and `::per_submit_reservation_does_not_cap_the_pool_at_one_share` prove the bounded deferred outbox withholds success until the durable record and admits submits on an empty outbox without capping the pool at one share; `block_record_capacity_is_bounded_and_permit_drop_releases_it` (node pool tests, both backends) proves the eight found-record slots with release on drop. `crates/stratum-v2/tests/protocol.rs::unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded` proves the per-connection pending-ack bound with a `pool-busy` refusal that records nothing. Gap: no test pins the literal composed defaults (128 total / 8 per IP, 64 jobs, sessions at twice the connection cap, 65,536 entries per job, 1024 records / 8 MiB); the tests prove the enforcement mechanisms with small capacities. +- Proof: `crates/stratum/tests/protocol.rs::shared_connection_limiter_enforces_total_and_per_ip_caps` proves total and per-IP caps with release on drop, and `crates/node/src/pool/tests.rs` (`protocol_admissions_share_total_capacity_and_release_it_on_drop`, `protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots`) proves SV1 and SV2 share one limiter and refused admissions consume no slot. `crates/stratum/tests/protocol.rs::connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions` proves excess peers are refused and shutdown joins served sessions. `crates/stratum/tests/jobs.rs::retention_is_bounded_and_retired_jobs_are_stale` proves bounded job retention with stale rejection of evicted jobs; `::sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first` and `::unusable_configurations_are_refused` prove bounded owner sessions and refusal of zero capacities. `::rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block` and `::block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay` prove rejected input does not fill the per-job duplicate set while a block accepted at capacity is still remembered. `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails`, `::configured_minima_cannot_fail_an_empty_outbox`, and `::per_submit_reservation_does_not_cap_the_pool_at_one_share` prove the bounded deferred outbox withholds success until the durable record and admits submits on an empty outbox without capping the pool at one share; `block_record_capacity_is_bounded_and_permit_drop_releases_it` (node pool tests, both backends) proves the eight found-record slots with release on drop. `crates/stratum-v2/tests/protocol.rs::unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded` proves the per-connection pending-ack bound with a `pool-busy` refusal that records nothing. `crates/node/tests/unit/config/tests.rs::pool_composed_connection_defaults_are_pinned` proves resolution pins the composed 128-total / 8-per-IP connection defaults, `crates/stratum/tests/protocol.rs::composed_pool_defaults_enforce_total_and_per_ip_caps` proves a (128, 8) limiter admits exactly 128 total with 8 per IP and releases on drop, and `crates/node/src/pool/tests.rs::composed_core_bounds_pin_the_contract_literals` proves the composed core carries 64 retained jobs, sessions at twice the connection cap, 65,536 duplicate-tracked entries per job, and a 1024-record / 8 MiB deferred outbox. ### `POOL-04`: Authority-key handling @@ -57,7 +57,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Secure authority-file loading MUST fail closed where this implementation has no supported loader; the non-Unix path reports unsupported operation. - The file input buffer, decoded secret buffer, and stored authority secret MUST use zeroizing ownership. Debug formatting MUST redact the stored secret, and parse errors MUST NOT include key input. This requirement does not certify zeroization of cryptographic dependency internals, kernel buffers, or every transient machine-level copy. - Operators SHOULD protect ancestor directories. `NOFOLLOW` on the opened path MUST NOT be described as an ancestor-directory trust guarantee. -- Proof: `crates/stratum-v2/tests/protocol.rs::noise_handshake_authenticates_the_pool_to_a_pinned_miner` proves the authority authenticates the Noise transport to a miner pinning its public key; `::a_miner_pinning_another_authority_rejects_the_server_certificate` proves a certificate from another authority is rejected; `::a_mismatched_authority_key_pair_is_refused_at_accept` proves a non-matching public/secret pair is refused at accept. Gap: no executable test covers `crates/node/src/pool/authority.rs::load` or `open_private_file` (the regular-file/`0600` descriptor check, the `RDONLY`/`CLOEXEC`/`NOFOLLOW`/`NONBLOCK` open flags, the exactly-two-64-hex-field and 256-byte limits, the non-Unix fail-closed path, the zeroizing file/secret buffers, the redacted `Debug`, or the input-free parse errors), so the file-format, permission, and redaction claims have no local proof. +- Proof: `crates/stratum-v2/tests/protocol.rs::noise_handshake_authenticates_the_pool_to_a_pinned_miner` proves the authority authenticates the Noise transport to a miner pinning its public key; `::a_miner_pinning_another_authority_rejects_the_server_certificate` proves a certificate from another authority is rejected; `::a_mismatched_authority_key_pair_is_refused_at_accept` proves a non-matching public/secret pair is refused at accept. `crates/node/src/pool/authority.rs::tests::load_accepts_exactly_two_hex_fields_in_a_0600_regular_file` proves a two-field `0600` regular file loads with the expected public key and a `` `Debug` that carries no secret hex; `::load_tolerates_surrounding_ascii_whitespace_but_nothing_else` and `::load_rejects_inputs_larger_than_256_bytes` prove the exactly-two-64-hex-field and 256-byte limits; `::parse_errors_carry_no_key_input` proves parse errors carry no key input in either `Display` or `Debug`; `::load_enforces_regular_file_and_0600_descriptor_checks` proves non-`0600` modes and directories are refused as `InsecureFile`; and `::load_refuses_a_final_component_symlink` proves a final-component symlink cannot bypass the descriptor check. Residual gap: the non-Unix fail-closed path cannot execute on Linux, and zeroization of transient machine-level copies beyond the `Zeroizing` file/secret buffers and the redacted `Debug` is not observably pinned. ### `POOL-05`: One mining owner and acknowledgement boundaries @@ -70,7 +70,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Each SV1 session MUST retain at most one pending accounting response. Additional submissions while it is pending MUST be rejected before validation with a retryable accounting error. Polling MUST send the deferred success only after accounting completion. Process failure MAY lose queued work before completion, but such work MUST NOT already have received success. - A found-block accounting record MUST initially be pending and deduplicated by its block/share identity. Retrying a record MUST NOT allocate the same reward twice. - New job parents, clean-job flags, and submission receipts MUST NOT be used as reorg or maturity evidence. -- Proof: `crates/stratum/tests/jobs.rs::found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts` proves found blocks go through the shared `MiningControl` submit path with Applied/Rejected/Undetermined verdict mapping. `crates/stratum/tests/protocol.rs::applied_block_retires_job_and_second_submit_is_stale` proves synchronous retirement on `Applied` with a second same-job nonce rejected as stale (SV1 21) and never resubmitted. `crates/node/src/pool/sv2.rs::applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node` proves the native SV2 path retires the common job before a sibling reaches the node. `crates/stratum-v2/tests/protocol.rs::stale_sibling_receipt_sends_stale_error_without_closing` proves an `inconclusive-not-best-prevblk` sibling still completes its record and is answered `stale-share` without closing; `::accepted_block_retirement_makes_second_same_job_submit_stale` proves post-retirement submits are stale while `::duplicate_share_is_refused_with_the_official_code` and `::stale_jobs_unknown_jobs_and_closed_channels_use_official_codes` prove byte-identical replays keep their `duplicate-share` identity. `::accepted_share_is_held_until_the_ledger_confirms_then_acknowledged` proves SV2 success waits for host confirmation of the durable outcome, and `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails` with `::crash_restored_share_still_delivers_its_block_record`, `::permanently_refused_share_is_rejected_without_retry_or_buffer`, and `::unknown_wire_job_rejects_before_the_common_submit_owner` prove SV1 deferred success, duplicate preservation across restart, no retry or buffering of permanent refusals, and rejection before the common owner for unknown jobs. `rejected_block_receipt_persists_pending_round_without_funds` and `failed_block_submission_persists_pending_round_without_funds` (node pool tests, both backends) prove rejected/failed receipts leave the round pending with no funds. Gap: no test asserts the at-most-one-pending-response SV1 session gate (retryable rejection of a second submit while one is pending, polling delivery of the deferred success) or that new parents, clean-job flags, and receipts are never consumed as reorg or maturity evidence. +- Proof: `crates/stratum/tests/jobs.rs::found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts` proves found blocks go through the shared `MiningControl` submit path with Applied/Rejected/Undetermined verdict mapping. `crates/stratum/tests/protocol.rs::applied_block_retires_job_and_second_submit_is_stale` proves synchronous retirement on `Applied` with a second same-job nonce rejected as stale (SV1 21) and never resubmitted. `crates/node/src/pool/sv2.rs::applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node` proves the native SV2 path retires the common job before a sibling reaches the node. `crates/stratum-v2/tests/protocol.rs::stale_sibling_receipt_sends_stale_error_without_closing` proves an `inconclusive-not-best-prevblk` sibling still completes its record and is answered `stale-share` without closing; `::accepted_block_retirement_makes_second_same_job_submit_stale` proves post-retirement submits are stale while `::duplicate_share_is_refused_with_the_official_code` and `::stale_jobs_unknown_jobs_and_closed_channels_use_official_codes` prove byte-identical replays keep their `duplicate-share` identity. `::accepted_share_is_held_until_the_ledger_confirms_then_acknowledged` proves SV2 success waits for host confirmation of the durable outcome, and `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails` with `::crash_restored_share_still_delivers_its_block_record`, `::permanently_refused_share_is_rejected_without_retry_or_buffer`, and `::unknown_wire_job_rejects_before_the_common_submit_owner` prove SV1 deferred success, duplicate preservation across restart, no retry or buffering of permanent refusals, and rejection before the common owner for unknown jobs. `rejected_block_receipt_persists_pending_round_without_funds` and `failed_block_submission_persists_pending_round_without_funds` (node pool tests, both backends) prove rejected/failed receipts leave the round pending with no funds. `crates/stratum/tests/protocol.rs::second_submit_while_accounting_pending_is_shed_before_validation` proves the at-most-one-pending-response SV1 session gate: a second submit while one accounting response is pending is shed before validation with the retryable `Share accounting unavailable` error, the shed share never reaches the ledger, the session's next poll still delivers the deferred first success, and the shed share is never burned so its retry credits normally. `::job_install_with_new_parent_issues_no_ledger_write` proves installing jobs with new parents writes nothing to share accounting. Residual gap: clean-job flags and submission receipts not being consumed as reorg or maturity evidence stays structural; receipts are partly covered above (rejected/failed receipts leave rounds pending with no funds), but flag non-use has no direct executable test. ### `POOL-06`: PPLNS reward and maturity @@ -89,7 +89,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Future credits MUST offset deficits before increasing available funds. Per account, `available + reserved + paid - deficit` MUST equal credited matured-round allocations. - Unknown membership MUST NOT be treated as proof of canonicality or displacement. Reorg monitoring is bounded: rounds and confirmed batches leave monitoring according to the configured watch horizon. This contract MUST NOT be advertised as unlimited deep-reorg recovery. - Payout confirmation reversal MUST preserve the batch's financial reservation/accounting transitions. A transaction disappearing from a mempool MUST NOT prove that its signature is unusable or that its inputs cannot later be spent. -- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::equal_height_fork_reverses_matured_credits` and `::lower_height_fork_reverses_matured_credits` prove an equal-height and a lower-height replacement tip both orphan a matured round and reverse its credits with conservation held. `::a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive` and `::a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns` prove pre-maturity orphaning with nothing to reverse plus revival, and post-maturity reversal with re-credit on return. `::reversing_credits_that_were_already_reserved_creates_an_explicit_deficit` proves reversal consumes available funds first and the remainder becomes an explicit deficit that later re-credit clears before increasing available funds, keeping `available + reserved + paid - deficit` balanced via `check_invariants`. `::unseen_rounds_stop_being_monitored_after_the_watch_depth` proves bounded monitoring leaves rounds unwatched past the horizon. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` and `::reconciliation_is_idempotent_and_writes_no_audit_noise` prove contradictory claims never apply and unknown membership changes nothing idempotently. `::a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it` and `::a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent` prove payout confirmation reversal preserves reservations and transitions, with failure only on conclusively double-spent inputs. Gap: no test exercises the node `NodeChainView` adapter's stable-observation path (canonical-body checks of transaction/spend index hints) or the missing-index observer errors; `::an_unavailable_observer_changes_nothing` proves only the generic unavailable-observer case, not the txindex/script-index-specific errors. +- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::equal_height_fork_reverses_matured_credits` and `::lower_height_fork_reverses_matured_credits` prove an equal-height and a lower-height replacement tip both orphan a matured round and reverse its credits with conservation held. `::a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive` and `::a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns` prove pre-maturity orphaning with nothing to reverse plus revival, and post-maturity reversal with re-credit on return. `::reversing_credits_that_were_already_reserved_creates_an_explicit_deficit` proves reversal consumes available funds first and the remainder becomes an explicit deficit that later re-credit clears before increasing available funds, keeping `available + reserved + paid - deficit` balanced via `check_invariants`. `::unseen_rounds_stop_being_monitored_after_the_watch_depth` proves bounded monitoring leaves rounds unwatched past the horizon. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` and `::reconciliation_is_idempotent_and_writes_no_audit_noise` prove contradictory claims never apply and unknown membership changes nothing idempotently. `::a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it` and `::a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent` prove payout confirmation reversal preserves reservations and transitions, with failure only on conclusively double-spent inputs. `crates/node/src/pool/tests.rs::node_chain_view_reports_missing_index_observer_errors` proves the `NodeChainView` missing-index legs through a real opened `NodeState`: `tx_status` without a txindex and `confirmed_spender` without a script index fail as observer errors rather than evidence of nonpayment or safe abandonment. Explicit blocker: the canonical-body checks of populated transaction/spend index hints have no fixture — they need applied blocks plus populated indexes, which no pool test scope builds. ### `POOL-08`: External signing without wallet custody @@ -101,7 +101,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Receipt validation MUST NOT be described as signature or input-ownership verification. `recordpayoutreceipt` MUST NOT broadcast. Chain observation of the recomputed txid supplies node-acceptance evidence; payment confirmation additionally requires the configured depth. - Signing failure, timeout, or receipt rejection MUST NOT release a reservation implicitly. Cancellation MUST require a prepared batch and explicit `never_handed_to_signer` or `signer_revoked` basis. The operator MUST establish that external basis; the RPC cannot prove signer revocation. - Lack of a transaction index prevents confirmed payout observation outside the mempool; lack of a script index prevents confirmed-spend observation. Such observer errors MUST NOT be converted into evidence of nonpayment or safe abandonment. -- Proof: `crates/rpc/src/handlers/pool.rs::disabled_pool_never_returns_fabricated_success` proves every pool handler is method-disabled without a ledger and returns no fabricated success. `::enabled_handlers_use_the_real_durable_ledger` proves key-free handlers over the real durable ledger with idempotency-key, minimum-payout, and output-cap policy enforcement. `::funded_external_payout_round_trip_preserves_reservations_on_rejection` proves the funded prepare/request/sign/broadcast walk with the reservation preserved across a txid-mismatch rejection and no cancellation after signing. `crates/pool-ledger/tests/support/ledger_cases.rs::preparing_a_batch_reserves_balances_atomically_and_never_double_allocates` proves atomic reservation with idempotency-key replay returning the immutable batch; `::payout_policy_is_bounded_by_dust_and_output_limits` proves the 546-sat dust floor and output-cap bound. `::payout_request_and_receipt_round_trip_and_reject_corruption` proves the strict `BRPR`/`BRPT` version 1 codecs with big-endian metadata, exact outputs, and rejection of bad magic, trailing bytes, and truncation; `::config_encode_validates_change_scripts_before_encoding` proves change-script allowlist validation. `::a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid` proves exact-output/txid matching with reservations preserved on every refusal (wrong txid, null or duplicate inputs, unparseable receipts, unknown batches, excess change outputs); `::replacements_must_conflict_with_every_earlier_signed_transaction` proves replacements must conflict rather than double-pay; `::cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing` proves cancellation requires a prepared batch with an explicit `never_handed_to_signer`/`signer_revoked` basis and is refused after signing; `::the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing` proves signer failures change nothing. `::a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting` proves broadcast requires mempool observation and confirmation requires the configured depth. Gap: no cited test exercises RPC-layer authentication dispatch for the pool methods; the handler tests call handlers directly with a constructed `Context`, so the authenticated-management-only gate itself has no local proof. +- Proof: `crates/rpc/src/handlers/pool.rs::disabled_pool_never_returns_fabricated_success` proves every pool handler is method-disabled without a ledger and returns no fabricated success. `::enabled_handlers_use_the_real_durable_ledger` proves key-free handlers over the real durable ledger with idempotency-key, minimum-payout, and output-cap policy enforcement. `::funded_external_payout_round_trip_preserves_reservations_on_rejection` proves the funded prepare/request/sign/broadcast walk with the reservation preserved across a txid-mismatch rejection and no cancellation after signing. `crates/pool-ledger/tests/support/ledger_cases.rs::preparing_a_batch_reserves_balances_atomically_and_never_double_allocates` proves atomic reservation with idempotency-key replay returning the immutable batch; `::payout_policy_is_bounded_by_dust_and_output_limits` proves the 546-sat dust floor and output-cap bound. `::payout_request_and_receipt_round_trip_and_reject_corruption` proves the strict `BRPR`/`BRPT` version 1 codecs with big-endian metadata, exact outputs, and rejection of bad magic, trailing bytes, and truncation; `::config_encode_validates_change_scripts_before_encoding` proves change-script allowlist validation. `::a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid` proves exact-output/txid matching with reservations preserved on every refusal (wrong txid, null or duplicate inputs, unparseable receipts, unknown batches, excess change outputs); `::replacements_must_conflict_with_every_earlier_signed_transaction` proves replacements must conflict rather than double-pay; `::cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing` proves cancellation requires a prepared batch with an explicit `never_handed_to_signer`/`signer_revoked` basis and is refused after signing; `::the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing` proves signer failures change nothing. `::a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting` proves broadcast requires mempool observation and confirmation requires the configured depth. `crates/rpc/tests/auth.rs::pool_management_requires_authentication_and_management_surface` proves the RPC-layer gate over real TCP dispatch: an authorized management request reaches the pool handler (which refuses without an enabled ledger instead of fabricating status), while missing or wrong credentials are refused with 401 and the public surface never routes pool JSON-RPC. ### `POOL-09`: Lifecycle and protocol scope @@ -109,7 +109,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Reconciliation MUST refresh work and observe rounds/payouts without holding shutdown hostage to a future mining generation. Shutdown MUST stop admission, cancel and join protocol workers, and drain bounded deferred accounting work within the node's shutdown deadline. A missed deadline or worker panic MUST remain an error, not a success report. - SV2 support is limited to the role matrix in `crates/stratum-v2/src/lib.rs`: Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling, including the adapter's reconnect API. - Custom jobs/work selection, Job Declaration, a Template Distribution server, extranonce-prefix changes, and extensions negotiation MUST NOT be advertised as implemented. The in-process template source MUST NOT be confused with a Template Distribution server. -- Proof: `crates/stratum-v2/tests/protocol.rs::the_role_matrix_does_not_claim_what_is_not_built` proves the role matrix marks standard/extended/group channels and `Noise_NX` implemented while Job Declaration, work selection, Template Distribution, and extensions negotiation are `NotImplemented` with documented detail. `::job_declaration_and_template_distribution_are_not_served` proves both protocols are refused with `unsupported-protocol` and the connection closes; `::custom_jobs_are_answered_with_the_official_code_not_pretended` proves custom-job requests are answered `jd-not-supported`. `crates/node/tests/unit/lifecycle/tests.rs::final_payout_script_prefers_pool_when_values_differ` proves one payout-script owner shared by pool and template paths with the pool reward winning while configured. Gap: no executable test covers `PoolService::prepare`/`start`/`shutdown`: ledger-before-RPC construction, listeners starting only after RPC bind plus an initial template, reconciliation that never holds shutdown hostage to a future mining generation, admission stop with worker cancel/join, bounded outbox/block-record draining within the deadline, or deadline-miss/panic surfacing as errors. +- Proof: `crates/stratum-v2/tests/protocol.rs::the_role_matrix_does_not_claim_what_is_not_built` proves the role matrix marks standard/extended/group channels and `Noise_NX` implemented while Job Declaration, work selection, Template Distribution, and extensions negotiation are `NotImplemented` with documented detail. `::job_declaration_and_template_distribution_are_not_served` proves both protocols are refused with `unsupported-protocol` and the connection closes; `::custom_jobs_are_answered_with_the_official_code_not_pretended` proves custom-job requests are answered `jd-not-supported`. `crates/node/tests/unit/lifecycle/tests.rs::final_payout_script_prefers_pool_when_values_differ` proves one payout-script owner shared by pool and template paths with the pool reward winning while configured. The in-crate `start_node`/`NodeState::open` harness exists and is now used for the `prepare` lock-lifetime proof (POOL-02) and the `NodeChainView` missing-index proof (POOL-07). Explicit blocker: no test starts pool listeners or shuts the pool down — that needs a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target and mining payout, free ports, plus a template-serving mining control for the initial template), and deadline-miss plus worker-panic surfacing have no injection seam. The prepare-then-RPC-bind-then-`start` sequencing itself remains structural in `crates/node/src/lifecycle.rs`. ## Evidence boundary and source map From 74eca7570d66a9073a590ece437ababfec66bb5f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:36:06 +0900 Subject: [PATCH 25/61] Deduplicate pool balance and Stratum encoding paths Share per-output balance mutation, canonical scriptSig limits, and primitive lowercase-hex encoding without changing ledger or wire behavior. --- crates/mining/src/coinbase.rs | 5 ++- crates/mining/src/template.rs | 8 ++-- crates/pool-ledger/src/store.rs | 72 +++++++++++++++++---------------- crates/stratum/src/job.rs | 14 ++++--- crates/stratum/src/sv1.rs | 13 ++---- 5 files changed, 56 insertions(+), 56 deletions(-) diff --git a/crates/mining/src/coinbase.rs b/crates/mining/src/coinbase.rs index 785f74b1a4..beb1bb41f6 100644 --- a/crates/mining/src/coinbase.rs +++ b/crates/mining/src/coinbase.rs @@ -4,7 +4,8 @@ use bitcoin_rs_primitives::{ use bitcoin_rs_script::push_int; use thiserror::Error; -const MIN_COINBASE_SCRIPT_SIG_LEN: usize = 2; +use crate::template::CoinbaseBudget; + /// BIP141 `OP_RETURN` `PUSH36` `aa21a9ed` prefix. Core `MINIMUM_WITNESS_COMMITMENT` is 38 bytes. const WITNESS_COMMITMENT_PREFIX: [u8; 6] = [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed]; @@ -140,7 +141,7 @@ fn coinbase_script_sig(height: u32) -> Vec { // Consensus rejects coinbase scriptSigs shorter than two bytes // (`bad-cb-length`). Heights whose BIP34 prefix is a single opcode need a // trailing OP_0, matching Bitcoin Core's `CreateNewBlock`. - if script.len() < MIN_COINBASE_SCRIPT_SIG_LEN { + if script.len() < CoinbaseBudget::MIN_SCRIPT_SIG_LEN { script.push(0x00); } script diff --git a/crates/mining/src/template.rs b/crates/mining/src/template.rs index 7f87f5dbb3..2d35352e7c 100644 --- a/crates/mining/src/template.rs +++ b/crates/mining/src/template.rs @@ -70,6 +70,10 @@ pub struct CoinbaseBudget { } impl CoinbaseBudget { + /// Consensus limit on a coinbase scriptSig length (`bad-cb-length`). + pub const MAX_SCRIPT_SIG_LEN: usize = 100; + /// Smallest scriptSig the node builds (`bad-cb-length` lower bound). + pub const MIN_SCRIPT_SIG_LEN: usize = 2; /// Splits a template identity into its ordinary part and the budget /// qualifier [`TemplateId::qualified`] appends, if any. /// @@ -574,8 +578,6 @@ fn reserve( }) } -/// Consensus limit on a coinbase scriptSig length (`bad-cb-length`). -const MAX_COINBASE_SCRIPT_SIG_LEN: usize = 100; /// Smallest serialized `TxOut`: an 8-byte value and a one-byte empty-script length. const MIN_TXOUT_SIZE: u32 = 9; @@ -596,7 +598,7 @@ fn coinbase_growth(coinbase: &Tx, budget: CoinbaseBudget) -> Result, batch: &PayoutBatch) -> Result<(), LedgerError> { - for output in &batch.outputs { +/// Applies `mutate` to the balance of every batch output, loading and storing +/// each account exactly once. The closure receives the loaded balance and the +/// output amount and performs the checked debit/credit; this keeps the +/// load-mutate-store loop in one place. +fn apply_to_outputs( + tables: &Tables<'_>, + outputs: &[PayoutOutput], + mut mutate: impl FnMut(&mut Balance, u64) -> Result<(), LedgerError>, +) -> Result<(), LedgerError> { + for output in outputs { let mut balance = load_balance(&tables.balances, output.account)?; - balance.reserved_sat = balance - .reserved_sat - .checked_sub(output.amount_sat) - .ok_or_else(|| { - LedgerError::Corrupt("confirming a payout exceeds the reservation".into()) - })?; - balance.paid_sat = balance - .paid_sat - .checked_add(output.amount_sat) - .ok_or(LedgerError::Overflow("paid balance"))?; + mutate(&mut balance, output.amount_sat)?; store_balance(&tables.balances, output.account, &balance)?; } Ok(()) } -fn unsettle_paid(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { - for output in &batch.outputs { - let mut balance = load_balance(&tables.balances, output.account)?; +fn settle_paid(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { + apply_to_outputs(tables, &batch.outputs, |balance, amount| { + balance.reserved_sat = balance.reserved_sat.checked_sub(amount).ok_or_else(|| { + LedgerError::Corrupt("confirming a payout exceeds the reservation".into()) + })?; balance.paid_sat = balance .paid_sat - .checked_sub(output.amount_sat) - .ok_or_else(|| { - LedgerError::Corrupt("reverting a payout exceeds the paid balance".into()) - })?; + .checked_add(amount) + .ok_or(LedgerError::Overflow("paid balance"))?; + Ok(()) + }) +} + +fn unsettle_paid(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { + apply_to_outputs(tables, &batch.outputs, |balance, amount| { + balance.paid_sat = balance.paid_sat.checked_sub(amount).ok_or_else(|| { + LedgerError::Corrupt("reverting a payout exceeds the paid balance".into()) + })?; balance.reserved_sat = balance .reserved_sat - .checked_add(output.amount_sat) + .checked_add(amount) .ok_or(LedgerError::Overflow("reserved balance"))?; - store_balance(&tables.balances, output.account, &balance)?; - } - Ok(()) + Ok(()) + }) } fn release_reservation(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), LedgerError> { - for output in &batch.outputs { - let mut balance = load_balance(&tables.balances, output.account)?; - balance.reserved_sat = balance - .reserved_sat - .checked_sub(output.amount_sat) - .ok_or_else(|| { - LedgerError::Corrupt("releasing a payout exceeds the reservation".into()) - })?; + apply_to_outputs(tables, &batch.outputs, |balance, amount| { + balance.reserved_sat = balance.reserved_sat.checked_sub(amount).ok_or_else(|| { + LedgerError::Corrupt("releasing a payout exceeds the reservation".into()) + })?; balance.available_sat = balance .available_sat - .checked_add(output.amount_sat) + .checked_add(amount) .ok_or(LedgerError::Overflow("available balance"))?; - store_balance(&tables.balances, output.account, &balance)?; - } - Ok(()) + Ok(()) + }) } fn next_rev(rev: u64) -> Result { diff --git a/crates/stratum/src/job.rs b/crates/stratum/src/job.rs index 9d8f4177c2..8ca2d8b92b 100644 --- a/crates/stratum/src/job.rs +++ b/crates/stratum/src/job.rs @@ -38,14 +38,14 @@ use ruint::Uint; use thiserror::Error; /// Consensus limit on a coinbase scriptSig length (`bad-cb-length`). -pub const MAX_COINBASE_SCRIPT_SIG_LEN: usize = 100; +/// +/// Canonical value is [`CoinbaseBudget::MAX_SCRIPT_SIG_LEN`]; this alias keeps +/// the existing `stratum::` path working. +pub const MAX_COINBASE_SCRIPT_SIG_LEN: usize = CoinbaseBudget::MAX_SCRIPT_SIG_LEN; /// BIP320 general-purpose version-rolling bits. pub const BIP320_VERSION_MASK: u32 = 0x1fff_e000; /// Core's `MAX_FUTURE_BLOCK_TIME`: the largest forward `ntime` window a job may grant. pub const MAX_NTIME_FORWARD_SECS: u32 = 7_200; - -/// Smallest scriptSig the node builds (`bad-cb-length` lower bound). -const MIN_COINBASE_SCRIPT_SIG_LEN: usize = 2; /// Offset of the scriptSig length byte in a one-input coinbase's txid serialization: /// version (4) + input count (1) + outpoint (36). const SCRIPT_SIG_LEN_OFFSET: usize = 41; @@ -814,7 +814,7 @@ impl Job { let available = payout.value.to_sat(); let base = input.script_sig.as_bytes(); let total = config.extranonce.total_len(); - let room = MAX_COINBASE_SCRIPT_SIG_LEN.saturating_sub(base.len()); + let room = CoinbaseBudget::MAX_SCRIPT_SIG_LEN.saturating_sub(base.len()); let needed = config.coinbase_tag.len().saturating_add(total); if needed > room { return Err(JobError::ExtranonceExceedsScriptSig { room, needed }); @@ -1231,7 +1231,9 @@ impl JobBookConfig { .coinbase_tag .len() .saturating_add(self.extranonce.total_len()); - if needed.saturating_add(MIN_COINBASE_SCRIPT_SIG_LEN) > MAX_COINBASE_SCRIPT_SIG_LEN { + if needed.saturating_add(CoinbaseBudget::MIN_SCRIPT_SIG_LEN) + > CoinbaseBudget::MAX_SCRIPT_SIG_LEN + { return Err(JobError::InvalidConfig( "coinbase tag and extranonce cannot fit under the scriptSig limit", )); diff --git a/crates/stratum/src/sv1.rs b/crates/stratum/src/sv1.rs index 0b295b8835..05c43c464a 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/stratum/src/sv1.rs @@ -31,6 +31,7 @@ use std::fmt; use crate::{AcceptedShare, BlockReceipt, FoundBlock, Job, JobError, JobId, ShareTarget}; use bitcoin_rs_mining::MiningControlError; use bitcoin_rs_primitives::Hash256; +use bitcoin_rs_primitives::encode::hex_encode as primitives_hex_encode; use sonic_rs::{Array, JsonContainerTrait, JsonValueTrait, Value}; pub use session::{ @@ -68,8 +69,6 @@ pub const MAX_DIFFICULTY_TEXT_LEN: usize = 20; // Hex and byte order // --------------------------------------------------------------------------- -const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; - fn nibble(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), @@ -81,19 +80,13 @@ fn nibble(byte: u8) -> Option { /// Appends the lowercase hex of `bytes` to `out`. pub fn push_hex(out: &mut String, bytes: &[u8]) { - out.reserve(bytes.len() * 2); - for &byte in bytes { - out.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); - out.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); - } + out.push_str(&primitives_hex_encode(bytes)); } /// Lowercase hex of `bytes`. #[must_use] pub fn encode_hex(bytes: &[u8]) -> String { - let mut out = String::with_capacity(bytes.len() * 2); - push_hex(&mut out, bytes); - out + primitives_hex_encode(bytes) } /// Decodes an even-length hex string of either case; `None` on any other byte. From 86a5800793ad12c2cd3483a824edf10f144488a4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:51:21 +0900 Subject: [PATCH 26/61] Close SV2 and payout validation review gaps Map permanent SV2 share conflicts to rejection, validate payout change scripts before encoding, and align architecture and embedding contracts with the final code. --- crates/node/src/pool/ledger.rs | 19 ++++-- crates/node/src/pool/sv2.rs | 13 ++-- crates/node/src/pool/tests.rs | 65 +++++++++++++++++-- crates/pool-ledger/src/lib.rs | 6 ++ .../pool-ledger/tests/support/ledger_cases.rs | 49 ++++++++++++++ docs/contracts/architecture.md | 4 +- docs/contracts/embedding.md | 3 +- 7 files changed, 141 insertions(+), 18 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index 8654c105aa..ec0d0e5cbc 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -588,13 +588,18 @@ impl LedgerHost { share: &AcceptedShare, ) -> Result { let credit = share_credit(self.run_epoch, account, share)?; - self.ledger - .record_share(&credit) - .map(|receipt| match receipt { + match self.ledger.record_share(&credit) { + Ok(receipt) => Ok(match receipt { ShareReceipt::Credited { .. } => RecordOutcome::Recorded, ShareReceipt::Duplicate { .. } => RecordOutcome::AlreadyRecorded, - }) - .map_err(|_| LedgerUnavailable) + }), + // Permanent conflicts are uncurable: report a rejected share with + // no retry, never an unavailable ledger. This mirrors the SV1 + // `ShareLedger` mapping below; every other ledger error stays a + // transient `LedgerUnavailable`. + Err(error) if error.is_permanent_share_conflict() => Ok(RecordOutcome::Rejected), + Err(_) => Err(LedgerUnavailable), + } } /// Records a found block after any submission result. @@ -687,8 +692,8 @@ impl AccountResolver for LedgerHost { /// SV1 settlement through the shared ledger. These two methods mirror /// [`LedgerHost::record_accepted_share`] and -/// [`LedgerHost::record_submitted_block`] (which keep the SV2 contract), with -/// one addition: permanent ledger conflicts surface here instead of +/// [`LedgerHost::record_submitted_block`] (which keep the SV2 contract): +/// permanent ledger conflicts surface as settled outcomes instead of /// collapsing into [`LedgerUnavailable`]. impl ShareLedger for LedgerHost { fn record_share( diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 00bdb3ebaf..165404e18e 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; -use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore}; +use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore, RecordOutcome}; use bitcoin_rs_stratum_v2::{ AdmitError, AuthorityKeypair, Connection, ERROR_CODE_SHARE_NOT_RECORDED, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, ServerConfig, @@ -347,13 +347,18 @@ fn handle_events( accounts.remove(&channel_id); } Event::ShareAccepted(event) => { + // Only credited shares confirm: a permanent ledger conflict + // (`RecordOutcome::Rejected`) rejects like any unavailable + // ledger, keeping the seam's rejected-vs-unavailable + // distinction while the wire keeps refusing the share. let recorded = accounts .get(&event.channel_id) .copied() .is_some_and(|account| { - ledger - .record_accepted_share(account, &event.outcome) - .is_ok() + matches!( + ledger.record_accepted_share(account, &event.outcome), + Ok(RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded) + ) }); if recorded { connection.confirm_share(event.ticket)?; diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index d65517b34a..cb457f36df 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -7,8 +7,8 @@ use bitcoin_rs_primitives::{Block, BlockHash, Hash256, Header, Txid}; use bitcoin_rs_rpc::context::PoolOperator; use bitcoin_rs_storage::KvStore; use bitcoin_rs_stratum::{ - AcceptedShare, AccountResolver, BlockReceipt, FoundBlock, JobId, Password, RecordOutcome, - SessionId, ShareClass, ShareLedger, ShareTarget, WorkerIdentity, + AcceptedShare, AccountResolver, BlockReceipt, FoundBlock, JobId, LedgerUnavailable, Password, + RecordOutcome, SessionId, ShareClass, ShareLedger, ShareTarget, WorkerIdentity, }; use pool_ledger::{ Balance, Ledger, MinerId, PayoutPolicy, PoolConfig, RoundState, ShareCredit, ShareReceipt, @@ -256,6 +256,55 @@ fn rejected_block_receipt_stays_pending(open: OpenStore) -> TestResult { Ok(()) } +fn sv2_permanent_share_conflict_is_rejected(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let host = registered_host(open, directory.path(), EPOCH)?; + let account = MinerId(42); + let first = share()?; + assert_eq!( + host.record_accepted_share(account, &first) + .unwrap_or_else(|_| panic!("first SV2 share record unavailable")), + RecordOutcome::Recorded + ); + // Same header hash with a different nonce: an uncurable identity conflict, + // never an unavailable ledger. + let mut same_hash = first.clone(); + same_hash.header.nonce = same_hash.header.nonce.wrapping_add(1); + assert_eq!( + host.record_accepted_share(account, &same_hash) + .unwrap_or_else(|error| panic!("share conflict surfaced as unavailable: {error:?}")), + RecordOutcome::Rejected + ); + // Same identity with a different header hash collides the other way. + let mut same_identity = first.clone(); + same_identity.hash = BlockHash::from(Hash256::from_le_bytes(&[9; 32])); + assert_eq!( + host.record_accepted_share(account, &same_identity) + .unwrap_or_else(|error| panic!("identity conflict surfaced as unavailable: {error:?}")), + RecordOutcome::Rejected + ); + assert_eq!(host.ledger().share_count()?, 1); + Ok(()) +} + +fn sv2_non_conflict_ledger_error_is_unavailable(open: OpenStore) -> TestResult { + let directory = tempfile::tempdir()?; + let host = registered_host(open, directory.path(), EPOCH)?; + // Oversized extranonce fails ledger validation: not a permanent share + // conflict, so the SV2 seam must keep the infrastructure error. + let mut bad = share()?; + bad.extranonce = vec![0u8; pool_ledger::MAX_EXTRANONCE_LEN + 1]; + assert!( + matches!( + host.record_accepted_share(MinerId(42), &bad), + Err(LedgerUnavailable) + ), + "non-conflict ledger refusal must stay unavailable" + ); + assert_eq!(host.ledger().share_count()?, 0); + Ok(()) +} + fn block_record_reservations_are_bounded_and_released(open: OpenStore) -> TestResult { let directory = tempfile::tempdir()?; let host = Arc::new(registered_host(open, directory.path(), EPOCH)?); @@ -371,6 +420,16 @@ macro_rules! backend_tests { restart_epoch_separates_reused_wire_job(open) } + #[test] + fn sv2_permanent_share_conflict_reports_rejected() -> TestResult { + sv2_permanent_share_conflict_is_rejected(open) + } + + #[test] + fn sv2_non_conflict_ledger_error_stays_unavailable() -> TestResult { + sv2_non_conflict_ledger_error_is_unavailable(open) + } + #[test] fn rejected_block_receipt_persists_pending_round_without_funds() -> TestResult { rejected_block_receipt_stays_pending(open) @@ -497,8 +556,6 @@ fn pool_prepare_create_open_and_epoch_run_under_the_directory_lock_fjall() -> Te #[test] fn node_chain_view_reports_missing_index_observer_errors() -> TestResult { - use pool_ledger::ChainView as _; - let directory = tempfile::tempdir()?; let mut config = crate::config::NodeConfig::default_for_network(crate::Network::Regtest); config.data_dir = directory.path().to_owned(); diff --git a/crates/pool-ledger/src/lib.rs b/crates/pool-ledger/src/lib.rs index ca69146910..fc9462699e 100644 --- a/crates/pool-ledger/src/lib.rs +++ b/crates/pool-ledger/src/lib.rs @@ -941,6 +941,12 @@ impl PayoutRequest { "request total does not match outputs", )); } + if self.allowed_change_scripts.len() > MAX_CHANGE_SCRIPTS { + return Err(LedgerError::InvalidInput("too many change scripts")); + } + for script in &self.allowed_change_scripts { + validate_script(script)?; + } let mut enc = codec::Enc::new(); enc.raw(REQUEST_MAGIC); enc.u8(WIRE_VERSION); diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/pool-ledger/tests/support/ledger_cases.rs index 91913afac1..e129e51f14 100644 --- a/crates/pool-ledger/tests/support/ledger_cases.rs +++ b/crates/pool-ledger/tests/support/ledger_cases.rs @@ -867,6 +867,55 @@ fn payout_request_and_receipt_round_trip_and_reject_corruption() { )); } +#[test] +fn payout_request_encode_rejects_invalid_change_scripts_before_emitting_bytes() { + fn request(change: Vec>) -> PayoutRequest { + PayoutRequest { + batch_id: hash(0xAB), + outputs: vec![PayoutOutput { + account: MinerId(1), + script_pubkey: account_script(1), + amount_sat: 1_000, + }], + total_sat: 1_000, + allowed_change_scripts: change, + } + } + + // Valid scripts (and the empty allow-list decode accepts) round-trip. + let valid = request(vec![change_script(), script(0xD0)]); + let encoded = valid.encode().fixture("encode valid change scripts"); + assert_eq!(PayoutRequest::decode(&encoded).fixture("decode"), valid); + let empty_list = request(Vec::new()); + let encoded_empty = empty_list.encode().fixture("encode empty change list"); + assert_eq!( + PayoutRequest::decode(&encoded_empty).fixture("decode empty change list"), + empty_list + ); + + // Over-count. + let too_many = request(vec![change_script(); MAX_CHANGE_SCRIPTS + 1]); + assert!(matches!( + too_many.encode(), + Err(LedgerError::InvalidInput(_)) + )); + // Empty entry. + assert!(matches!( + request(vec![Vec::new()]).encode(), + Err(LedgerError::InvalidInput(_)) + )); + // Oversized entry. + assert!(matches!( + request(vec![vec![0x51; MAX_SCRIPT_LEN + 1]]).encode(), + Err(LedgerError::InvalidInput(_)) + )); + // OP_RETURN-leading entry. + assert!(matches!( + request(vec![vec![0x6a, 0x01]]).encode(), + Err(LedgerError::InvalidInput(_)) + )); +} + // --------------------------------------------------------------------------------------------- // Lifecycle, configuration, storage // --------------------------------------------------------------------------------------------- diff --git a/docs/contracts/architecture.md b/docs/contracts/architecture.md index ccf91c5910..ccd5bd7b7d 100644 --- a/docs/contracts/architecture.md +++ b/docs/contracts/architecture.md @@ -155,8 +155,8 @@ Crate names use the `bitcoin-rs-` prefix except for the `bitcoin-rs` binary. `TxUnavailableCause::PrefixScanTruncated` or `LivePrefixScanTruncated` instead of returning a partial answer. The index capability reset (`crates/index/src/index/state.rs`) calls `KvStore::scan_prefix_bounded` - under `RESET_SCAN_LIMIT` and deletes one page at a time. No production - code calls `scan_prefix_from_bounded`; storage tests do. + under `RESET_SCAN_LIMIT` and deletes one page at a time. No production code + calls `scan_prefix_from_bounded`; storage tests cover it. - Proof: `crates/storage/tests/bounded_prefix_scan.rs` tests `fjall_bounded_prefix_scan`, `rocksdb_bounded_prefix_scan`, and `redb_bounded_prefix_scan` (feature-gated; limit semantics, prefix diff --git a/docs/contracts/embedding.md b/docs/contracts/embedding.md index b6a9e8d784..8f0e60e411 100644 --- a/docs/contracts/embedding.md +++ b/docs/contracts/embedding.md @@ -258,7 +258,8 @@ is already public and nothing here is re-exported. `Txid`, `Tx`, and ## Error behavior -Errors at the typed lifecycle, readiness, mining, and broadcast boundary are +Errors at the typed lifecycle, readiness, typed reads (`tx_by_id` and +`block_by_hash`, EMB-06), mining, and broadcast boundary are `NodeError`: `Startup` (configuration, storage, recovery, or service-bind failure, with rollback as described above), `Shutdown` (join or checkpoint failure, reported only by consuming shutdown, never by Drop), `Unavailable` From 6e3be6c3f7ae548c482cf096a2f58ff0c25ec275 Mon Sep 17 00:00:00 2001 From: "gormes-agent[bot]" Date: Wed, 28 Oct 2026 04:44:39 +0000 Subject: [PATCH 27/61] Decode share hash entries up to the full tuple length --- crates/pool-ledger/src/lib.rs | 6 ++++- crates/pool-ledger/src/store.rs | 4 +-- .../pool-ledger/tests/support/ledger_cases.rs | 26 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/pool-ledger/src/lib.rs b/crates/pool-ledger/src/lib.rs index fc9462699e..33275bc384 100644 --- a/crates/pool-ledger/src/lib.rs +++ b/crates/pool-ledger/src/lib.rs @@ -36,6 +36,10 @@ pub type Hash32 = [u8; 32]; pub const MAX_MONEY_SAT: u64 = 2_100_000_000_000_000; /// Longest accepted extranonce (`session || rolled`), in bytes. pub const MAX_EXTRANONCE_LEN: usize = 64; +/// Fixed-width prefix of [`ShareCredit::tuple_key`]: job, account, nonce, version, ntime. +const SHARE_TUPLE_FIXED_LEN: usize = 8 + 8 + 4 + 4 + 4; +/// Longest [`ShareCredit::tuple_key`] a valid share can produce. +pub(crate) const MAX_SHARE_TUPLE_LEN: usize = SHARE_TUPLE_FIXED_LEN + MAX_EXTRANONCE_LEN; /// Longest accepted script, in bytes (the consensus script size limit). pub const MAX_SCRIPT_LEN: usize = 10_000; /// Largest raw transaction accepted from a signer, in bytes. @@ -134,7 +138,7 @@ impl ShareCredit { /// only in version or ntime are distinct shares, never a conflicting reuse. #[must_use] pub fn tuple_key(&self) -> Vec { - let mut key = Vec::with_capacity(28 + self.extranonce.len()); + let mut key = Vec::with_capacity(SHARE_TUPLE_FIXED_LEN + self.extranonce.len()); key.extend_from_slice(&self.job.to_be_bytes()); key.extend_from_slice(&self.account.0.to_be_bytes()); key.extend_from_slice(&self.nonce.to_be_bytes()); diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs index 24f4204904..de822beb14 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/pool-ledger/src/store.rs @@ -22,7 +22,7 @@ use crate::codec::{Dec, Enc}; use crate::{ AuditKind, AuditRecord, Balance, BatchState, BatchTransition, BlockFound, BlockRecorded, CancelBasis, ChainView, Closure, Confirmation, ExternalSigner, Hash32, LedgerError, - MAX_BATCH_OUTPUTS, MAX_EXTRANONCE_LEN, MAX_MONEY_SAT, MAX_SCRIPT_LEN, MinerId, + MAX_BATCH_OUTPUTS, MAX_MONEY_SAT, MAX_SCRIPT_LEN, MAX_SHARE_TUPLE_LEN, MinerId, ObservedCoinbase, OutPoint, PayoutBatch, PayoutOutput, PayoutPolicy, PayoutReceipt, PayoutRequest, PoolConfig, ReceiptError, Round, RoundState, RoundTransition, ShareCredit, ShareReceipt, SignedTx, TxStatus, WindowAccumulator, compute_batch_id, parse_transaction, @@ -2322,7 +2322,7 @@ fn decode_hash_entry(bytes: &[u8]) -> Result<(u64, u128, Vec), LedgerError> let mut dec = Dec::new(bytes); let seq = dec.u64()?; let work = dec.u128()?; - let tuple = dec.bytes(MAX_EXTRANONCE_LEN + 20)?.to_vec(); + let tuple = dec.bytes(MAX_SHARE_TUPLE_LEN)?.to_vec(); dec.finish()?; Ok((seq, work, tuple)) } diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/pool-ledger/tests/support/ledger_cases.rs index e129e51f14..20886d014f 100644 --- a/crates/pool-ledger/tests/support/ledger_cases.rs +++ b/crates/pool-ledger/tests/support/ledger_cases.rs @@ -1055,6 +1055,32 @@ fn shares_are_idempotent_by_header_hash_and_unique_by_identity() { ledger.check_invariants().fixture("invariants"); } +#[test] +fn maximum_length_extranonce_shares_stay_readable() { + let (_dir, ledger) = plain_ledger(100); + let mut longest = share(1, 1, 1, 10); + longest.extranonce = vec![0xAB; MAX_EXTRANONCE_LEN]; + assert_eq!( + ledger.record_share(&longest).fixture("credit"), + ShareReceipt::Credited { seq: 0 } + ); + assert_eq!( + ledger.share_by_hash(&longest.header_hash).fixture("lookup"), + Some((0, 10)) + ); + assert_eq!( + ledger.record_share(&longest).fixture("replay"), + ShareReceipt::Duplicate { seq: 0 } + ); + let mut same_identity = longest.clone(); + same_identity.header_hash = hash(0xEE); + assert!(matches!( + ledger.record_share(&same_identity).err(), + Some(LedgerError::ShareIdentityConflict { seq: 0 }) + )); + ledger.check_invariants().fixture("invariants"); +} + #[test] fn window_boundaries_are_exact_in_work_units() { for (window, expected) in [ From 5f3c4cf78070593654eb09ace4d97fb84da2009a Mon Sep 17 00:00:00 2001 From: "gormes-agent[bot]" Date: Wed, 28 Oct 2026 05:10:17 +0000 Subject: [PATCH 28/61] Repay reward deficits before releasing payout reservations --- crates/pool-ledger/src/store.rs | 6 +++- .../pool-ledger/tests/support/ledger_cases.rs | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs index de822beb14..644ad8b5fa 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/pool-ledger/src/store.rs @@ -2180,9 +2180,13 @@ fn release_reservation(tables: &Tables<'_>, batch: &PayoutBatch) -> Result<(), L balance.reserved_sat = balance.reserved_sat.checked_sub(amount).ok_or_else(|| { LedgerError::Corrupt("releasing a payout exceeds the reservation".into()) })?; + // Released money first repays any deficit left by a reversed reward, so a reorged + // reward can never become payable again through a cancelled or failed batch. + let offset = balance.deficit_sat.min(amount); + balance.deficit_sat -= offset; balance.available_sat = balance .available_sat - .checked_add(amount) + .checked_add(amount - offset) .ok_or(LedgerError::Overflow("available balance"))?; Ok(()) }) diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/pool-ledger/tests/support/ledger_cases.rs index 20886d014f..b3fcf46694 100644 --- a/crates/pool-ledger/tests/support/ledger_cases.rs +++ b/crates/pool-ledger/tests/support/ledger_cases.rs @@ -1567,6 +1567,36 @@ fn reversing_credits_that_were_already_reserved_creates_an_explicit_deficit() { fund.ledger.check_invariants().fixture("invariants"); } +#[test] +fn cancelling_a_batch_of_reversed_credits_repays_the_deficit_first() { + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + fund.chain.set_block(100, hash(0xDD)); + fund.ledger + .reconcile_rounds(&fund.chain) + .fixture("displaced"); + fund.ledger + .cancel_prepared(&batch.id, CancelBasis::NeverHandedToSigner) + .fixture("cancel"); + let balance = fund.ledger.balance(MinerId(1)).fixture("balance"); + assert_eq!( + ( + balance.available_sat, + balance.reserved_sat, + balance.deficit_sat + ), + (0, 0, 0), + "a reversed reward never becomes payable again" + ); + assert_eq!( + fund.ledger + .prepare_payout_batch(idem(2), policy()) + .fixture("prepare again"), + None + ); + fund.ledger.check_invariants().fixture("invariants"); +} + #[test] fn unseen_rounds_stop_being_monitored_after_the_watch_depth() { let fund = staged(0, 1_000_001); From 7aac79b3d396d5aa34d56d8a4c68bdae19e96862 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:30:38 +0900 Subject: [PATCH 29/61] Keep SV2 success deferred and walk reward windows by seq Handle an exhausted block-record permit without dropping buffered shares, hold SV2 success until a queued record is durable, and walk reward shares newest-first with point lookups instead of materializing the full history. The remote branch already fixes the deficit release and decode bound. --- crates/node/src/pool/ledger.rs | 7 +++- crates/node/src/pool/sv2.rs | 57 +++++++++++++++++++++++++++------ crates/pool-ledger/src/store.rs | 26 +++++++++++---- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index ec0d0e5cbc..5af832b3d1 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -317,7 +317,12 @@ struct PendingBlockRecords { pub(crate) enum BlockRecordOutcome { /// The found-block round is durably visible and protocol success may be sent. Durable, - /// The host retained the block for retry; the socket must not send success. + /// The host retained the block for retry with its reservation held. The + /// socket holds `SubmitShares.Success` until the reconcile drain makes + /// the round durable, mirroring the SV1 completion: delivery is + /// at-least-once (inline plus reconcile drain) against the idempotent + /// ledger consumer keyed on the block hash, since exactly-once delivery + /// does not exist. Queued, /// The block cannot be represented by the ledger; close without success. Unrecordable, diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 165404e18e..6e5823845d 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -8,9 +8,10 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; +use bitcoin_rs_primitives::BlockHash; use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore, RecordOutcome}; use bitcoin_rs_stratum_v2::{ - AdmitError, AuthorityKeypair, Connection, ERROR_CODE_SHARE_NOT_RECORDED, + AdmitError, AuthorityKeypair, BlockSubmission, Connection, ERROR_CODE_SHARE_NOT_RECORDED, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, ServerConfig, }; use pool_ledger::MinerId; @@ -224,6 +225,11 @@ fn serve_connection( let mut accounts = HashMap::new(); let mut input = vec![0; MAX_FEED_LEN]; let mut buffered_input = None; + // Success acknowledgements held until a retained block record is durable. + // Each entry holds one consumed permit's queue slot, so the + // `MAX_PENDING_BLOCK_RECORDS` bound caps this queue; the reconcile worker + // normally drains entries within one 250ms pass. + let mut deferred: Vec<(BlockHash, BlockSubmission)> = Vec::new(); let mut transmit = PendingTransmit::default(); let start = Instant::now(); let mut last_progress = start; @@ -269,11 +275,12 @@ fn serve_connection( } } - let Some(block_permit) = ledger.try_reserve_block_slot() else { - thread::sleep(SOCKET_POLL_INTERVAL); - continue; - }; - let mut block_permit = Some(block_permit); + // A block-record reservation is only consumed by a `BlockFound` + // event. When the pending-block queue is full there is no permit, + // but shares and channel messages on the buffered frame must still + // be handled; the `BlockFound` arm errors if its event actually + // needs a missing permit. + let mut block_permit = ledger.try_reserve_block_slot(); let length = buffered_input .take() .ok_or_else(|| anyhow!("SV2 buffered input length is missing"))?; @@ -286,7 +293,17 @@ fn serve_connection( control, &mut accounts, &mut block_permit, + &mut deferred, )?; + // Confirm blocks whose retained records became durable while this + // feed was handled. A closed channel settles its entry; a session + // fault is retried next iteration and surfaces on socket progress. + deferred.retain(|(hash, submission)| { + if !matches!(ledger.found_block_recorded(*hash), Ok(true)) { + return true; + } + connection.confirm_block(submission).is_err() + }); // Any consumed event or newly queued reply is progress: an idle // miner that keeps submitting (or being announced to) never trips // the idle deadline on a quiet socket alone. @@ -329,6 +346,7 @@ fn handle_events( control: &dyn MiningControl, accounts: &mut HashMap, block_permit: &mut Option, + deferred: &mut Vec<(BlockHash, BlockSubmission)>, ) -> Result<()> { while let Some(event) = connection.poll_event() { match event { @@ -386,12 +404,32 @@ fn handle_events( .as_ref() .map(|submitted| submitted.receipt().clone()) .map_err(Clone::clone); + // A queued record is retained with its reservation and the + // reconcile worker drains it about every 250ms. Hold + // `SubmitShares.Success` until the round is durable — the + // session contract and SV1 both withhold success until then — + // but keep the connection open while the ledger catches up + // instead of closing it under load. Only success is deferred; + // node rejections answer immediately below. match permit.record_or_enqueue(account, found, &receipt) { BlockRecordOutcome::Durable => {} BlockRecordOutcome::Queued => { - return Err(anyhow!( - "SV2 block record queued for durable reconciliation" - )); + if matches!( + &submission, + Ok(submitted) + if matches!( + submitted.receipt().status(), + BlockStatus::Applied | BlockStatus::Undetermined + ) + ) { + // The guard above ensures `submission` is `Ok`; + // a mismatch falls through to the verdict below. + let Ok(submitted) = submission else { + return Err(anyhow!("SV2 block lost its submission")); + }; + deferred.push((found.share.hash, submitted)); + continue; + } } BlockRecordOutcome::Unrecordable => { return Err(anyhow!( @@ -999,6 +1037,7 @@ mod tests { self.control.as_ref(), &mut self.accounts, &mut permit, + &mut Vec::new(), )?; let sent = self.connection.take_transmit(); Ok(self.miner.open(&sent)?) diff --git a/crates/pool-ledger/src/store.rs b/crates/pool-ledger/src/store.rs index 644ad8b5fa..e2657f72cb 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/pool-ledger/src/store.rs @@ -1626,13 +1626,27 @@ fn collect_window( target: u128, ) -> Result { let mut window = WindowAccumulator::new(target); - for item in shares.range(..=end_seq)?.rev() { - let (_, value) = item?; - let (account, work) = decode_share(value.value())?; - window.push_older(account, work)?; - if window.is_full() { - return Ok(window); + // Newest-first point lookups stop once the window fills. `range(..=end_seq)` + // materializes the whole share history before `.rev()` can stop early, so a + // busy pool pays full-history cost on every found block. Share seqs are + // contiguous (`bump` from zero) with only a pruned prefix removed, so this + // countdown covers every live row; gaps, if any, are skipped. + let mut seq = end_seq; + loop { + if seq < pruned_below { + break; + } + if let Some(guard) = shares.get(seq)? { + let (account, work) = decode_share(guard.value())?; + window.push_older(account, work)?; + if window.is_full() { + return Ok(window); + } + } + if seq == pruned_below { + break; } + seq -= 1; } if pruned_below > 0 { return Err(LedgerError::WindowPruned); From 5804089e406ca9c6278c0a5b271749adacef4a74 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:20:20 +0900 Subject: [PATCH 30/61] Keep SV2 block permits and drain deferred replies Drain durable block acknowledgements before reading the socket so a silent miner receives SubmitShares.Success without another frame. Wait for a block-record permit before feeding a solved block instead of dropping the event when the queue is full. --- crates/node/src/pool/sv2.rs | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 6e5823845d..2b84fa20c9 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -244,6 +244,19 @@ fn serve_connection( if last_progress.elapsed() >= IDLE_TIMEOUT { return Err(anyhow!("SV2 connection idle timed out")); } + // Confirm blocks whose retained records became durable even when + // the miner sends no more bytes; a silent socket must not delay + // `SubmitShares.Success` until the next frame. + let deferred_before = deferred.len(); + deferred.retain(|(hash, submission)| { + if !matches!(ledger.found_block_recorded(*hash), Ok(true)) { + return true; + } + connection.confirm_block(submission).is_err() + }); + if deferred.len() != deferred_before { + last_progress = Instant::now(); + } announce_current_job(&mut connection, core)?; transmit.fill(&mut connection); let pending_bytes = transmit.remaining(); @@ -252,6 +265,15 @@ fn serve_connection( last_progress = Instant::now(); } + // A block-record reservation is only consumed by a `BlockFound` + // event. Wait for capacity instead of feeding a solved block with + // no permit: dropping the event would close the connection without + // submitting or retaining the block. + let Some(block_permit) = ledger.try_reserve_block_slot() else { + thread::sleep(SOCKET_POLL_INTERVAL); + continue; + }; + let mut block_permit = Some(block_permit); if buffered_input.is_none() { let read_len = connection.read_hint().clamp(1, MAX_FEED_LEN); match stream.read(&mut input[..read_len]) { @@ -275,12 +297,6 @@ fn serve_connection( } } - // A block-record reservation is only consumed by a `BlockFound` - // event. When the pending-block queue is full there is no permit, - // but shares and channel messages on the buffered frame must still - // be handled; the `BlockFound` arm errors if its event actually - // needs a missing permit. - let mut block_permit = ledger.try_reserve_block_slot(); let length = buffered_input .take() .ok_or_else(|| anyhow!("SV2 buffered input length is missing"))?; @@ -295,15 +311,6 @@ fn serve_connection( &mut block_permit, &mut deferred, )?; - // Confirm blocks whose retained records became durable while this - // feed was handled. A closed channel settles its entry; a session - // fault is retried next iteration and surfaces on socket progress. - deferred.retain(|(hash, submission)| { - if !matches!(ledger.found_block_recorded(*hash), Ok(true)) { - return true; - } - connection.confirm_block(submission).is_err() - }); // Any consumed event or newly queued reply is progress: an idle // miner that keeps submitting (or being announced to) never trips // the idle deadline on a quiet socket alone. From bd238acc02104994662d6f2207626bc2a0d44a46 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:05:58 +0900 Subject: [PATCH 31/61] Reserve SV2 block permits after buffering a frame A quiet socket must not hold one of the shared block-record slots while it sleeps. Read and buffer first, then reserve capacity before feeding the frame, so a solved block is never dropped and idle connections cannot exhaust the queue. --- crates/node/src/pool/sv2.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 2b84fa20c9..af272ccdd6 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -265,15 +265,6 @@ fn serve_connection( last_progress = Instant::now(); } - // A block-record reservation is only consumed by a `BlockFound` - // event. Wait for capacity instead of feeding a solved block with - // no permit: dropping the event would close the connection without - // submitting or retaining the block. - let Some(block_permit) = ledger.try_reserve_block_slot() else { - thread::sleep(SOCKET_POLL_INTERVAL); - continue; - }; - let mut block_permit = Some(block_permit); if buffered_input.is_none() { let read_len = connection.read_hint().clamp(1, MAX_FEED_LEN); match stream.read(&mut input[..read_len]) { @@ -297,6 +288,15 @@ fn serve_connection( } } + // Reserve only after a frame is buffered. A quiet socket must not + // hold one of the bounded block-record slots while it sleeps, and + // a solved block must not be fed without a reservation: dropping + // either would strand capacity or lose the block. + let Some(block_permit) = ledger.try_reserve_block_slot() else { + thread::sleep(SOCKET_POLL_INTERVAL); + continue; + }; + let mut block_permit = Some(block_permit); let length = buffered_input .take() .ok_or_else(|| anyhow!("SV2 buffered input length is missing"))?; From a18d6ed49f5b7c319d2f6fa4d662d058e41fb7a3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:43:09 +0900 Subject: [PATCH 32/61] Adapt merged sync tests to branch ownership API Main compact-sync tests called record_owned_body_fetch, which the branch replaced with enqueue_owned_body_fetch. The NotFoundFixture fields also kept SocketAddr after current_source and test_addr moved to PeerRemote. - Add the missing SocketAddr import. - Store owner and alternate as PeerRemote. - Mark the owned fetch through enqueue_owned_body_fetch. --- crates/p2p/src/sync/tests.rs | 5 +++-- crates/p2p/src/sync/tests/head_sync.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/p2p/src/sync/tests.rs b/crates/p2p/src/sync/tests.rs index c2ff9f0d99..2dc1dc5921 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -2,6 +2,7 @@ use crate::PeerRemote; pub(crate) use crate::test_support::{OrFail, TestResult}; use std::net::IpAddr; use std::net::Ipv4Addr; +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -2848,9 +2849,9 @@ struct NotFoundFixture { applied_tip: Arc>, blocks: Vec, blocks_tx: InboundBlockSender, - owner: SocketAddr, + owner: PeerRemote, owner_rx: crate::test_support::MessageReceiver, - alternate: SocketAddr, + alternate: PeerRemote, alternate_rx: Option, } diff --git a/crates/p2p/src/sync/tests/head_sync.rs b/crates/p2p/src/sync/tests/head_sync.rs index e02888e5e1..7528dea3c3 100644 --- a/crates/p2p/src/sync/tests/head_sync.rs +++ b/crates/p2p/src/sync/tests/head_sync.rs @@ -1457,7 +1457,7 @@ fn compact_reconstruction_admission_stays_near_the_applied_tip() -> TestResult { !sync.compact_reconstruction_admissible(source, &blocks[5].header), "five blocks above the applied tip" ); - sync.record_owned_body_fetch(source, Hash256::from(blocks[3].block_hash())); + assert!(sync.enqueue_owned_body_fetch(source, Hash256::from(blocks[3].block_hash()), || true)); assert!( sync.compact_reconstruction_admissible(source, &blocks[3].header), "a fetch this connection owns" From 6f63d9662cef50e4ed0f8ac609038a51c0106a54 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:45:06 +0900 Subject: [PATCH 33/61] Update lockfile after main integration --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 939b07172e..db24f43b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,7 +1829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] From 48b895dd1bbe2437aaed9f6440cd69dbe0b6aeb8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:29:32 +0900 Subject: [PATCH 34/61] refactor(mining): consolidate pool crates under mining::pool Move crates/stratum, crates/pool-ledger, and crates/stratum-v2 into crates/mining/src/pool as job, core, ledger, and stratum/{sv1,sv2} modules behind the pool and pool-sv2 features. Extract the shared PoolCore from the SV1 session into pool::core, preserving the branch's newer SV2 permit ordering, payout/duplicate handling, and mining tests. Update every workspace member, node/rpc/bin dependency, import, test target, README, and docs path; delete the three satellite crates. --- Cargo.lock | 72 +- Cargo.toml | 6 - .../tests/support/dependency_graph.rs | 12 +- crates/mining/Cargo.toml | 39 + crates/mining/README.md | 10 + crates/mining/src/lib.rs | 4 + crates/mining/src/pool.rs | 21 + crates/mining/src/pool/core.rs | 795 ++++++++++++++++++ .../{stratum/src => mining/src/pool}/job.rs | 2 +- .../src/lib.rs => mining/src/pool/ledger.rs} | 0 .../src => mining/src/pool/ledger}/kv.rs | 4 +- .../src => mining/src/pool/ledger}/store.rs | 22 +- crates/mining/src/pool/stratum.rs | 4 + .../src => mining/src/pool/stratum}/sv1.rs | 64 +- .../src/pool/stratum}/sv1/session.rs | 749 +---------------- .../lib.rs => mining/src/pool/stratum/sv2.rs} | 4 +- .../src/pool/stratum/sv2}/session.rs | 16 +- .../jobs.rs => mining/tests/pool_jobs.rs} | 2 +- .../ledger.rs => mining/tests/pool_ledger.rs} | 4 +- .../protocol.rs => mining/tests/pool_sv1.rs} | 25 +- .../protocol.rs => mining/tests/pool_sv2.rs} | 12 +- .../tests/support/kv_transactions.rs | 0 .../tests/support/pool_ledger_cases.rs} | 2 +- crates/node/Cargo.toml | 27 +- crates/node/src/config.rs | 8 +- crates/node/src/pool.rs | 15 +- crates/node/src/pool/authority.rs | 2 +- crates/node/src/pool/ledger.rs | 12 +- crates/node/src/pool/sv2.rs | 20 +- crates/node/src/pool/tests.rs | 26 +- crates/node/tests/unit/config/tests.rs | 2 +- crates/node/tests/unit/lifecycle/tests.rs | 2 +- crates/pool-ledger/Cargo.toml | 32 - crates/rpc/Cargo.toml | 3 +- crates/rpc/src/context.rs | 2 + crates/rpc/src/handlers/pool.rs | 3 +- crates/stratum-v2/Cargo.toml | 51 -- crates/stratum/Cargo.toml | 32 - crates/stratum/src/lib.rs | 34 - docs/contracts/pool.md | 34 +- docs/pool.md | 4 +- 41 files changed, 1037 insertions(+), 1141 deletions(-) create mode 100644 crates/mining/src/pool.rs create mode 100644 crates/mining/src/pool/core.rs rename crates/{stratum/src => mining/src/pool}/job.rs (99%) rename crates/{pool-ledger/src/lib.rs => mining/src/pool/ledger.rs} (100%) rename crates/{pool-ledger/src => mining/src/pool/ledger}/kv.rs (99%) rename crates/{pool-ledger/src => mining/src/pool/ledger}/store.rs (99%) create mode 100644 crates/mining/src/pool/stratum.rs rename crates/{stratum/src => mining/src/pool/stratum}/sv1.rs (94%) rename crates/{stratum/src => mining/src/pool/stratum}/sv1/session.rs (65%) rename crates/{stratum-v2/src/lib.rs => mining/src/pool/stratum/sv2.rs} (99%) rename crates/{stratum-v2/src => mining/src/pool/stratum/sv2}/session.rs (99%) rename crates/{stratum/tests/jobs.rs => mining/tests/pool_jobs.rs} (99%) rename crates/{pool-ledger/tests/ledger.rs => mining/tests/pool_ledger.rs} (65%) rename crates/{stratum/tests/protocol.rs => mining/tests/pool_sv1.rs} (98%) rename crates/{stratum-v2/tests/protocol.rs => mining/tests/pool_sv2.rs} (99%) rename crates/{pool-ledger => mining}/tests/support/kv_transactions.rs (100%) rename crates/{pool-ledger/tests/support/ledger_cases.rs => mining/tests/support/pool_ledger_cases.rs} (99%) delete mode 100644 crates/pool-ledger/Cargo.toml delete mode 100644 crates/stratum-v2/Cargo.toml delete mode 100644 crates/stratum/Cargo.toml delete mode 100644 crates/stratum/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index db24f43b6f..e330b4a8c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,20 +428,35 @@ dependencies = [ name = "bitcoin-rs-mining" version = "0.14.0" dependencies = [ + "binary_sv2", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-consensus", "bitcoin-rs-mempool", + "bitcoin-rs-mining", "bitcoin-rs-primitives", "bitcoin-rs-script", + "bitcoin-rs-storage", + "codec_sv2", + "common_messages_sv2", "compact_str", "criterion", + "framing_sv2", "hashbrown 0.17.1", + "mining_sv2", + "noise_sv2", "parking_lot", "proptest", + "rand 0.8.8", + "ruint", + "secp256k1 0.28.2", "serde_json", + "sha2", + "sonic-rs", + "tempfile", "thiserror", "tracing", + "zeroize", ] [[package]] @@ -460,13 +475,10 @@ dependencies = [ "bitcoin-rs-mining", "bitcoin-rs-node", "bitcoin-rs-p2p", - "bitcoin-rs-pool-ledger", "bitcoin-rs-primitives", "bitcoin-rs-rpc", "bitcoin-rs-script", "bitcoin-rs-storage", - "bitcoin-rs-stratum", - "bitcoin-rs-stratum-v2", "bitcoin-rs-utxo", "bytes", "cap-fs-ext", @@ -532,15 +544,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "bitcoin-rs-pool-ledger" -version = "0.14.0" -dependencies = [ - "bitcoin-rs-storage", - "sha2", - "tempfile", -] - [[package]] name = "bitcoin-rs-primitives" version = "0.14.0" @@ -573,7 +576,6 @@ dependencies = [ "bitcoin-rs-mining", "bitcoin-rs-node", "bitcoin-rs-p2p", - "bitcoin-rs-pool-ledger", "bitcoin-rs-primitives", "bitcoin-rs-rpc", "bitcoin-rs-script", @@ -670,50 +672,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "bitcoin-rs-stratum" -version = "0.14.0" -dependencies = [ - "bitcoin-rs-chain", - "bitcoin-rs-consensus", - "bitcoin-rs-mempool", - "bitcoin-rs-mining", - "bitcoin-rs-primitives", - "bitcoin-rs-script", - "compact_str", - "parking_lot", - "ruint", - "sha2", - "sonic-rs", - "thiserror", -] - -[[package]] -name = "bitcoin-rs-stratum-v2" -version = "0.14.0" -dependencies = [ - "binary_sv2", - "bitcoin-rs-chain", - "bitcoin-rs-consensus", - "bitcoin-rs-mempool", - "bitcoin-rs-mining", - "bitcoin-rs-primitives", - "bitcoin-rs-script", - "bitcoin-rs-stratum", - "codec_sv2", - "common_messages_sv2", - "framing_sv2", - "mining_sv2", - "noise_sv2", - "parking_lot", - "rand 0.8.8", - "ruint", - "secp256k1 0.28.2", - "sha2", - "thiserror", - "zeroize", -] - [[package]] name = "bitcoin-rs-utxo" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index e2578f6147..8fba644c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,6 @@ members = [ "crates/mempool", "crates/p2p", "crates/mining", - "crates/stratum", - "crates/pool-ledger", - "crates/stratum-v2", "crates/rpc", "crates/node", "bin/bitcoin-rs", @@ -54,9 +51,6 @@ bitcoin-rs-index = { path = "crates/index", version = "0.14.0" } bitcoin-rs-mempool = { path = "crates/mempool", version = "0.14.0" } bitcoin-rs-p2p = { path = "crates/p2p", version = "0.14.0" } bitcoin-rs-mining = { path = "crates/mining", version = "0.14.0" } -bitcoin-rs-stratum = { path = "crates/stratum", version = "0.14.0" } -bitcoin-rs-pool-ledger = { path = "crates/pool-ledger", version = "0.14.0" } -bitcoin-rs-stratum-v2 = { path = "crates/stratum-v2", version = "0.14.0" } bitcoin-rs-rpc = { path = "crates/rpc", version = "0.14.0" } bitcoin-rs-node = { path = "crates/node", version = "0.14.0", default-features = false } bitcoin-rs-e2e = { path = "e2e", version = "0.14.0" } diff --git a/bin/bitcoin-rs/tests/support/dependency_graph.rs b/bin/bitcoin-rs/tests/support/dependency_graph.rs index 385717c2fd..b8f809500b 100644 --- a/bin/bitcoin-rs/tests/support/dependency_graph.rs +++ b/bin/bitcoin-rs/tests/support/dependency_graph.rs @@ -64,16 +64,8 @@ pub(crate) fn approved_layer(crate_name: &str) -> u8 { match crate_name { "bitcoin-rs-primitives" | "bitcoin-rs-script" | "bitcoin-rs-consensus" => 0, STORAGE_CRATE => 1, - "bitcoin-rs-chain" - | CHAINSTATE_CRATE - | "bitcoin-rs-utxo" - | "bitcoin-rs-p2p" - | "bitcoin-rs-mempool" - | "bitcoin-rs-index" - | "bitcoin-rs-mining" - | "bitcoin-rs-stratum" - | "bitcoin-rs-stratum-v2" - | "bitcoin-rs-pool-ledger" => 2, + "bitcoin-rs-chain" | CHAINSTATE_CRATE | "bitcoin-rs-utxo" | "bitcoin-rs-p2p" + | "bitcoin-rs-mempool" | "bitcoin-rs-index" | "bitcoin-rs-mining" => 2, RPC_CRATE => 3, NODE_CRATE | BIN_CRATE diff --git a/crates/mining/Cargo.toml b/crates/mining/Cargo.toml index b468d0a600..269b7d1422 100644 --- a/crates/mining/Cargo.toml +++ b/crates/mining/Cargo.toml @@ -22,10 +22,27 @@ compact_str.workspace = true parking_lot.workspace = true thiserror.workspace = true tracing.workspace = true +bitcoin-rs-storage = { workspace = true, optional = true } +ruint = { workspace = true, optional = true } +sonic-rs = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +# Pinned SRI release family; only the optional SV2 adapter uses Noise. +binary_sv2 = { workspace = true, optional = true } +framing_sv2 = { workspace = true, optional = true } +codec_sv2 = { workspace = true, features = ["noise_sv2"], optional = true } +noise_sv2 = { workspace = true, optional = true } +mining_sv2 = { workspace = true, optional = true } +common_messages_sv2 = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } [features] default = [] test-seam = [] +pool = ["dep:bitcoin-rs-storage", "dep:ruint", "dep:sonic-rs", "dep:sha2"] +pool-sv2 = [ + "pool", "dep:binary_sv2", "dep:framing_sv2", "dep:codec_sv2", + "dep:noise_sv2", "dep:mining_sv2", "dep:common_messages_sv2", "dep:zeroize", +] [dev-dependencies] @@ -37,6 +54,28 @@ criterion.workspace = true # gateway replacement seam must arrive through the test-seam feature, the same # way bitcoin-rs-p2p and bitcoin-rs-rpc opt in. bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } +bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } +bitcoin-rs-storage = { workspace = true, features = ["test-seam", "redb", "fjall"] } +tempfile.workspace = true +# Miner-side key generation uses the same version as the optional Noise graph. +secp256k1 = { version = "=0.28.2", default-features = false, features = ["std", "rand-std"] } +rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } + +[[test]] +name = "pool_jobs" +required-features = ["pool"] + +[[test]] +name = "pool_sv1" +required-features = ["pool"] + +[[test]] +name = "pool_sv2" +required-features = ["pool-sv2"] + +[[test]] +name = "pool_ledger" +required-features = ["pool", "test-seam"] [[bench]] name = "candidate" diff --git a/crates/mining/README.md b/crates/mining/README.md index 3ba626c0ba..a070973cdb 100644 --- a/crates/mining/README.md +++ b/crates/mining/README.md @@ -165,3 +165,13 @@ against live fixture pools. It is a measurement seam, not a budget. Part of [`bitcoin-rs`](../../README.md); see [`CONCEPTS.md`](../../CONCEPTS.md) for the project vocabulary. + +## Native pool + +The `pool` feature adds `pool::JobBook`, `pool::PoolCore`, the SV1 server +in `pool::stratum::sv1`, and durable PPLNS accounting in `pool::ledger`. +`pool-sv2` additionally enables the Noise transport and SV2 protocol session +in `pool::stratum::sv2`. Both protocols use the same job/share owner and +`MiningControl` submission boundary. The node owns listener composition, +chain observations, storage construction, and shutdown. Neither feature +enables the runtime by itself. See [pool operation](../../docs/pool.md). diff --git a/crates/mining/src/lib.rs b/crates/mining/src/lib.rs index e60c00955c..478b5bb76f 100755 --- a/crates/mining/src/lib.rs +++ b/crates/mining/src/lib.rs @@ -48,3 +48,7 @@ pub use template::{ TemplateId, assemble_candidate, assemble_ordered_candidate, select_template_body, select_template_body_after, solve_block, }; + +/// Optional native mining pool protocols and accounting. +#[cfg(feature = "pool")] +pub mod pool; diff --git a/crates/mining/src/pool.rs b/crates/mining/src/pool.rs new file mode 100644 index 0000000000..80c31a62a0 --- /dev/null +++ b/crates/mining/src/pool.rs @@ -0,0 +1,21 @@ +//! Native mining pool jobs, protocols, and durable accounting. +//! +//! One JobBook owns job projection and share validation for both protocols. +//! Node composition supplies mining control, storage, and chain observations. +#![forbid(unsafe_code)] + +mod core; +mod job; +pub mod ledger; +pub mod stratum; + +pub use core::{ + BlockOutcome, LedgerUnavailable, PoolCore, RecordOutcome, RefreshError, Refreshed, ShareLedger, + WorkerIdentity, +}; +pub use job::{ + AcceptedShare, BIP320_VERSION_MASK, BlockReceipt, BlockStatus, Extranonce, ExtranonceLayout, + FoundBlock, Installed, Job, JobBook, JobBookConfig, JobError, JobId, + MAX_COINBASE_SCRIPT_SIG_LEN, MAX_NTIME_FORWARD_SECS, Session, SessionId, ShareClass, + ShareError, ShareOutcome, ShareSubmission, ShareTarget, ShareWork, StaleReason, fetch_template, +}; diff --git a/crates/mining/src/pool/core.rs b/crates/mining/src/pool/core.rs new file mode 100644 index 0000000000..a5eb8884dc --- /dev/null +++ b/crates/mining/src/pool/core.rs @@ -0,0 +1,795 @@ +//! Shared pool job publication, submission, and deferred accounting. +//! +//! Lock order is book then published. Neither lock spans a socket operation, +//! ledger call, or node call. Protocol sessions share the same `JobBook`. +use super::{ + AcceptedShare, BlockReceipt, BlockStatus, ExtranonceLayout, FoundBlock, Installed, Job, + JobBook, JobBookConfig, JobError, JobId, Session as BookSession, SessionId, ShareClass, + ShareError, ShareOutcome, ShareSubmission, ShareTarget, StaleReason, fetch_template, +}; +use crate::{Candidate, CoinbaseBudget, MiningControl, MiningControlError}; +use bitcoin_rs_primitives::{BlockHash, Hash256, TxOut}; +use std::collections::VecDeque; +use std::error::Error; +use std::fmt; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::thread; +use std::time::Duration; + +fn locked(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Who a share is credited to: the pool account and the rig under it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorkerIdentity { + /// Account that is paid. + pub account: String, + /// Rig name under the account. + pub worker: String, +} + +/// The ledger could not take the record right now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LedgerUnavailable; + +/// Whether a share row was new. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecordOutcome { + /// New row written. + Recorded, + /// A row with this header hash already exists; nothing changed. + AlreadyRecorded, + /// The ledger refused the share permanently (retired round, conflicting + /// credit, or claimed block): never retry and never buffer. Adapters + /// report a rejected share, not an unavailable ledger. + Rejected, +} + +/// Idempotent accounting boundary shared by pool submission and its host. +/// +/// Implementations must be idempotent on `share.hash`: the adapter retries a +/// failed call, and the common owner's duplicate set makes every first delivery +/// unique. +pub trait ShareLedger: Send + Sync { + /// Records one accepted share (`share.class` may be `Block`) credited to + /// `worker`. `share.hash` is the unique key; `share.work` is the work. + fn record_share( + &self, + worker: &WorkerIdentity, + share: &AcceptedShare, + ) -> Result; + + /// Records what the node answered when `block` was submitted. `Err` or an + /// `Undetermined` receipt means the chain outcome is unknown: keep the + /// block pending and reconcile from chain observation. + fn record_block( + &self, + worker: &WorkerIdentity, + block: &FoundBlock, + submission: &Result, + ) -> Result<(), LedgerUnavailable>; +} + +/// What the node answered to a block found by a share. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockOutcome { + /// Accepted, or its body was already connected. + Applied, + /// Rejected, or a duplicate of a known-invalid block. + Rejected, + /// The node could not conclude; the ledger reconciles from the chain. + Undetermined, + /// The submit call itself failed; chain outcome unknown. + NodeError, +} + +#[derive(Default)] +struct Published { + /// Increments on every fresh job. + seq: u64, + /// `seq` of the newest job that was installed with `clean_jobs`. + clean_seq: u64, + /// Newest job while it is active; `None` after `invalidate_active`. + job: Option>, +} + +pub(super) struct Latest { + pub(super) seq: u64, + pub(super) clean_seq: u64, + pub(super) job: Option>, +} + +/// A session slot in the book, closed on drop. +pub(super) struct Lease { + pub(super) pool: Arc, + pub(super) session: BookSession, +} + +impl Drop for Lease { + fn drop(&mut self) { + let _ = locked(&self.pool.book).close_session(self.session.id()); + } +} + +#[derive(Clone, Debug)] +enum Pending { + Share { + worker: WorkerIdentity, + share: AcceptedShare, + completion: Arc, + }, + BlockRecord { + worker: WorkerIdentity, + block: Box, + submission: Result, + completion: Arc, + }, +} + +pub(super) struct Completion { + remaining: AtomicUsize, +} + +/// Debug shows only the outstanding count; a completion carries no secrets. +impl fmt::Debug for Completion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Completion") + .field("remaining", &self.remaining.load(Ordering::Acquire)) + .finish() + } +} + +impl Completion { + fn new(items: usize) -> Self { + Self { + remaining: AtomicUsize::new(items), + } + } + + fn record(&self) { + let _ = self + .remaining + .try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }); + } + + pub(super) fn is_complete(&self) -> bool { + self.remaining.load(Ordering::Acquire) == 0 + } +} + +impl Pending { + fn hash(&self) -> BlockHash { + match self { + Self::Share { share, .. } => share.hash, + Self::BlockRecord { block, .. } => block.share.hash, + } + } + + fn completion(&self) -> &Completion { + match self { + Self::Share { completion, .. } | Self::BlockRecord { completion, .. } => completion, + } + } + + fn bytes_cost(&self) -> usize { + match self { + Self::Share { .. } => SHARE_RECORD_BYTES, + Self::BlockRecord { block, .. } => BLOCK_RECORD_FRAMING_BYTES + block.raw.len(), + } + } +} + +pub(super) enum SubmitFailure { + Share(ShareError), + Duplicate, + OutboxFull, +} + +pub(super) struct Credited { + pub(super) hash: BlockHash, + pub(super) class: ShareClass, + pub(super) completion: Arc, + pub(super) block: Option, +} + +#[derive(Default)] +struct Outbox { + items: VecDeque, + total_bytes: usize, + in_flight: usize, + reserved_items: usize, + reserved_bytes: usize, +} + +/// Buffered cost of one accepted share record. +const SHARE_RECORD_BYTES: usize = 256; +/// Buffered cost of one block record without its block body. +const BLOCK_RECORD_FRAMING_BYTES: usize = 512; + +/// Atomically holds outbox space for one in-flight submit: two item slots and +/// the share plus block-record framing bytes. A block body is admitted over the +/// byte bound rather than shedding a found block, so one in-flight submit +/// never spends the whole pool byte budget. +struct OutboxReservation<'a> { + pool: &'a PoolCore, + items: usize, + bytes: usize, + active: bool, +} +struct DrainGuard<'a>(&'a AtomicBool); + +impl Drop for DrainGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl OutboxReservation<'_> { + /// Commits an item into the outbox by consuming part of this reservation. + /// Item slots are linearizable under the single outbox mutex, so the + /// pre-allocated slots are guaranteed. A block body is admitted over the + /// byte bound rather than shedding a found block, and still counts toward + /// `total_bytes`. + fn commit_reserved(&mut self, item: Pending) { + let cost = item.bytes_cost(); + let mut outbox = locked(&self.pool.outbox); + outbox.reserved_items = outbox.reserved_items.saturating_sub(1); + outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(cost); + outbox.total_bytes += cost; + outbox.items.push_back(item); + let total = outbox.items.len() + outbox.in_flight; + self.pool.pending_count.store(total, Ordering::Release); + self.items = self.items.saturating_sub(1); + self.bytes = self.bytes.saturating_sub(cost); + } +} + +impl Drop for OutboxReservation<'_> { + fn drop(&mut self) { + if self.active { + let mut outbox = locked(&self.pool.outbox); + outbox.reserved_items = outbox.reserved_items.saturating_sub(self.items); + outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(self.bytes); + self.active = false; + } + } +} + +pub(super) struct ShareRequest<'a> { + pub(super) session: SessionId, + pub(super) job: JobId, + pub(super) rolled: &'a [u8], + pub(super) ntime: u32, + pub(super) nonce: u32, + /// Canonical header version, normalized by the protocol edge before the + /// common submit owner runs. + pub(super) version: u32, +} + +/// The pool: one `JobBook`, the node control it submits blocks to, and the +/// ledger it credits. Shared by every connection thread. +pub struct PoolCore { + book: Mutex, + published: Mutex, + control: Arc, + ledger: Arc, + layout: ExtranonceLayout, + version_mask: u32, + budget: CoinbaseBudget, + ledger_attempts: u32, + ledger_backoff: Duration, + outbox: Mutex, + outbox_max_items: usize, + outbox_max_bytes: usize, + pending_count: AtomicUsize, + draining: AtomicBool, +} + +/// A template fetched and installed by [`PoolCore::refresh`]. +#[derive(Debug)] +pub struct Refreshed { + /// What `JobBook::install` did. + pub installed: Installed, + /// Pass to the next [`PoolCore::refresh`] to long-poll for a newer template. + pub long_poll_id: String, + /// The template's `submit_old`. + pub submit_old: Option, +} + +/// [`PoolCore::refresh`] failure. +#[derive(Debug)] +pub enum RefreshError { + /// The node would not give a template. + Template(MiningControlError), + /// The job book would not project it. + Install(JobError), +} + +impl fmt::Display for RefreshError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Template(error) => write!(f, "template fetch failed: {error}"), + Self::Install(error) => write!(f, "job install failed: {error}"), + } + } +} + +impl Error for RefreshError {} + +impl PoolCore { + /// Builds the pool over a fresh `JobBook`. Retries a failed ledger call + /// three times with a 5 ms base backoff; see [`Self::with_ledger_retry`]. + pub fn new( + config: JobBookConfig, + control: Arc, + ledger: Arc, + ) -> Result { + let layout = config.extranonce; + let version_mask = config.version_mask; + let budget = config.budget; + let book = JobBook::new(config)?; + Ok(Self { + book: Mutex::new(book), + published: Mutex::new(Published::default()), + control, + ledger, + layout, + version_mask, + budget, + ledger_attempts: 3, + ledger_backoff: Duration::from_millis(5), + outbox: Mutex::new(Outbox::default()), + outbox_max_items: 4096, + outbox_max_bytes: 32 * 1024 * 1024, + pending_count: AtomicUsize::new(0), + draining: AtomicBool::new(false), + }) + } + + /// Sets the ledger retry budget: `attempts` calls (at least one) with a + /// sleep of `backoff * attempt` plus a per-share jitter of up to 2.5 ms + /// between them. + #[must_use] + pub fn with_ledger_retry(mut self, attempts: u32, backoff: Duration) -> Self { + self.ledger_attempts = attempts.max(1); + self.ledger_backoff = backoff; + self + } + + /// Sets the outbox bounds for queuing writes when the ledger is unavailable. + #[must_use] + pub fn with_outbox(mut self, max_items: usize, max_bytes: usize) -> Self { + self.outbox_max_items = max_items.max(1); + self.outbox_max_bytes = max_bytes.max(1024); + self + } + + /// Number of writes currently waiting in the outbox. + #[must_use] + pub fn pending_ledger_writes(&self) -> usize { + self.pending_count.load(Ordering::Acquire) + } + + /// Extranonce split every session uses (SV1 `extranonce2_size` is its + /// `rolled_len`). + #[must_use] + pub fn layout(&self) -> ExtranonceLayout { + self.layout + } + + /// Version bits the pool lets miners roll (zero: no rolling). + #[must_use] + pub fn version_mask(&self) -> u32 { + self.version_mask + } + + /// Open sessions in the book. + #[must_use] + pub fn session_count(&self) -> usize { + locked(&self.book).session_count() + } + + /// Runs an owner-local validation operation under the sole authoritative + /// `JobBook` mutex. The callback must not re-enter `PoolCore`, install or + /// invalidate jobs, or perform node, ledger, or other external I/O while + /// this lock is held. + pub fn with_job_book(&self, operation: impl FnOnce(&mut JobBook) -> R) -> R { + let mut book = locked(&self.book); + operation(&mut book) + } + + /// Returns the active job from the same authoritative `JobBook` used by + /// SV1 validation and session allocation. + #[must_use] + pub fn current_job(&self) -> Option> { + self.with_job_book(|book| book.current().cloned()) + } + + /// Returns the latest installation metadata from the authoritative pool + /// owner. The value is absent before the first install or after explicit + /// invalidation. + #[must_use] + pub fn current_installed(&self) -> Option { + self.with_job_book(|book| book.current_installed().cloned()) + } + + /// Installs `candidate` (with the pool's coinbase `extra_outputs`) and, when + /// it is a fresh job, publishes it to every session at its next poll. + pub fn install( + &self, + candidate: Arc, + extra_outputs: Vec, + ) -> Result { + self.install_with(false, candidate, extra_outputs) + } + + /// Marks every active job stale. Sessions keep their last job until the + /// next install, whose `clean_jobs` is then true; shares on the stale jobs + /// are rejected by the book. + pub fn invalidate_active(&self) -> Vec { + let mut book = locked(&self.book); + let stale = book.invalidate_active(); + let mut published = locked(&self.published); + published.job = None; + stale + } + + /// Fetches one template from the node (long-polling on `long_poll_id`) and + /// installs it. `submit_old == Some(false)` first invalidates active jobs. + pub fn refresh( + &self, + long_poll_id: Option<&str>, + extra_outputs: Vec, + ) -> Result { + let template = fetch_template(&*self.control, self.budget, long_poll_id) + .map_err(RefreshError::Template)?; + let next = template.candidate.template_id.as_str().to_owned(); + let invalidate = template.submit_old == Some(false); + let installed = self + .install_with(invalidate, Arc::clone(&template.candidate), extra_outputs) + .map_err(RefreshError::Install)?; + Ok(Refreshed { + installed, + long_poll_id: next, + submit_old: template.submit_old, + }) + } + + fn install_with( + &self, + invalidate: bool, + candidate: Arc, + extra_outputs: Vec, + ) -> Result { + let mut book = locked(&self.book); + if invalidate && !book.invalidate_active().is_empty() { + locked(&self.published).job = None; + } + let installed = book.install(candidate, extra_outputs)?; + if installed.fresh { + let mut published = locked(&self.published); + published.seq = published.seq.saturating_add(1); + published.job = Some(Arc::clone(&installed.job)); + if installed.clean_jobs { + published.clean_seq = published.seq; + } + } + Ok(installed) + } + + pub(super) fn open_lease(self: &Arc) -> Result { + let session = locked(&self.book).open_session()?; + Ok(Lease { + pool: Arc::clone(self), + session, + }) + } + + pub(super) fn latest(&self) -> Latest { + let published = locked(&self.published); + Latest { + seq: published.seq, + clean_seq: published.clean_seq, + job: published.job.clone(), + } + } + + fn retry_pause(&self, attempt: u32, hash: BlockHash) { + let jitter = u64::from(Hash256::from(hash).to_le_bytes()[0]) * 10; + thread::sleep(self.ledger_backoff * (attempt + 1) + Duration::from_micros(jitter)); + } + + fn submit_block_to_node(&self, found: &FoundBlock) -> Result { + const ATTEMPTS: u32 = 5; + let mut attempt = 0; + loop { + match found.submit(&*self.control) { + Ok(receipt) => { + if receipt.status() == BlockStatus::Applied { + self.invalidate_active(); + } + return Ok(receipt); + } + Err( + MiningControlError::Unavailable(message) | MiningControlError::Failed(message), + ) => { + attempt += 1; + if attempt == ATTEMPTS { + return Err(MiningControlError::Failed(message)); + } + self.retry_pause(attempt - 1, found.share.hash); + } + Err(non_retriable) => return Err(non_retriable), + } + } + } + + fn attempt_item(&self, item: &Pending) -> Result { + match item { + Pending::Share { worker, share, .. } => self.ledger.record_share(worker, share), + Pending::BlockRecord { + worker, + block, + submission, + .. + } => self + .ledger + .record_block(worker, block, submission) + .map(|()| RecordOutcome::Recorded), + } + } + + fn deliver_inline(&self, item: &Pending) -> Result { + self.drain_outbox(); + if self.pending_count.load(Ordering::Acquire) > 0 { + return Err(LedgerUnavailable); + } + let hash = item.hash(); + for attempt in 0..self.ledger_attempts { + match self.attempt_item(item) { + Ok(outcome) => return Ok(outcome), + Err(LedgerUnavailable) => { + if attempt + 1 < self.ledger_attempts { + self.retry_pause(attempt, hash); + } + } + } + } + Err(LedgerUnavailable) + } + + /// Drains pending outbox items sequentially. + pub fn drain_outbox(&self) { + if self.pending_count.load(Ordering::Acquire) == 0 { + return; + } + if self + .draining + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return; + } + let _guard = DrainGuard(&self.draining); + + loop { + let item = { + let mut outbox = locked(&self.outbox); + let Some(item) = outbox.items.pop_front() else { + outbox.in_flight = 0; + self.pending_count.store(0, Ordering::Release); + break; + }; + outbox.total_bytes = outbox.total_bytes.saturating_sub(item.bytes_cost()); + outbox.in_flight = 1; + self.pending_count + .store(outbox.items.len() + 1, Ordering::Release); + item + }; + + let mut succeeded = false; + for attempt in 0..self.ledger_attempts { + match self.attempt_item(&item) { + Ok(_) => { + item.completion().record(); + succeeded = true; + break; + } + Err(LedgerUnavailable) => { + if attempt + 1 < self.ledger_attempts { + self.retry_pause(attempt, item.hash()); + } + } + } + } + + let mut outbox = locked(&self.outbox); + outbox.in_flight = 0; + if succeeded { + self.pending_count + .store(outbox.items.len(), Ordering::Release); + } else { + outbox.total_bytes += item.bytes_cost(); + outbox.items.push_front(item); + self.pending_count + .store(outbox.items.len(), Ordering::Release); + break; + } + } + } + + /// Atomically reserves outbox space for one share (and potential block). + /// Returns `None` if the outbox has reached capacity. + /// The check-and-hold is linearizable under the single outbox mutex. A + /// second submit while the ledger is unavailable is shed with a retryable + /// accounting error, not buffered. + fn reserve_outbox_space(&self) -> Option> { + self.drain_outbox(); + // Two item slots, clamped so configured minima still admit one submit + // on an empty outbox. The byte half covers the share and the + // block-record framing only: the block body is admitted over the byte + // bound rather than shed, so one in-flight submit never spends the + // whole pool budget and stalls the pool at a single share. + let reserve_items = 2_usize.min(self.outbox_max_items); + let reserve_bytes = + (SHARE_RECORD_BYTES + BLOCK_RECORD_FRAMING_BYTES).min(self.outbox_max_bytes); + let mut outbox = locked(&self.outbox); + let committed_items = outbox.items.len() + outbox.reserved_items; + let committed_bytes = outbox.total_bytes.saturating_add(outbox.reserved_bytes); + if committed_items.saturating_add(reserve_items) > self.outbox_max_items + || committed_bytes.saturating_add(reserve_bytes) > self.outbox_max_bytes + { + None + } else { + outbox.reserved_items += reserve_items; + outbox.reserved_bytes += reserve_bytes; + Some(OutboxReservation { + pool: self, + items: reserve_items, + bytes: reserve_bytes, + active: true, + }) + } + } + + pub(super) fn submit( + &self, + worker: &WorkerIdentity, + request: &ShareRequest<'_>, + target: &ShareTarget, + ) -> Result { + // Atomically reserve space BEFORE validation. If the outbox is at + // capacity, fail with backpressure so the miner may retry and the hash + // is never burned in `JobBook`. + let mut reservation = self + .reserve_outbox_space() + .ok_or(SubmitFailure::OutboxFull)?; + + let outcome = { + let mut book = locked(&self.book); + // `request.version` is already canonical: the edge normalized the + // wire bits, so this common path holds no wire branching. + let submission = ShareSubmission { + session: request.session, + job: request.job, + rolled_extranonce: request.rolled, + ntime: request.ntime, + nonce: request.nonce, + version: request.version, + }; + book.validate(&submission, target) + .map_err(SubmitFailure::Share)? + }; + + match outcome { + ShareOutcome::Share(accepted) => { + let hash = accepted.hash; + let completion = Arc::new(Completion::new(1)); + let item = Pending::Share { + worker: worker.clone(), + share: *accepted, + completion: Arc::clone(&completion), + }; + // Attempt inline delivery first; otherwise the completion waits on the outbox. + // Delivery is at-least-once (inline plus drain) against the + // idempotent ledger consumer, which is keyed on `share.hash`. + match self.deliver_inline(&item) { + Ok(RecordOutcome::Recorded) => completion.record(), + // Already durable (crash restore or post-eviction replay) + // or permanently refused (the ledger judged this work + // first): answer the duplicate identity at once with no + // retry and no buffering. The book already burned the + // hash, so a miner retry converges to the same duplicate + // verdict instead of spinning on an uncurable refusal. + Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { + return Err(SubmitFailure::Duplicate); + } + Err(LedgerUnavailable) => reservation.commit_reserved(item), + } + Ok(Credited { + hash, + class: ShareClass::Share, + completion, + block: None, + }) + } + ShareOutcome::Block(found) => { + // Priority invariant: node submission runs before any ledger record. + let submission = self.submit_block_to_node(&found); + let block_outcome = match &submission { + Ok(receipt) => match receipt.status() { + BlockStatus::Applied => BlockOutcome::Applied, + BlockStatus::Rejected => BlockOutcome::Rejected, + BlockStatus::Undetermined => BlockOutcome::Undetermined, + }, + Err(_) => BlockOutcome::NodeError, + }; + let hash = found.share.hash; + let job = found.share.job; + let stale_parent = submission.as_ref().is_ok_and(BlockReceipt::is_stale_parent); + let completion = Arc::new(Completion::new(2)); + let share_item = Pending::Share { + worker: worker.clone(), + share: found.share.clone(), + completion: Arc::clone(&completion), + }; + let block_item = Pending::BlockRecord { + worker: worker.clone(), + block: found, + submission, + completion: Arc::clone(&completion), + }; + + // The share needs no further crediting when it is already + // durable (crash restore or post-eviction replay) or + // permanently refused (the ledger judged this work first). + // Either way the block record still needs delivery, so keep + // going and report the duplicate only after the block fate is + // known. No retry and no buffering on the settled path: a + // settled share can never become deliverable. + let mut share_settled = false; + match self.deliver_inline(&share_item) { + Ok(RecordOutcome::Recorded) => completion.record(), + Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { + completion.record(); + share_settled = true; + } + Err(LedgerUnavailable) => reservation.commit_reserved(share_item), + } + + // Deliver or buffer block record. + if self.deliver_inline(&block_item).is_err() { + reservation.commit_reserved(block_item); + } else { + completion.record(); + } + + if stale_parent { + return Err(SubmitFailure::Share(ShareError::Stale { + job, + reason: StaleReason::Invalidated, + })); + } + + if share_settled { + // The share earned no new credit, but the block round is + // now durable: the replay keeps its duplicate identity, + // exactly as a book-level duplicate would. + return Err(SubmitFailure::Duplicate); + } + + Ok(Credited { + hash, + class: ShareClass::Block, + completion, + block: Some(block_outcome), + }) + } + } + } +} diff --git a/crates/stratum/src/job.rs b/crates/mining/src/pool/job.rs similarity index 99% rename from crates/stratum/src/job.rs rename to crates/mining/src/pool/job.rs index 8ca2d8b92b..1a19296f50 100644 --- a/crates/stratum/src/job.rs +++ b/crates/mining/src/pool/job.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use bitcoin_rs_chain::{ChainWork, compact_is_met_by}; use bitcoin_rs_consensus::transaction_sigop_cost; -use bitcoin_rs_mining::{ +use crate::{ BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CoinbaseBudget, MiningControl, MiningControlError, MiningError, STALE_PARENT_REASON, diff --git a/crates/pool-ledger/src/lib.rs b/crates/mining/src/pool/ledger.rs similarity index 100% rename from crates/pool-ledger/src/lib.rs rename to crates/mining/src/pool/ledger.rs diff --git a/crates/pool-ledger/src/kv.rs b/crates/mining/src/pool/ledger/kv.rs similarity index 99% rename from crates/pool-ledger/src/kv.rs rename to crates/mining/src/pool/ledger/kv.rs index 25b8d17238..e884c5bd27 100644 --- a/crates/pool-ledger/src/kv.rs +++ b/crates/mining/src/pool/ledger/kv.rs @@ -1,5 +1,5 @@ //! Snapshot-backed logical tables and a single revision-guarded durable commit. -use crate::LedgerError; +use super::LedgerError; use bitcoin_rs_storage::{ColumnFamily, KvSnapshot, KvStore, WriteCondition}; use std::cell::RefCell; use std::collections::BTreeMap; @@ -8,7 +8,7 @@ use std::ops::{Bound, RangeBounds}; use std::sync::{Arc, Mutex, MutexGuard}; #[cfg(test)] -#[path = "../tests/support/kv_transactions.rs"] +#[path = "../../../tests/support/kv_transactions.rs"] mod tests; const PREFIX: &[u8] = b"pool-ledger/v1/"; diff --git a/crates/pool-ledger/src/store.rs b/crates/mining/src/pool/ledger/store.rs similarity index 99% rename from crates/pool-ledger/src/store.rs rename to crates/mining/src/pool/ledger/store.rs index e2657f72cb..a12ad543a0 100644 --- a/crates/pool-ledger/src/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -10,7 +10,7 @@ //! second time, because the identity it keys on (header hash, block hash, idempotency key, txid, //! round or batch state) is checked inside the same transaction that would apply it. -use crate::kv::{Database, Table, TableDefinition, WriteTransaction}; +use super::kv::{Database, Table, TableDefinition, WriteTransaction}; use bitcoin_rs_storage::KvStore; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -18,8 +18,8 @@ use std::sync::Arc; #[cfg(feature = "test-seam")] use std::sync::Mutex; -use crate::codec::{Dec, Enc}; -use crate::{ +use super::codec::{Dec, Enc}; +use super::{ AuditKind, AuditRecord, Balance, BatchState, BatchTransition, BlockFound, BlockRecorded, CancelBasis, ChainView, Closure, Confirmation, ExternalSigner, Hash32, LedgerError, MAX_BATCH_OUTPUTS, MAX_MONEY_SAT, MAX_SCRIPT_LEN, MAX_SHARE_TUPLE_LEN, MinerId, @@ -671,13 +671,13 @@ impl Ledger { let mut observations = Vec::with_capacity(open.len()); for round in &open { let canon = match chain.block_location(&round.block_hash, round.height)? { - crate::BlockLocation::Canonical { height } if height == round.height => { + super::BlockLocation::Canonical { height } if height == round.height => { Canon::Canonical } - crate::BlockLocation::Canonical { .. } | crate::BlockLocation::Side => { + super::BlockLocation::Canonical { .. } | super::BlockLocation::Side => { Canon::Displaced } - crate::BlockLocation::Unknown => Canon::Unknown, + super::BlockLocation::Unknown => Canon::Unknown, }; let coinbase = if canon == Canon::Canonical && !round.reward_fixed { chain.coinbase_of(&round.block_hash)? @@ -798,7 +798,7 @@ impl Ledger { /// # Errors /// Returns [`LedgerError::InvalidInput`] for an empty, oversized or `OP_RETURN` script. pub fn set_payout_script(&self, account: MinerId, script: &[u8]) -> Result<(), LedgerError> { - crate::validate_script(script)?; + super::validate_script(script)?; let txn = self.db.begin_write()?; let changed = { let tables = Tables::open(&txn)?; @@ -814,7 +814,7 @@ impl Ledger { &tables, AuditKind::PayoutScriptSet, account.0, - &crate::sha256(script), + &super::sha256(script), &ZERO_HASH, [0, 0, 0], )?; @@ -888,7 +888,7 @@ impl Ledger { if picks.is_empty() { return Ok(None); } - let total = crate::check_outputs(&picks)?; + let total = super::check_outputs(&picks)?; let id = compute_batch_id(&idempotency_key, &picks)?; let seq = bump(&tables.meta_u64, M_NEXT_BATCH, 1)?; for pick in &picks { @@ -2041,7 +2041,7 @@ fn observe_batch( let status = chain.tx_status(&tx.txid)?; let (canonical, depth) = match status { TxStatus::Confirmed { block_hash, height } => ( - matches!(chain.block_location(&block_hash, height)?, crate::BlockLocation::Canonical { height: observed } if observed == height), + matches!(chain.block_location(&block_hash, height)?, super::BlockLocation::Canonical { height: observed } if observed == height), confirmations(tip, height), ), _ => (false, 0), @@ -2087,7 +2087,7 @@ fn find_dead_spend( { continue; } - let canonical = matches!(chain.block_location(&spend.block_hash, spend.height)?, crate::BlockLocation::Canonical { height } if height == spend.height); + let canonical = matches!(chain.block_location(&spend.block_hash, spend.height)?, super::BlockLocation::Canonical { height } if height == spend.height); if canonical && confirmations(tip, spend.height) >= u64::from(config.abandon_confirmations) { return Ok(Some(spend.spender_txid)); diff --git a/crates/mining/src/pool/stratum.rs b/crates/mining/src/pool/stratum.rs new file mode 100644 index 0000000000..079422cccf --- /dev/null +++ b/crates/mining/src/pool/stratum.rs @@ -0,0 +1,4 @@ +//! Stratum wire protocols over the shared mining pool owner. +pub mod sv1; +#[cfg(feature = "pool-sv2")] +pub mod sv2; diff --git a/crates/stratum/src/sv1.rs b/crates/mining/src/pool/stratum/sv1.rs similarity index 94% rename from crates/stratum/src/sv1.rs rename to crates/mining/src/pool/stratum/sv1.rs index 05c43c464a..1be31b79d5 100644 --- a/crates/stratum/src/sv1.rs +++ b/crates/mining/src/pool/stratum/sv1.rs @@ -28,16 +28,15 @@ mod session; use std::collections::HashMap; use std::fmt; -use crate::{AcceptedShare, BlockReceipt, FoundBlock, Job, JobError, JobId, ShareTarget}; -use bitcoin_rs_mining::MiningControlError; +use crate::pool::{Job, JobError, JobId, ShareTarget, WorkerIdentity}; use bitcoin_rs_primitives::Hash256; use bitcoin_rs_primitives::encode::hex_encode as primitives_hex_encode; use sonic_rs::{Array, JsonContainerTrait, JsonValueTrait, Value}; +pub use crate::pool::{BlockOutcome, PoolCore, RefreshError, Refreshed}; pub use session::{ - BlockOutcome, ConfigError, ConnectionLimiter, ConnectionPermit, Observer, PoolCore, - RefreshError, Refreshed, RefusalReason, Server, ServerConfig, ServerDeps, ServerEvent, - SessionEnd, ShutdownReport, + ConfigError, ConnectionLimiter, ConnectionPermit, Observer, RefusalReason, Server, + ServerConfig, ServerDeps, ServerEvent, SessionEnd, ShutdownReport, }; /// Longest accepted JSON-RPC request id string. @@ -650,15 +649,6 @@ impl fmt::Debug for Password { } } -/// Who a share is credited to: the pool account and the rig under it. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct WorkerIdentity { - /// Account that is paid. - pub account: String, - /// Rig name under the account. - pub worker: String, -} - /// Authentication failed. Carries no reason, so unknown account and wrong /// password are indistinguishable to the miner and to logs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -856,52 +846,6 @@ impl DifficultyPolicy { } } -// --------------------------------------------------------------------------- -// Accounting boundary -// --------------------------------------------------------------------------- - -/// The ledger could not take the record right now. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct LedgerUnavailable; - -/// Whether a share row was new. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RecordOutcome { - /// New row written. - Recorded, - /// A row with this header hash already exists; nothing changed. - AlreadyRecorded, - /// The ledger refused the share permanently (retired round, conflicting - /// credit, or claimed block): never retry and never buffer. Adapters - /// report a rejected share, not an unavailable ledger. - Rejected, -} - -/// Pool accounting, separate from this adapter. -/// -/// Implementations must be idempotent on `share.hash`: the adapter retries a -/// failed call, and the common owner's duplicate set makes every first delivery -/// unique. -pub trait ShareLedger: Send + Sync { - /// Records one accepted share (`share.class` may be `Block`) credited to - /// `worker`. `share.hash` is the unique key; `share.work` is the work. - fn record_share( - &self, - worker: &WorkerIdentity, - share: &AcceptedShare, - ) -> Result; - - /// Records what the node answered when `block` was submitted. `Err` or an - /// `Undetermined` receipt means the chain outcome is unknown: keep the - /// block pending and reconcile from chain observation. - fn record_block( - &self, - worker: &WorkerIdentity, - block: &FoundBlock, - submission: &Result, - ) -> Result<(), LedgerUnavailable>; -} - // --------------------------------------------------------------------------- // Typed client requests // --------------------------------------------------------------------------- diff --git a/crates/stratum/src/sv1/session.rs b/crates/mining/src/pool/stratum/sv1/session.rs similarity index 65% rename from crates/stratum/src/sv1/session.rs rename to crates/mining/src/pool/stratum/sv1/session.rs index d07a38f196..8c0c548ec7 100644 --- a/crates/stratum/src/sv1/session.rs +++ b/crates/mining/src/pool/stratum/sv1/session.rs @@ -25,29 +25,28 @@ //! `ShareError` onto SV1 error codes, picks the session's share target //! (difficulty policy), and hands accepted shares to the [`ShareLedger`]. -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::error::Error; use std::fmt; use std::io::{self, Read, Write}; use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use crate::{ - AcceptedShare, BlockReceipt, BlockStatus, ExtranonceLayout, FoundBlock, Installed, Job, - JobBook, JobBookConfig, JobError, JobId, Session as BookSession, SessionId, ShareClass, - ShareError, ShareOutcome, ShareSubmission, ShareTarget, StaleReason, fetch_template, +use crate::pool::{ + BlockOutcome, JobId, PoolCore, SessionId, ShareClass, ShareError, ShareTarget, StaleReason, + WorkerIdentity, }; -use bitcoin_rs_mining::{Candidate, CoinbaseBudget, MiningControl, MiningControlError}; -use bitcoin_rs_primitives::{BlockHash, Hash256, TxOut}; +use crate::pool::core::{Completion, Credited, Lease, ShareRequest, SubmitFailure}; +use bitcoin_rs_primitives::BlockHash; use super::{ AccountResolver, Authorize, ClientRequest, Configure, Difficulty, DifficultyPolicy, Incoming, - LedgerUnavailable, LineBuffer, RecordOutcome, Request, RequestId, ShareLedger, Submit, - Subscribe, WorkerIdentity, decode_line, encode_error, encode_hex, encode_notify, encode_reject, - encode_result, encode_set_difficulty, errors, json_string, parse_wire_job_id, + LineBuffer, Request, RequestId, Submit, Subscribe, decode_line, encode_error, encode_hex, + encode_notify, encode_reject, encode_result, encode_set_difficulty, errors, json_string, + parse_wire_job_id, }; fn locked(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -72,19 +71,6 @@ pub enum RefusalReason { SpawnFailed, } -/// What the node answered to a block found by a share. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BlockOutcome { - /// Accepted, or its body was already connected. - Applied, - /// Rejected, or a duplicate of a known-invalid block. - Rejected, - /// The node could not conclude; the ledger reconciles from the chain. - Undetermined, - /// The submit call itself failed; chain outcome unknown. - NodeError, -} - /// How a connection ended. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SessionEnd { @@ -344,122 +330,6 @@ impl ServerConfig { } } -// --------------------------------------------------------------------------- -// Pool core: the shared JobBook -// --------------------------------------------------------------------------- - -#[derive(Default)] -struct Published { - /// Increments on every fresh job. - seq: u64, - /// `seq` of the newest job that was installed with `clean_jobs`. - clean_seq: u64, - /// Newest job while it is active; `None` after `invalidate_active`. - job: Option>, -} - -struct Latest { - seq: u64, - clean_seq: u64, - job: Option>, -} - -/// A session slot in the book, closed on drop. -struct Lease { - pool: Arc, - session: BookSession, -} - -impl Drop for Lease { - fn drop(&mut self) { - let _ = locked(&self.pool.book).close_session(self.session.id()); - } -} - -#[derive(Clone, Debug)] -enum Pending { - Share { - worker: WorkerIdentity, - share: AcceptedShare, - completion: Arc, - }, - BlockRecord { - worker: WorkerIdentity, - block: Box, - submission: Result, - completion: Arc, - }, -} - -struct Completion { - remaining: AtomicUsize, -} - -/// Debug shows only the outstanding count; a completion carries no secrets. -impl fmt::Debug for Completion { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Completion") - .field("remaining", &self.remaining.load(Ordering::Acquire)) - .finish() - } -} - -impl Completion { - fn new(items: usize) -> Self { - Self { - remaining: AtomicUsize::new(items), - } - } - - fn record(&self) { - let _ = self - .remaining - .try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { - remaining.checked_sub(1) - }); - } - - fn is_complete(&self) -> bool { - self.remaining.load(Ordering::Acquire) == 0 - } -} - -impl Pending { - fn hash(&self) -> BlockHash { - match self { - Self::Share { share, .. } => share.hash, - Self::BlockRecord { block, .. } => block.share.hash, - } - } - - fn completion(&self) -> &Completion { - match self { - Self::Share { completion, .. } | Self::BlockRecord { completion, .. } => completion, - } - } - - fn bytes_cost(&self) -> usize { - match self { - Self::Share { .. } => SHARE_RECORD_BYTES, - Self::BlockRecord { block, .. } => BLOCK_RECORD_FRAMING_BYTES + block.raw.len(), - } - } -} - -enum SubmitFailure { - Share(ShareError), - Duplicate, - OutboxFull, -} - -struct Credited { - hash: BlockHash, - class: ShareClass, - completion: Arc, - block: Option, -} - struct DeferredResponse { id: RequestId, /// Whether the originating frame carried an `"id"` member. `false` is a @@ -472,131 +342,7 @@ struct DeferredResponse { completion: Arc, } -#[derive(Default)] -struct Outbox { - items: VecDeque, - total_bytes: usize, - in_flight: usize, - reserved_items: usize, - reserved_bytes: usize, -} - -/// Buffered cost of one accepted share record. -const SHARE_RECORD_BYTES: usize = 256; -/// Buffered cost of one block record without its block body. -const BLOCK_RECORD_FRAMING_BYTES: usize = 512; - -/// Atomically holds outbox space for one in-flight submit: two item slots and -/// the share plus block-record framing bytes. A block body is admitted over -/// the byte bound rather than shedding a found block, so one in-flight submit -/// never spends the whole pool byte budget. -struct OutboxReservation<'a> { - pool: &'a PoolCore, - items: usize, - bytes: usize, - active: bool, -} -struct DrainGuard<'a>(&'a AtomicBool); - -impl Drop for DrainGuard<'_> { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } -} - -impl OutboxReservation<'_> { - /// Commits an item into the outbox by consuming part of this reservation. - /// Item slots are linearizable under the single outbox mutex, so the - /// pre-allocated slots are guaranteed. A block body is admitted over the - /// byte bound rather than shedding a found block, and still counts toward - /// `total_bytes`. - fn commit_reserved(&mut self, item: Pending) { - let cost = item.bytes_cost(); - let mut outbox = locked(&self.pool.outbox); - outbox.reserved_items = outbox.reserved_items.saturating_sub(1); - outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(cost); - outbox.total_bytes += cost; - outbox.items.push_back(item); - let total = outbox.items.len() + outbox.in_flight; - self.pool.pending_count.store(total, Ordering::Release); - self.items = self.items.saturating_sub(1); - self.bytes = self.bytes.saturating_sub(cost); - } -} - -impl Drop for OutboxReservation<'_> { - fn drop(&mut self) { - if self.active { - let mut outbox = locked(&self.pool.outbox); - outbox.reserved_items = outbox.reserved_items.saturating_sub(self.items); - outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(self.bytes); - self.active = false; - } - } -} - -struct ShareRequest<'a> { - session: SessionId, - job: JobId, - rolled: &'a [u8], - ntime: u32, - nonce: u32, - /// Canonical header version, normalized by the SV1 edge before the - /// common submit owner runs. - version: u32, -} - -/// The pool: one `JobBook`, the node control it submits blocks to, and the -/// ledger it credits. Shared by every connection thread. -pub struct PoolCore { - book: Mutex, - published: Mutex, - control: Arc, - ledger: Arc, - layout: ExtranonceLayout, - version_mask: u32, - budget: CoinbaseBudget, - ledger_attempts: u32, - ledger_backoff: Duration, - outbox: Mutex, - outbox_max_items: usize, - outbox_max_bytes: usize, - pending_count: AtomicUsize, - draining: AtomicBool, -} - -/// A template fetched and installed by [`PoolCore::refresh`]. -#[derive(Debug)] -pub struct Refreshed { - /// What `JobBook::install` did. - pub installed: Installed, - /// Pass to the next [`PoolCore::refresh`] to long-poll for a newer template. - pub long_poll_id: String, - /// The template's `submit_old`. - pub submit_old: Option, -} - -/// [`PoolCore::refresh`] failure. -#[derive(Debug)] -pub enum RefreshError { - /// The node would not give a template. - Template(MiningControlError), - /// The job book would not project it. - Install(JobError), -} - -impl fmt::Display for RefreshError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Template(error) => write!(f, "template fetch failed: {error}"), - Self::Install(error) => write!(f, "job install failed: {error}"), - } - } -} - -impl Error for RefreshError {} - -fn share_error_wire(error: &ShareError) -> crate::WireError { +fn share_error_wire(error: &ShareError) -> super::WireError { match error { ShareError::UnknownSession(_) | ShareError::SessionPrefixLength { .. } => errors::INTERNAL, ShareError::UnknownJob(_) @@ -614,479 +360,6 @@ fn share_error_wire(error: &ShareError) -> crate::WireError { } } -impl PoolCore { - /// Builds the pool over a fresh `JobBook`. Retries a failed ledger call - /// three times with a 5 ms base backoff; see [`Self::with_ledger_retry`]. - pub fn new( - config: JobBookConfig, - control: Arc, - ledger: Arc, - ) -> Result { - let layout = config.extranonce; - let version_mask = config.version_mask; - let budget = config.budget; - let book = JobBook::new(config)?; - Ok(Self { - book: Mutex::new(book), - published: Mutex::new(Published::default()), - control, - ledger, - layout, - version_mask, - budget, - ledger_attempts: 3, - ledger_backoff: Duration::from_millis(5), - outbox: Mutex::new(Outbox::default()), - outbox_max_items: 4096, - outbox_max_bytes: 32 * 1024 * 1024, - pending_count: AtomicUsize::new(0), - draining: AtomicBool::new(false), - }) - } - - /// Sets the ledger retry budget: `attempts` calls (at least one) with a - /// sleep of `backoff * attempt` plus a per-share jitter of up to 2.5 ms - /// between them. - #[must_use] - pub fn with_ledger_retry(mut self, attempts: u32, backoff: Duration) -> Self { - self.ledger_attempts = attempts.max(1); - self.ledger_backoff = backoff; - self - } - - /// Sets the outbox bounds for queuing writes when the ledger is unavailable. - #[must_use] - pub fn with_outbox(mut self, max_items: usize, max_bytes: usize) -> Self { - self.outbox_max_items = max_items.max(1); - self.outbox_max_bytes = max_bytes.max(1024); - self - } - - /// Number of writes currently waiting in the outbox. - #[must_use] - pub fn pending_ledger_writes(&self) -> usize { - self.pending_count.load(Ordering::Acquire) - } - - /// Extranonce split every session uses (SV1 `extranonce2_size` is its - /// `rolled_len`). - #[must_use] - pub fn layout(&self) -> ExtranonceLayout { - self.layout - } - - /// Version bits the pool lets miners roll (zero: no rolling). - #[must_use] - pub fn version_mask(&self) -> u32 { - self.version_mask - } - - /// Open sessions in the book. - #[must_use] - pub fn session_count(&self) -> usize { - locked(&self.book).session_count() - } - - /// Runs an owner-local validation operation under the sole authoritative - /// `JobBook` mutex. The callback must not re-enter `PoolCore`, install or - /// invalidate jobs, or perform node, ledger, or other external I/O while - /// this lock is held. - pub fn with_job_book(&self, operation: impl FnOnce(&mut JobBook) -> R) -> R { - let mut book = locked(&self.book); - operation(&mut book) - } - - /// Returns the active job from the same authoritative `JobBook` used by - /// SV1 validation and session allocation. - #[must_use] - pub fn current_job(&self) -> Option> { - self.with_job_book(|book| book.current().cloned()) - } - - /// Returns the latest installation metadata from the authoritative pool - /// owner. The value is absent before the first install or after explicit - /// invalidation. - #[must_use] - pub fn current_installed(&self) -> Option { - self.with_job_book(|book| book.current_installed().cloned()) - } - - /// Installs `candidate` (with the pool's coinbase `extra_outputs`) and, when - /// it is a fresh job, publishes it to every session at its next poll. - pub fn install( - &self, - candidate: Arc, - extra_outputs: Vec, - ) -> Result { - self.install_with(false, candidate, extra_outputs) - } - - /// Marks every active job stale. Sessions keep their last job until the - /// next install, whose `clean_jobs` is then true; shares on the stale jobs - /// are rejected by the book. - pub fn invalidate_active(&self) -> Vec { - let mut book = locked(&self.book); - let stale = book.invalidate_active(); - let mut published = locked(&self.published); - published.job = None; - stale - } - - /// Fetches one template from the node (long-polling on `long_poll_id`) and - /// installs it. `submit_old == Some(false)` first invalidates active jobs. - pub fn refresh( - &self, - long_poll_id: Option<&str>, - extra_outputs: Vec, - ) -> Result { - let template = fetch_template(&*self.control, self.budget, long_poll_id) - .map_err(RefreshError::Template)?; - let next = template.candidate.template_id.as_str().to_owned(); - let invalidate = template.submit_old == Some(false); - let installed = self - .install_with(invalidate, Arc::clone(&template.candidate), extra_outputs) - .map_err(RefreshError::Install)?; - Ok(Refreshed { - installed, - long_poll_id: next, - submit_old: template.submit_old, - }) - } - - fn install_with( - &self, - invalidate: bool, - candidate: Arc, - extra_outputs: Vec, - ) -> Result { - let mut book = locked(&self.book); - if invalidate && !book.invalidate_active().is_empty() { - locked(&self.published).job = None; - } - let installed = book.install(candidate, extra_outputs)?; - if installed.fresh { - let mut published = locked(&self.published); - published.seq = published.seq.saturating_add(1); - published.job = Some(Arc::clone(&installed.job)); - if installed.clean_jobs { - published.clean_seq = published.seq; - } - } - Ok(installed) - } - - fn open_lease(self: &Arc) -> Result { - let session = locked(&self.book).open_session()?; - Ok(Lease { - pool: Arc::clone(self), - session, - }) - } - - fn latest(&self) -> Latest { - let published = locked(&self.published); - Latest { - seq: published.seq, - clean_seq: published.clean_seq, - job: published.job.clone(), - } - } - - fn retry_pause(&self, attempt: u32, hash: BlockHash) { - let jitter = u64::from(Hash256::from(hash).to_le_bytes()[0]) * 10; - thread::sleep(self.ledger_backoff * (attempt + 1) + Duration::from_micros(jitter)); - } - - fn submit_block_to_node(&self, found: &FoundBlock) -> Result { - const ATTEMPTS: u32 = 5; - let mut attempt = 0; - loop { - match found.submit(&*self.control) { - Ok(receipt) => { - if receipt.status() == BlockStatus::Applied { - self.invalidate_active(); - } - return Ok(receipt); - } - Err( - MiningControlError::Unavailable(message) | MiningControlError::Failed(message), - ) => { - attempt += 1; - if attempt == ATTEMPTS { - return Err(MiningControlError::Failed(message)); - } - self.retry_pause(attempt - 1, found.share.hash); - } - Err(non_retriable) => return Err(non_retriable), - } - } - } - - fn attempt_item(&self, item: &Pending) -> Result { - match item { - Pending::Share { worker, share, .. } => self.ledger.record_share(worker, share), - Pending::BlockRecord { - worker, - block, - submission, - .. - } => self - .ledger - .record_block(worker, block, submission) - .map(|()| RecordOutcome::Recorded), - } - } - - fn deliver_inline(&self, item: &Pending) -> Result { - self.drain_outbox(); - if self.pending_count.load(Ordering::Acquire) > 0 { - return Err(LedgerUnavailable); - } - let hash = item.hash(); - for attempt in 0..self.ledger_attempts { - match self.attempt_item(item) { - Ok(outcome) => return Ok(outcome), - Err(LedgerUnavailable) => { - if attempt + 1 < self.ledger_attempts { - self.retry_pause(attempt, hash); - } - } - } - } - Err(LedgerUnavailable) - } - - /// Drains pending outbox items sequentially. - pub fn drain_outbox(&self) { - if self.pending_count.load(Ordering::Acquire) == 0 { - return; - } - if self - .draining - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) - .is_err() - { - return; - } - let _guard = DrainGuard(&self.draining); - - loop { - let item = { - let mut outbox = locked(&self.outbox); - let Some(item) = outbox.items.pop_front() else { - outbox.in_flight = 0; - self.pending_count.store(0, Ordering::Release); - break; - }; - outbox.total_bytes = outbox.total_bytes.saturating_sub(item.bytes_cost()); - outbox.in_flight = 1; - self.pending_count - .store(outbox.items.len() + 1, Ordering::Release); - item - }; - - let mut succeeded = false; - for attempt in 0..self.ledger_attempts { - match self.attempt_item(&item) { - Ok(_) => { - item.completion().record(); - succeeded = true; - break; - } - Err(LedgerUnavailable) => { - if attempt + 1 < self.ledger_attempts { - self.retry_pause(attempt, item.hash()); - } - } - } - } - - let mut outbox = locked(&self.outbox); - outbox.in_flight = 0; - if succeeded { - self.pending_count - .store(outbox.items.len(), Ordering::Release); - } else { - outbox.total_bytes += item.bytes_cost(); - outbox.items.push_front(item); - self.pending_count - .store(outbox.items.len(), Ordering::Release); - break; - } - } - } - - /// Atomically reserves outbox space for one share (and potential block). - /// Returns `None` if the outbox has reached capacity. - /// The check-and-hold is linearizable under the single outbox mutex. A - /// second submit while the ledger is unavailable is shed with a retryable - /// accounting error, not buffered. - fn reserve_outbox_space(&self) -> Option> { - self.drain_outbox(); - // Two item slots, clamped so configured minima still admit one submit - // on an empty outbox. The byte half covers the share and the - // block-record framing only: the block body is admitted over the byte - // bound rather than shed, so one in-flight submit never spends the - // whole pool budget and stalls the pool at a single share. - let reserve_items = 2_usize.min(self.outbox_max_items); - let reserve_bytes = - (SHARE_RECORD_BYTES + BLOCK_RECORD_FRAMING_BYTES).min(self.outbox_max_bytes); - let mut outbox = locked(&self.outbox); - let committed_items = outbox.items.len() + outbox.reserved_items; - let committed_bytes = outbox.total_bytes.saturating_add(outbox.reserved_bytes); - if committed_items.saturating_add(reserve_items) > self.outbox_max_items - || committed_bytes.saturating_add(reserve_bytes) > self.outbox_max_bytes - { - None - } else { - outbox.reserved_items += reserve_items; - outbox.reserved_bytes += reserve_bytes; - Some(OutboxReservation { - pool: self, - items: reserve_items, - bytes: reserve_bytes, - active: true, - }) - } - } - - fn submit( - &self, - worker: &WorkerIdentity, - request: &ShareRequest<'_>, - target: &ShareTarget, - ) -> Result { - // Atomically reserve space BEFORE validation. If the outbox is at - // capacity, fail with backpressure so the miner may retry and the hash - // is never burned in `JobBook`. - let mut reservation = self - .reserve_outbox_space() - .ok_or(SubmitFailure::OutboxFull)?; - - let outcome = { - let mut book = locked(&self.book); - // `request.version` is already canonical: the edge normalized the - // wire bits, so this common path holds no wire branching. - let submission = ShareSubmission { - session: request.session, - job: request.job, - rolled_extranonce: request.rolled, - ntime: request.ntime, - nonce: request.nonce, - version: request.version, - }; - book.validate(&submission, target) - .map_err(SubmitFailure::Share)? - }; - - match outcome { - ShareOutcome::Share(accepted) => { - let hash = accepted.hash; - let completion = Arc::new(Completion::new(1)); - let item = Pending::Share { - worker: worker.clone(), - share: *accepted, - completion: Arc::clone(&completion), - }; - // Attempt inline delivery first; otherwise the completion waits on the outbox. - // Delivery is at-least-once (inline plus drain) against the - // idempotent ledger consumer, which is keyed on `share.hash`. - match self.deliver_inline(&item) { - Ok(RecordOutcome::Recorded) => completion.record(), - // Already durable (crash restore or post-eviction replay) - // or permanently refused (the ledger judged this work - // first): answer the duplicate identity at once with no - // retry and no buffering. The book already burned the - // hash, so a miner retry converges to the same duplicate - // verdict instead of spinning on an uncurable refusal. - Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { - return Err(SubmitFailure::Duplicate); - } - Err(LedgerUnavailable) => reservation.commit_reserved(item), - } - Ok(Credited { - hash, - class: ShareClass::Share, - completion, - block: None, - }) - } - ShareOutcome::Block(found) => { - // Priority invariant: node submission runs before any ledger record. - let submission = self.submit_block_to_node(&found); - let block_outcome = match &submission { - Ok(receipt) => match receipt.status() { - BlockStatus::Applied => BlockOutcome::Applied, - BlockStatus::Rejected => BlockOutcome::Rejected, - BlockStatus::Undetermined => BlockOutcome::Undetermined, - }, - Err(_) => BlockOutcome::NodeError, - }; - let hash = found.share.hash; - let job = found.share.job; - let stale_parent = submission.as_ref().is_ok_and(BlockReceipt::is_stale_parent); - let completion = Arc::new(Completion::new(2)); - let share_item = Pending::Share { - worker: worker.clone(), - share: found.share.clone(), - completion: Arc::clone(&completion), - }; - let block_item = Pending::BlockRecord { - worker: worker.clone(), - block: found, - submission, - completion: Arc::clone(&completion), - }; - - // The share needs no further crediting when it is already - // durable (crash restore or post-eviction replay) or - // permanently refused (the ledger judged this work first). - // Either way the block record still needs delivery, so keep - // going and report the duplicate only after the block fate is - // known. No retry and no buffering on the settled path: a - // settled share can never become deliverable. - let mut share_settled = false; - match self.deliver_inline(&share_item) { - Ok(RecordOutcome::Recorded) => completion.record(), - Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { - completion.record(); - share_settled = true; - } - Err(LedgerUnavailable) => reservation.commit_reserved(share_item), - } - - // Deliver or buffer block record. - if self.deliver_inline(&block_item).is_err() { - reservation.commit_reserved(block_item); - } else { - completion.record(); - } - - if stale_parent { - return Err(SubmitFailure::Share(ShareError::Stale { - job, - reason: StaleReason::Invalidated, - })); - } - - if share_settled { - // The share earned no new credit, but the block round is - // now durable: the replay keeps its duplicate identity, - // exactly as a book-level duplicate would. - return Err(SubmitFailure::Duplicate); - } - - Ok(Credited { - hash, - class: ShareClass::Block, - completion, - block: Some(block_outcome), - }) - } - } - } -} - // --------------------------------------------------------------------------- // Shared server state // --------------------------------------------------------------------------- diff --git a/crates/stratum-v2/src/lib.rs b/crates/mining/src/pool/stratum/sv2.rs similarity index 99% rename from crates/stratum-v2/src/lib.rs rename to crates/mining/src/pool/stratum/sv2.rs index 0aafac3c55..45d1e3d820 100644 --- a/crates/stratum-v2/src/lib.rs +++ b/crates/mining/src/pool/stratum/sv2.rs @@ -16,7 +16,7 @@ //! `CloseChannel`, `Reconnect`, and the error and cancel semantics around //! them, using the official message layouts from `mining_sv2 12.0.0` and //! `common_messages_sv2 9.0.0`. -//! 3. **The one job and share owner**, [`bitcoin_rs_stratum::JobBook`]. This +//! 3. **The one job and share owner**, [`crate::pool::JobBook`]. This //! crate never assembles a template, never builds a coinbase, and never //! validates a share or a block: it passes the owner's `Job` to the wire //! and maps the owner's `ShareError` to an official error code. @@ -91,7 +91,7 @@ pub use session::{ use std::fmt; use std::time::Duration; -use bitcoin_rs_stratum::{Installed, JobBook, ShareTarget}; +use crate::pool::{Installed, JobBook, ShareTarget}; use codec_sv2::{ Decoded, Decrypted, ENCRYPTED_SV2_FRAME_HEADER_SIZE, Handshake, NoiseDecoder, NoiseEncoder, SV2_FRAME_PLAINTEXT_CHUNK_SIZE, SerializedFrame, TransportDecryptState, TransportEncryptState, diff --git a/crates/stratum-v2/src/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs similarity index 99% rename from crates/stratum-v2/src/session.rs rename to crates/mining/src/pool/stratum/sv2/session.rs index db14fd7b9c..f825409317 100644 --- a/crates/stratum-v2/src/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -7,17 +7,17 @@ //! //! The session is a *wire* adapter. It owns channel identity, the message //! layouts, and the per-channel share target. Everything else is delegated to -//! the one owner, [`bitcoin_rs_stratum::JobBook`]: +//! the one owner, [`crate::pool::JobBook`]: //! //! - job projection (coinbase split, merkle branch, header facts) comes from -//! [`bitcoin_rs_stratum::Job`]; +//! [`crate::pool::Job`]; //! - extranonce uniqueness and session identity come from //! [`JobBook::open_session`] / [`JobBook::close_session`]; //! - every share verdict (stale, duplicate, version mask, time window, target, //! network target) comes from [`JobBook::validate`], whose rejection is only //! *mapped* here to an official SV2 error code; //! - a block candidate is handed to the host as the owner's -//! [`bitcoin_rs_stratum::FoundBlock`]; the node's authoritative submit path is +//! [`crate::pool::FoundBlock`]; the node's authoritative submit path is //! called by the host through [`FoundBlock::submit`], never from here. //! //! One wire rule is checked here before delegating, because it is a rule about @@ -38,8 +38,8 @@ use std::fmt; use std::sync::Arc; use binary_sv2::{B032, B064K, Decodable, Encodable, GetSize, Seq0255, Str0255, Sv2Option, U256}; -use bitcoin_rs_mining::{MiningControl, MiningControlError}; -use bitcoin_rs_stratum::{ +use crate::{MiningControl, MiningControlError}; +use crate::pool::{ AcceptedShare, BlockReceipt, Extranonce, FoundBlock, Job, JobBook, JobError, JobId, Session, SessionId, ShareError, ShareOutcome, ShareSubmission, ShareTarget, }; @@ -96,7 +96,7 @@ use mining_sv2::{ use ruint::Uint; use thiserror::Error; -use crate::{ConfigError, Frame, MAX_EXTRANONCE_WIRE_LEN, PROTOCOL_VERSION, U24_MAX}; +use super::{ConfigError, Frame, MAX_EXTRANONCE_WIRE_LEN, PROTOCOL_VERSION, U24_MAX}; /// `SetupConnection.flags` bit 0: the client can only process standard jobs. pub const FLAG_REQUIRES_STANDARD_JOBS: u32 = 1 << 0; @@ -673,7 +673,7 @@ fn code_str(code: &str) -> Result, SessionError> { /// /// Spec §5.3.13: `SubmitShares.Success.new_shares_sum` is the sum of the /// *difficulty* of the jobs the shares were submitted against, NOT the raw -/// hash count ([`bitcoin_rs_stratum::ShareWork`]), which is scaled by 2^32. +/// hash count ([`crate::pool::ShareWork`]), which is scaled by 2^32. /// Targets easier than difficulty 1 clamp to 1 so that acknowledged shares /// always contribute at least 1 unit to the acknowledged shares sum. pub fn target_difficulty(target: ShareTarget) -> u64 { @@ -1352,7 +1352,7 @@ impl ProtocolSession { /// `NewExtendedMiningJob`; the others, one job per channel. pub fn announce( &mut self, - installed: &bitcoin_rs_stratum::Installed, + installed: &crate::pool::Installed, ) -> Result<(), SessionError> { // Delivery is driven by per-connection channel state, not the // owner's `fresh` flag: a channel opened after the install (or one diff --git a/crates/stratum/tests/jobs.rs b/crates/mining/tests/pool_jobs.rs similarity index 99% rename from crates/stratum/tests/jobs.rs rename to crates/mining/tests/pool_jobs.rs index 3abcd1927d..39d5b55b58 100644 --- a/crates/stratum/tests/jobs.rs +++ b/crates/mining/tests/pool_jobs.rs @@ -29,7 +29,7 @@ use bitcoin_rs_primitives::{ Script, Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::consensus_bytes, }; use bitcoin_rs_script::VerifyFlags; -use bitcoin_rs_stratum::{ +use bitcoin_rs_mining::pool::{ BIP320_VERSION_MASK, BlockStatus, Extranonce, ExtranonceLayout, Job, JobBook, JobBookConfig, JobError, JobId, Session, SessionId, ShareClass, ShareError, ShareOutcome, ShareSubmission, ShareTarget, ShareWork, StaleReason, fetch_template, diff --git a/crates/pool-ledger/tests/ledger.rs b/crates/mining/tests/pool_ledger.rs similarity index 65% rename from crates/pool-ledger/tests/ledger.rs rename to crates/mining/tests/pool_ledger.rs index ecc89416c4..58c15aea91 100644 --- a/crates/pool-ledger/tests/ledger.rs +++ b/crates/mining/tests/pool_ledger.rs @@ -1,9 +1,9 @@ //! The complete accounting suite runs against each real node storage backend. mod redb { const BACKEND: &str = "redb"; - include!("support/ledger_cases.rs"); + include!("support/pool_ledger_cases.rs"); } mod fjall { const BACKEND: &str = "fjall"; - include!("support/ledger_cases.rs"); + include!("support/pool_ledger_cases.rs"); } diff --git a/crates/stratum/tests/protocol.rs b/crates/mining/tests/pool_sv1.rs similarity index 98% rename from crates/stratum/tests/protocol.rs rename to crates/mining/tests/pool_sv1.rs index 626f5e7710..d32dd92d3c 100644 --- a/crates/stratum/tests/protocol.rs +++ b/crates/mining/tests/pool_sv1.rs @@ -26,11 +26,14 @@ use bitcoin_rs_mining::{ use bitcoin_rs_primitives::{ Amount, CompactTarget, Hash256, LockTime, OutPoint, Script, Sequence, Tx, TxIn, TxOut, Witness, }; -use bitcoin_rs_stratum::{ - AcceptedShare, ConnectionLimiter, DifficultyPolicy, Extranonce, ExtranonceLayout, FoundBlock, - FrameError, Job, JobBookConfig, LineBuffer, Observer, PoolCore, RecordOutcome, RefusalReason, - Server, ServerConfig, ServerDeps, ServerEvent, ShareLedger, StaticAccountResolver, - prevhash_from_wire, prevhash_to_wire, swap_words, +use bitcoin_rs_mining::pool::stratum::sv1::{ + ConnectionLimiter, DifficultyPolicy, FrameError, LineBuffer, Observer, RefusalReason, Server, + ServerConfig, ServerDeps, ServerEvent, StaticAccountResolver, prevhash_from_wire, + prevhash_to_wire, swap_words, +}; +use bitcoin_rs_mining::pool::{ + AcceptedShare, Extranonce, ExtranonceLayout, FoundBlock, Job, JobBookConfig, PoolCore, + RecordOutcome, ShareLedger, }; type TestResult = Result<(), Box>; @@ -98,9 +101,9 @@ impl TestLedger { impl ShareLedger for TestLedger { fn record_share( &self, - worker: &bitcoin_rs_stratum::WorkerIdentity, + worker: &bitcoin_rs_mining::pool::WorkerIdentity, share: &AcceptedShare, - ) -> Result { + ) -> Result { self.share_calls.fetch_add(1, Ordering::SeqCst); if self.reject_shares.load(Ordering::SeqCst) { return Ok(RecordOutcome::Rejected); @@ -113,7 +116,7 @@ impl ShareLedger for TestLedger { && *remaining > 0 { *remaining -= 1; - return Err(bitcoin_rs_stratum::LedgerUnavailable); + return Err(bitcoin_rs_mining::pool::LedgerUnavailable); } let mut shares = self .shares @@ -132,10 +135,10 @@ impl ShareLedger for TestLedger { fn record_block( &self, - worker: &bitcoin_rs_stratum::WorkerIdentity, + worker: &bitcoin_rs_mining::pool::WorkerIdentity, block: &FoundBlock, - _submission: &Result, - ) -> Result<(), bitcoin_rs_stratum::LedgerUnavailable> { + _submission: &Result, + ) -> Result<(), bitcoin_rs_mining::pool::LedgerUnavailable> { self.blocks .lock() .unwrap_or_else(|error| panic!("test operation failed: {error}")) diff --git a/crates/stratum-v2/tests/protocol.rs b/crates/mining/tests/pool_sv2.rs similarity index 99% rename from crates/stratum-v2/tests/protocol.rs rename to crates/mining/tests/pool_sv2.rs index a0a518696b..df84abd67e 100644 --- a/crates/stratum-v2/tests/protocol.rs +++ b/crates/mining/tests/pool_sv2.rs @@ -5,7 +5,7 @@ //! encrypted frames, real regtest candidates from the node's own //! `assemble_candidate` over a mempool filled through the public //! `MempoolGateway::submit_transaction` path, and the real -//! `bitcoin_rs_stratum::JobBook` as the one job +//! `bitcoin_rs_mining::pool::JobBook` as the one job //! and share predicate. The miner side is a minimal SV2 client built from the //! same crates (a hand-written client would only test itself). //! @@ -33,11 +33,11 @@ use bitcoin_rs_primitives::{ Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::double_sha256, }; use bitcoin_rs_script::VerifyFlags; -use bitcoin_rs_stratum::{ +use bitcoin_rs_mining::pool::{ BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Installed, Job, JobBook, JobBookConfig, JobId, ShareClass, ShareError, ShareTarget, }; -use bitcoin_rs_stratum_v2::{ +use bitcoin_rs_mining::pool::stratum::sv2::{ AuthorityKeypair, CloseReason, Connection, Error, Event, FLAG_REQUIRES_STANDARD_JOBS, FLAG_REQUIRES_VERSION_ROLLING, FLAG_REQUIRES_WORK_SELECTION, Limits, ROLES, RoleStatus, SUCCESS_FLAG_REQUIRES_FIXED_VERSION, ServerConfig, SessionError, TransportError, @@ -741,7 +741,7 @@ fn share_error(frames: &mut Vec) -> (u32, u32, String) { ) } -fn accepted(events: Vec) -> bitcoin_rs_stratum_v2::ShareEvent { +fn accepted(events: Vec) -> bitcoin_rs_mining::pool::stratum::sv2::ShareEvent { events .into_iter() .find_map(|event| match event { @@ -1538,7 +1538,7 @@ fn the_host_can_refuse_to_credit_a_share() { .conn .reject_share( share.ticket, - bitcoin_rs_stratum_v2::ERROR_CODE_SHARE_NOT_RECORDED + bitcoin_rs_mining::pool::stratum::sv2::ERROR_CODE_SHARE_NOT_RECORDED ) .unwrap_or_else(|error| panic!("flushes: {error:?}")) ); @@ -2630,7 +2630,7 @@ fn malformed_reject_code_preserves_the_pending_ticket() { .conn .reject_share( share.ticket, - bitcoin_rs_stratum_v2::ERROR_CODE_SHARE_NOT_RECORDED + bitcoin_rs_mining::pool::stratum::sv2::ERROR_CODE_SHARE_NOT_RECORDED ) .unwrap_or_else(|error| panic!("valid refusal sends: {error:?}")) ); diff --git a/crates/pool-ledger/tests/support/kv_transactions.rs b/crates/mining/tests/support/kv_transactions.rs similarity index 100% rename from crates/pool-ledger/tests/support/kv_transactions.rs rename to crates/mining/tests/support/kv_transactions.rs diff --git a/crates/pool-ledger/tests/support/ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs similarity index 99% rename from crates/pool-ledger/tests/support/ledger_cases.rs rename to crates/mining/tests/support/pool_ledger_cases.rs index b3fcf46694..7055c99b90 100644 --- a/crates/pool-ledger/tests/support/ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -4,7 +4,7 @@ use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; -use pool_ledger::*; +use bitcoin_rs_mining::pool::ledger::*; use std::sync::Arc; use bitcoin_rs_storage::{ColumnFamily, FjallStore, KvStore, RedbStore}; use tempfile::TempDir; diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 5f9adbb7fb..614c5f862f 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -59,8 +59,8 @@ redb = [ kernel = ["bitcoin-rs-chainstate/kernel"] zmq = ["bitcoin-rs-rpc/zmq"] silent-payments = ["bitcoin-rs-rpc/silent-payments"] -pool = ["bitcoin-rs-rpc/pool", "dep:bitcoin-rs-stratum", "dep:bitcoin-rs-pool-ledger"] -pool-sv2 = ["pool", "dep:bitcoin-rs-stratum-v2", "dep:zeroize", "dep:binary_sv2", "dep:codec_sv2", "dep:common_messages_sv2", "dep:mining_sv2", "dep:noise_sv2"] +pool = ["bitcoin-rs-rpc/pool", "bitcoin-rs-mining/pool"] +pool-sv2 = ["pool", "bitcoin-rs-mining/pool-sv2", "dep:zeroize"] [dependencies] hashbrown.workspace = true @@ -76,22 +76,7 @@ bitcoin-rs-mempool.workspace = true bitcoin-rs-p2p.workspace = true bitcoin-rs-mining.workspace = true bitcoin-rs-rpc.workspace = true -bitcoin-rs-stratum = { workspace = true, optional = true } -bitcoin-rs-stratum-v2 = { workspace = true, optional = true } -bitcoin-rs-pool-ledger = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } -# Pool-only SRI crates, used solely by the `pool/sv2.rs` host-path regression -# (which compiles only under `pool-sv2`). Optional normal dependencies: Cargo -# forbids `optional` dev-dependencies and does not honor `cfg(feature)` in -# target dev tables, so this is the supported wiring that keeps non-pool -# graphs from resolving the Noise/secp duplicate-crypto chain documented in -# deny.toml. The same pinned SRI release family the pool adapter links, never -# a second wire version. -binary_sv2 = { workspace = true, optional = true } -codec_sv2 = { workspace = true, features = ["noise_sv2"], optional = true } -common_messages_sv2 = { workspace = true, optional = true } -mining_sv2 = { workspace = true, optional = true } -noise_sv2 = { workspace = true, optional = true } bitcoin.workspace = true bytes.workspace = true @@ -112,6 +97,7 @@ thiserror.workspace = true anyhow.workspace = true rayon.workspace = true sha2.workspace = true +sonic-rs.workspace = true [target.'cfg(unix)'.dependencies] rustix.workspace = true @@ -134,6 +120,13 @@ rustix.workspace = true bitcoin-rs-script.workspace = true bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } +# Miner side of the SV2 host-path regression in `pool/sv2.rs`: the same pinned +# SRI release family the pool adapter links, never a second wire version. +binary_sv2.workspace = true +codec_sv2 = { workspace = true, features = ["noise_sv2"] } +common_messages_sv2.workspace = true +mining_sv2.workspace = true +noise_sv2.workspace = true [[bench]] name = "sync_pipeline" diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 6706658031..c64b7498de 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -7,6 +7,8 @@ use bitcoin_rs_consensus::ValidationEngine; use bitcoin_rs_index::IndexCapabilities; use bitcoin_rs_primitives::Network; use bitcoin_rs_storage::StorageBackend; +#[cfg(feature = "pool")] +use bitcoin_rs_mining::pool::ledger as pool_ledger; use core::fmt; use crossbeam_channel::Receiver; use serde::Deserialize; @@ -145,7 +147,7 @@ pub struct PoolConfig { pub share_difficulty: String, /// Target resolved by the authoritative share arithmetic owner. #[cfg(feature = "pool")] - pub share_target: bitcoin_rs_stratum::ShareTarget, + pub share_target: bitcoin_rs_mining::pool::ShareTarget, /// Immutable PPLNS work window. pub window_work: u64, /// Immutable fee basis points. @@ -268,7 +270,7 @@ impl PoolConfig { #[cfg(feature = "pool")] { self.ledger_config().validate()?; - let target = bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal( + let target = bitcoin_rs_mining::pool::ShareTarget::from_pool_difficulty_decimal( &self.share_difficulty, ) .map_err(|error| anyhow::anyhow!("invalid pool share difficulty: {error}"))?; @@ -349,7 +351,7 @@ fn resolve_pool(settings: &UserConfig) -> Result> { .clone() .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-share-difficulty"))?, #[cfg(feature = "pool")] - share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal( + share_target: bitcoin_rs_mining::pool::ShareTarget::from_pool_difficulty_decimal( pool.share_difficulty .as_deref() .ok_or_else(|| anyhow::anyhow!("--pool requires --pool-share-difficulty"))?, diff --git a/crates/node/src/pool.rs b/crates/node/src/pool.rs index 8acbc54787..df500ae526 100644 --- a/crates/node/src/pool.rs +++ b/crates/node/src/pool.rs @@ -8,12 +8,13 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use anyhow::{Context as _, Result}; +use bitcoin_rs_mining::pool::ledger as pool_ledger; +use bitcoin_rs_mining::pool::stratum::sv1::{ + ConnectionLimiter, DifficultyPolicy, Server, ServerConfig, ServerDeps, +}; +use bitcoin_rs_mining::pool::{ExtranonceLayout, JobBookConfig, PoolCore}; use bitcoin_rs_mining::{CoinbaseBudget, MiningControl}; use bitcoin_rs_storage::KvStore; -use bitcoin_rs_stratum::{ - ConnectionLimiter, DifficultyPolicy, ExtranonceLayout, JobBookConfig, PoolCore, Server, - ServerConfig, ServerDeps, -}; #[cfg(feature = "pool-sv2")] mod authority; @@ -65,7 +66,7 @@ impl crate::storage_backend::StoreConsumer for LedgerComposer { /// (twice the connection cap), 65,536 duplicate-tracked shares per job, and a /// deferred share-ledger outbox of 1024 records / 8 MiB. Owner sessions are /// not live TCP connections; the aggregate TCP caps live in the shared -/// [`ConnectionLimiter`](bitcoin_rs_stratum::ConnectionLimiter). +/// [`ConnectionLimiter`](bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter). struct ComposedCore { book: JobBookConfig, outbox_items: usize, @@ -84,8 +85,8 @@ fn composed_core_config(max_connections: u32) -> Result { }, extranonce: ExtranonceLayout::new(4, 4), coinbase_tag: b"/btcrs/".to_vec(), - version_mask: bitcoin_rs_stratum::BIP320_VERSION_MASK, - ntime_forward_secs: bitcoin_rs_stratum::MAX_NTIME_FORWARD_SECS, + version_mask: bitcoin_rs_mining::pool::BIP320_VERSION_MASK, + ntime_forward_secs: bitcoin_rs_mining::pool::MAX_NTIME_FORWARD_SECS, max_jobs: 64, max_sessions: sessions, max_shares_per_job: 65_536, diff --git a/crates/node/src/pool/authority.rs b/crates/node/src/pool/authority.rs index 42b58ad864..700e86a2bb 100644 --- a/crates/node/src/pool/authority.rs +++ b/crates/node/src/pool/authority.rs @@ -1,7 +1,7 @@ use std::io::{self, Read as _}; use std::path::Path; -use bitcoin_rs_stratum_v2::AuthorityKeypair; +use bitcoin_rs_mining::pool::stratum::sv2::AuthorityKeypair; use thiserror::Error; use zeroize::Zeroizing; diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index 5af832b3d1..7ee7df89e7 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -7,15 +7,17 @@ use bitcoin_rs_mempool::MempoolReader; use bitcoin_rs_primitives::encode::deserialize; use bitcoin_rs_primitives::{Block, Hash256, OutPoint as NodeOutPoint, Txid}; use bitcoin_rs_storage::block_body::BlockBodyStore; -use bitcoin_rs_stratum::{ - AcceptedShare, AccountResolver, AuthDenied, BlockReceipt, FoundBlock, LedgerUnavailable, - Password, RecordOutcome, ShareLedger, WorkerIdentity, -}; -use pool_ledger::{ +use bitcoin_rs_mining::pool::ledger as pool_ledger; +use bitcoin_rs_mining::pool::ledger::{ BlockFound as LedgerBlockFound, BlockLocation, ChainView, ChainViewError, ConfirmedSpend, Ledger, MinerId, ObservedCoinbase, ShareCredit, ShareReceipt, TxOut, TxStatus, work_from_le_bytes, }; +use bitcoin_rs_mining::pool::stratum::sv1::{AccountResolver, AuthDenied, Password}; +use bitcoin_rs_mining::pool::{ + AcceptedShare, BlockReceipt, FoundBlock, LedgerUnavailable, RecordOutcome, ShareLedger, + WorkerIdentity, +}; /// Node-backed observation of the fully applied chain. /// diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index af272ccdd6..3a5e54e8ba 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -9,12 +9,14 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; use bitcoin_rs_primitives::BlockHash; -use bitcoin_rs_stratum::{BlockStatus, ConnectionLimiter, PoolCore, RecordOutcome}; -use bitcoin_rs_stratum_v2::{ +use bitcoin_rs_mining::pool::ledger as pool_ledger; +use bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter; +use bitcoin_rs_mining::pool::stratum::sv2::{ AdmitError, AuthorityKeypair, BlockSubmission, Connection, ERROR_CODE_SHARE_NOT_RECORDED, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, ServerConfig, }; -use pool_ledger::MinerId; +use bitcoin_rs_mining::pool::{BlockStatus, PoolCore, RecordOutcome}; +use bitcoin_rs_mining::pool::ledger::MinerId; use super::authority; use super::ledger::{BlockRecordOutcome, BlockRecordPermit, LedgerHost}; @@ -600,13 +602,13 @@ mod tests { use bitcoin_rs_primitives::{BlockHash, CompactTarget, Hash256, Network}; use bitcoin_rs_rpc::context::PoolOperator as _; use bitcoin_rs_storage::KvStore; - use bitcoin_rs_stratum::{ + use bitcoin_rs_mining::pool::stratum::sv2::{ + AdmitError, AuthorityKeypair, Connection, FLAG_REQUIRES_STANDARD_JOBS, Limits, ServerConfig, + }; + use bitcoin_rs_mining::pool::{ BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Job, JobBookConfig, MAX_NTIME_FORWARD_SECS, PoolCore, ShareLedger, ShareTarget, }; - use bitcoin_rs_stratum_v2::{ - AdmitError, AuthorityKeypair, Connection, FLAG_REQUIRES_STANDARD_JOBS, Limits, ServerConfig, - }; use codec_sv2::{ Decoded, Decrypted, Handshake, InitiatorSent, MessageFrame, NoiseDecoder, NoiseEncoder, TransportDecryptState, TransportEncryptState, @@ -623,7 +625,7 @@ mod tests { SubmitSharesStandard, }; use noise_sv2::{INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE, Initiator}; - use pool_ledger::{Ledger, MinerId, PayoutPolicy}; + use bitcoin_rs_mining::pool::ledger::{Ledger, MinerId, PayoutPolicy, PoolConfig}; use zeroize::Zeroizing; use super::{LedgerHost, announce_current_job, handle_events}; @@ -663,7 +665,7 @@ mod tests { fn ledger_host(path: &Path) -> Result, Box> { let store: Arc = Arc::new(Store::open(path)?); - let ledger = Ledger::create(store, pool_ledger::PoolConfig::new(1_000, PAYOUT.to_vec()))?; + let ledger = Ledger::create(store, PoolConfig::new(1_000, PAYOUT.to_vec()))?; ledger.set_payout_script(MinerId(42), PAYOUT)?; Ok(Arc::new(LedgerHost::new( Arc::new(ledger), diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index cb457f36df..0b95fffecd 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -6,13 +6,15 @@ use bitcoin_rs_mining::{BlockValidationResult, MiningControlError}; use bitcoin_rs_primitives::{Block, BlockHash, Hash256, Header, Txid}; use bitcoin_rs_rpc::context::PoolOperator; use bitcoin_rs_storage::KvStore; -use bitcoin_rs_stratum::{ - AcceptedShare, AccountResolver, BlockReceipt, FoundBlock, JobId, LedgerUnavailable, Password, - RecordOutcome, SessionId, ShareClass, ShareLedger, ShareTarget, WorkerIdentity, -}; -use pool_ledger::{ +use bitcoin_rs_mining::pool::ledger as pool_ledger; +use bitcoin_rs_mining::pool::ledger::{ Balance, Ledger, MinerId, PayoutPolicy, PoolConfig, RoundState, ShareCredit, ShareReceipt, }; +use bitcoin_rs_mining::pool::stratum::sv1::{AccountResolver, Password}; +use bitcoin_rs_mining::pool::{ + AcceptedShare, BlockReceipt, FoundBlock, JobId, LedgerUnavailable, RecordOutcome, SessionId, + ShareClass, ShareLedger, ShareTarget, WorkerIdentity, +}; use super::ledger::LedgerHost; @@ -340,7 +342,9 @@ fn block_record_reservations_are_bounded_and_released(open: OpenStore) -> TestRe #[test] fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResult { - let shared = Arc::new(bitcoin_rs_stratum::ConnectionLimiter::new(1, 1)?); + let shared = Arc::new( + bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(1, 1)?, + ); let sv1 = Arc::clone(&shared); let sv2 = Arc::clone(&shared); let first = sv1 @@ -348,7 +352,7 @@ fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResu .map_err(|reason| std::io::Error::other(format!("sv1 admission refused: {reason:?}")))?; assert!(matches!( sv2.try_admit("192.0.2.2".parse()?), - Err(bitcoin_rs_stratum::RefusalReason::ConnectionLimit) + Err(bitcoin_rs_mining::pool::stratum::sv1::RefusalReason::ConnectionLimit) )); assert_eq!(shared.active(), 1); drop(first); @@ -363,7 +367,9 @@ fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResu #[test] fn protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots() -> TestResult { - let shared = Arc::new(bitcoin_rs_stratum::ConnectionLimiter::new(3, 1)?); + let shared = Arc::new( + bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(3, 1)?, + ); let sv1 = Arc::clone(&shared); let sv2 = Arc::clone(&shared); let same_ip = "192.0.2.1".parse()?; @@ -372,7 +378,7 @@ fn protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots() - })?; assert!(matches!( sv2.try_admit(same_ip), - Err(bitcoin_rs_stratum::RefusalReason::PerIpLimit) + Err(bitcoin_rs_mining::pool::stratum::sv1::RefusalReason::PerIpLimit) )); let other = sv2.try_admit("192.0.2.2".parse()?).map_err(|reason| { std::io::Error::other(format!("second-IP admission refused: {reason:?}")) @@ -630,7 +636,7 @@ fn pool_prepare_holds_directory_lock_for_service_lifetime() -> TestResult { max_connections: 128, max_connections_per_ip: 8, share_difficulty: "1".to_owned(), - share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("1")?, + share_target: bitcoin_rs_mining::pool::ShareTarget::from_pool_difficulty_decimal("1")?, window_work: 1_000, fee_bps: 0, reward_script: vec![0x51], diff --git a/crates/node/tests/unit/config/tests.rs b/crates/node/tests/unit/config/tests.rs index 8010a90ad2..772f7fd6f4 100644 --- a/crates/node/tests/unit/config/tests.rs +++ b/crates/node/tests/unit/config/tests.rs @@ -113,7 +113,7 @@ fn pool_layers_preserve_explicit_fields_and_override_policy() -> anyhow::Result< assert_eq!(pool.share_difficulty, "2.5"); assert_eq!( pool.share_target, - bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("2.5")? + bitcoin_rs_mining::pool::ShareTarget::from_pool_difficulty_decimal("2.5")? ); assert_eq!(pool.ledger_config().window_work, 1000); assert_eq!(pool.ledger_config().pool_fee_bps, 125); diff --git a/crates/node/tests/unit/lifecycle/tests.rs b/crates/node/tests/unit/lifecycle/tests.rs index dbb6cd271f..c13ea12396 100644 --- a/crates/node/tests/unit/lifecycle/tests.rs +++ b/crates/node/tests/unit/lifecycle/tests.rs @@ -1083,7 +1083,7 @@ fn final_payout_script_prefers_pool_when_values_differ() { max_connections: 1, max_connections_per_ip: 1, share_difficulty: String::from("1"), - share_target: bitcoin_rs_stratum::ShareTarget::from_pool_difficulty_decimal("1") + share_target: bitcoin_rs_mining::pool::ShareTarget::from_pool_difficulty_decimal("1") .expect("fixed test difficulty parses"), window_work: 1, fee_bps: 0, diff --git a/crates/pool-ledger/Cargo.toml b/crates/pool-ledger/Cargo.toml deleted file mode 100644 index 6bc5a53f72..0000000000 --- a/crates/pool-ledger/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "bitcoin-rs-pool-ledger" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -version.workspace = true -authors.workspace = true -repository.workspace = true -description = "bitcoin-rs :: durable external-signer PPLNS pool ledger" - -[lib] -name = "pool_ledger" - -[features] -default = [] -# Explicitly test-only crash/fault hooks, never enabled by node production features. -test-seam = [] - -[[test]] -name = "ledger" -required-features = ["test-seam"] - -[lints] -workspace = true - -[dependencies] -bitcoin-rs-storage.workspace = true -sha2.workspace = true - -[dev-dependencies] -bitcoin-rs-storage = { workspace = true, features = ["test-seam", "redb", "fjall"] } -tempfile.workspace = true diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 21c51fcd38..d2ececb4bc 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [features] default = [] -pool = ["dep:bitcoin-rs-pool-ledger"] +pool = ["bitcoin-rs-mining/pool"] silent-payments = ["bitcoin-rs-index/silent-payments"] # Synthetic capability worlds for tests, not production composition. test-seam = ["bitcoin-rs-index/test-seam"] @@ -28,7 +28,6 @@ bitcoin-rs-mempool.workspace = true bitcoin-rs-p2p.workspace = true bitcoin-rs-utxo.workspace = true bitcoin-rs-mining.workspace = true -bitcoin-rs-pool-ledger = { workspace = true, optional = true } bitcoin-rs-script.workspace = true bitcoin.workspace = true miniscript.workspace = true diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index 91e07c0ab5..cd3f899766 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -8,6 +8,8 @@ use bitcoin_rs_mempool::{AdmissionChain, ChainAdmissionSnapshot, MempoolGateway, #[cfg(any(test, feature = "test-seam"))] use bitcoin_rs_mempool::{Mempool, MempoolLimits, MempoolObserver}; use bitcoin_rs_mining::MiningControl; +#[cfg(feature = "pool")] +use bitcoin_rs_mining::pool::ledger as pool_ledger; use bitcoin_rs_primitives::{ BlockHash, CompactTarget, Hash256, Network, OutPoint, Tx, consensus_bytes, unix_time_secs, }; diff --git a/crates/rpc/src/handlers/pool.rs b/crates/rpc/src/handlers/pool.rs index af5c1435c3..97c7aedbda 100644 --- a/crates/rpc/src/handlers/pool.rs +++ b/crates/rpc/src/handlers/pool.rs @@ -2,7 +2,8 @@ //! //! BRPR/BRPT bytes are the ledger's canonical codecs. Recording a receipt //! validates and persists an externally signed transaction; it never broadcasts it. -use pool_ledger::{ +use bitcoin_rs_mining::pool::ledger as pool_ledger; +use bitcoin_rs_mining::pool::ledger::{ CancelBasis, Hash32, Ledger, LedgerError, MinerId, PayoutBatch, PayoutPolicy, PayoutReceipt, }; use sonic_rs::{JsonContainerTrait as _, JsonValueTrait as _, Value, json}; diff --git a/crates/stratum-v2/Cargo.toml b/crates/stratum-v2/Cargo.toml deleted file mode 100644 index f2e6c5af5b..0000000000 --- a/crates/stratum-v2/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "bitcoin-rs-stratum-v2" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -publish = false -description = "Sans-IO Stratum V2 mining-pool server adapter for bitcoin-rs" - -[lints] -workspace = true - -[lib] -name = "bitcoin_rs_stratum_v2" -path = "src/lib.rs" - -[dependencies] -# One job / share / stale / duplicate owner. This crate never assembles a -# template and never validates a share or a block itself. -bitcoin-rs-stratum = { workspace = true } -# The typed host boundary for submitting a found block before ledger record -# and SV2 acknowledgement. -bitcoin-rs-mining = { workspace = true } - -# Stratum Reference Implementation crates. These are semver-major-coupled; -# the wire layouts MUST remain pinned as a release family. -binary_sv2 = { workspace = true } -framing_sv2 = { workspace = true } -codec_sv2 = { workspace = true, features = ["noise_sv2"] } -noise_sv2 = { workspace = true } -mining_sv2 = { workspace = true } -common_messages_sv2 = { workspace = true } - -ruint = { workspace = true } -thiserror = { workspace = true } -zeroize = { workspace = true } - -[dev-dependencies] -bitcoin-rs-primitives = { workspace = true } -bitcoin-rs-consensus = { workspace = true, default-features = false } -bitcoin-rs-script = { workspace = true, default-features = false } -bitcoin-rs-chain = { workspace = true, default-features = false } -bitcoin-rs-mempool = { workspace = true } -parking_lot = { workspace = true } -sha2 = { workspace = true } -# noise_sv2 2.0.0 resolves secp256k1 0.28.2; this test-only key generator -# rides the same narrow, parent-approved duplicate-crypto exception. -secp256k1 = { version = "=0.28.2", default-features = false, features = ["std", "rand-std"] } -rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } diff --git a/crates/stratum/Cargo.toml b/crates/stratum/Cargo.toml deleted file mode 100644 index 08316b307e..0000000000 --- a/crates/stratum/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "bitcoin-rs-stratum" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -version.workspace = true -authors.workspace = true -repository.workspace = true -description = "bitcoin-rs :: Stratum V1 pool protocol and native job/share owner" - -[lints] -workspace = true - -[dependencies] -bitcoin-rs-primitives.workspace = true -bitcoin-rs-consensus.workspace = true -bitcoin-rs-script.workspace = true -bitcoin-rs-chain.workspace = true -bitcoin-rs-mining.workspace = true -compact_str.workspace = true -ruint.workspace = true -sonic-rs.workspace = true -thiserror.workspace = true - -[features] -default = [] - -[dev-dependencies] -bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } -bitcoin-rs-mining = { workspace = true, features = ["test-seam"] } -parking_lot.workspace = true -sha2.workspace = true diff --git a/crates/stratum/src/lib.rs b/crates/stratum/src/lib.rs deleted file mode 100644 index 4d38249eea..0000000000 --- a/crates/stratum/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Native Stratum pool protocol and the shared job/share projection owner. -//! -//! [`JobBook`] is the single owner of projected jobs, extranonce allocation, -//! share validation, stale/duplicate state, and block assembly. The SV1 -//! adapter is a sibling module over that owner; it does not hash headers, -//! build coinbases, or maintain a second share predicate. -#![forbid(unsafe_code)] - -mod job; -mod sv1; - -pub use job::{ - AcceptedShare, BIP320_VERSION_MASK, BlockReceipt, BlockStatus, Extranonce, ExtranonceLayout, - FoundBlock, Installed, Job, JobBook, JobBookConfig, JobError, JobId, - MAX_COINBASE_SCRIPT_SIG_LEN, MAX_NTIME_FORWARD_SECS, Session, SessionId, ShareClass, - ShareError, ShareOutcome, ShareSubmission, ShareTarget, ShareWork, StaleReason, fetch_template, -}; -pub use sv1::errors; -pub use sv1::{ - AccountResolver, AuthDenied, Authorize, ClientRequest, Configure, DecodeFailure, Difficulty, - DifficultyError, DifficultyPolicy, FrameError, Incoming, InvalidAccount, LedgerUnavailable, - LineBuffer, MAX_CONFIGURE_EXTENSIONS, MAX_DIFFICULTY_TEXT_LEN, MAX_EXTENSION_NAME_LEN, - MAX_EXTRANONCE2_BYTES, MAX_ID_LEN, MAX_JOB_ID_LEN, MAX_JSON_DEPTH, MAX_LOGIN_LEN, - MAX_METHOD_LEN, MAX_PARAMS, MAX_PASSWORD_LEN, MAX_USER_AGENT_LEN, Password, RecordOutcome, - Request, RequestId, ShareLedger, StaticAccountResolver, Submit, Subscribe, WireError, - WorkerIdentity, decode_hex, decode_line, encode_error, encode_hex, encode_notify, - encode_reject, encode_result, encode_set_difficulty, json_string, parse_u32_hex, - parse_wire_job_id, prevhash_from_wire, prevhash_to_wire, push_hex, swap_words, wire_job_id, -}; -pub use sv1::{ - BlockOutcome, ConfigError, ConnectionLimiter, ConnectionPermit, Observer, PoolCore, - RefreshError, Refreshed, RefusalReason, Server, ServerConfig, ServerDeps, ServerEvent, - SessionEnd, ShutdownReport, -}; diff --git a/docs/contracts/pool.md b/docs/contracts/pool.md index b17d3c86a1..e27c088635 100644 --- a/docs/contracts/pool.md +++ b/docs/contracts/pool.md @@ -10,10 +10,10 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, | --- | --- | --- | | Operator options | `crates/node/src/options.rs`, `config.rs` | Feature/runtime gating, explicit configuration, and validation. | | Composition and shutdown | `crates/node/src/lifecycle.rs`, `pool.rs` | Ledger construction, RPC handle publication, listeners, reconciliation, and draining. | -| Jobs and SV1 | `crates/stratum/src/` | MiningControl-backed jobs, share validation, bounded admission, and SV1 protocol handling. | -| SV2 protocol | `crates/stratum-v2/src/` | Noise responder, mining channels, job/share frames, and deferred acknowledgements. | +| Jobs and SV1 | `crates/mining/src/pool/job.rs`, `pool/core.rs`, `pool/stratum/sv1.rs` | MiningControl-backed jobs, share validation, bounded admission, and SV1 protocol handling. | +| SV2 protocol | `crates/mining/src/pool/stratum/sv2.rs` | Noise responder, mining channels, job/share frames, and deferred acknowledgements. | | Node SV2 adapter | `crates/node/src/pool/sv2.rs`, `authority.rs` | Socket lifecycle, account mapping, found-block handling, and authority-file security. | -| Accounting | `crates/pool-ledger/src/lib.rs`, `store.rs`, `kv.rs` | PPLNS work, round/batch transitions, balance conservation, audit records, and versioned payout codecs over `KvStore`. | +| Accounting | `crates/mining/src/pool/ledger.rs`, `ledger/store.rs`, `ledger/kv.rs` | PPLNS work, round/batch transitions, balance conservation, audit records, and versioned payout codecs over `KvStore`. | | Applied-chain observations | `crates/node/src/pool/ledger.rs` | Hash-keyed canonicality, coinbase bodies, payout transaction/spend observations, and stable reconciliation. | | Management API | `crates/rpc/src/handlers/pool.rs` | Authenticated key-free accounting operations through the composed pool handle. | @@ -28,7 +28,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - SV2 bind and authority-file path MUST be configured together. Pool sub-options MUST NOT be silently ignored when the runtime is disabled. - The node MUST reject maturity below 100, zero payout confirmations, fees above 10,000 basis points, and a reorg watch shorter than maturity, payout confirmation depth, or the fixed six-confirmation abandon depth. - A reward script MUST be nonempty, bounded to 10,000 bytes, minimally encoded, and not begin with `OP_RETURN`. Passing these checks MUST NOT be represented as proof of spendability or control by the operator. -- Proof: `crates/node/tests/unit/config/tests.rs::pool_defaults_off_and_suboptions_require_enablement` proves the service resolves to no pool by default and rejects pool sub-options when the runtime is disabled. `::pool_requires_explicit_policy_and_listener` proves a nonempty datadir, an explicit listener, a share difficulty, a positive work window, a reward script, and a node mining payout address are each required. `::pool_non_loopback_requires_explicit_opt_in` proves a non-loopback bind requires explicit opt-in and that port zero is rejected. `::pool_money_depth_and_capacity_bounds_are_validated` proves rejection of maturity below 100, zero payout confirmations, fees above 10,000 basis points, a shallow reorg watch, non-positive or inverted connection caps, and `OP_RETURN` or non-minimally-encoded reward scripts. `::pool_exact_difficulty_and_script_parsing_reject_invalid_inputs` proves plain-decimal difficulty and minimally-encoded nonempty script parsing. `::pool_sv2_requires_feature_and_paired_authority_path_without_opening_it` proves the SV2 bind and authority path must be paired, SV1 and SV2 binds must differ, and resolution never opens the key file. `crates/pool-ledger/tests/support/ledger_cases.rs::config_validation_rejects_every_unsafe_value` proves ledger-side rejection of zero window work, excess fees, zero maturity, a shallow watch depth, zero dust, an `OP_RETURN` pool script, and empty change scripts. +- Proof: `crates/node/tests/unit/config/tests.rs::pool_defaults_off_and_suboptions_require_enablement` proves the service resolves to no pool by default and rejects pool sub-options when the runtime is disabled. `::pool_requires_explicit_policy_and_listener` proves a nonempty datadir, an explicit listener, a share difficulty, a positive work window, a reward script, and a node mining payout address are each required. `::pool_non_loopback_requires_explicit_opt_in` proves a non-loopback bind requires explicit opt-in and that port zero is rejected. `::pool_money_depth_and_capacity_bounds_are_validated` proves rejection of maturity below 100, zero payout confirmations, fees above 10,000 basis points, a shallow reorg watch, non-positive or inverted connection caps, and `OP_RETURN` or non-minimally-encoded reward scripts. `::pool_exact_difficulty_and_script_parsing_reject_invalid_inputs` proves plain-decimal difficulty and minimally-encoded nonempty script parsing. `::pool_sv2_requires_feature_and_paired_authority_path_without_opening_it` proves the SV2 bind and authority path must be paired, SV1 and SV2 binds must differ, and resolution never opens the key file. `crates/mining/tests/support/pool_ledger_cases.rs::config_validation_rejects_every_unsafe_value` proves ledger-side rejection of zero window work, excess fees, zero maturity, a shallow watch depth, zero dust, an `OP_RETURN` pool script, and empty change scripts. ### `POOL-02`: Schema consent, storage, and immutable policy @@ -38,7 +38,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The service MUST lock the dedicated pool directory. It MUST create a ledger only when absent and reopen an existing ledger with the persisted immutable policy checked for equality. - Accounting policy changes MUST NOT silently reinterpret historical shares or funds. A configuration mismatch MUST fail rather than delete or recreate the ledger. - Ledger creation/opening and run-epoch allocation MUST precede listener admission. A run epoch MUST distinguish restarted protocol job identities in durable share accounting. -- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::create_refuses_existing_paths_and_open_demands_the_same_config` proves the ledger is created only when absent and reopening with a different window, fee, or any persisted immutable policy fails with `ConfigMismatch` instead of deleting or recreating. `::open_refuses_a_foreign_store_without_writing_to_it` proves opening a store holding foreign content fails with `NotALedger` while leaving the foreign bytes intact and writing no `pool-ledger/v1/` keys. The `redb` and `fjall` modules in `crates/pool-ledger/tests/ledger.rs` run the whole suite through `Arc` against both real node storage engines, which exercises the storage-owned boundary. `::runtime_epochs_are_durable_and_shared_between_ledger_handles` proves run-epoch allocation is monotonic across handles and restarts, while `crates/node/src/pool/tests.rs` (`redb`/`fjall::restart_epoch_prevents_reused_wire_job_identity_collision`) proves the new epoch separates a reused wire job identity after restart and `redb`/`fjall::accepted_share_has_exact_durable_identity_and_idempotent_replay` proves the exact durable share identity survives reopen. `crates/node/src/pool/tests.rs::pool_prepare_holds_directory_lock_for_service_lifetime` proves the real `PoolService::prepare` path (opened `NodeState`, pool-enabled config) holds the dedicated pool-directory lock for the service lifetime and releases it on drop, while `::pool_directory_lock_is_exclusive_while_prepare_resources_live` pins the underlying exclusivity. `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_redb` and `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_fjall` prove ledger creation/opening with the persisted-policy equality check plus run-epoch allocation run under that same held lock through the exact `LedgerComposer`/`open_generic` prepare path, with a changed policy failing as `ConfigMismatch`. Residual gap: listeners starting only after RPC bind plus an initial template, and shutdown draining, are not covered; they need a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target plus a template-serving mining control) and remain structural in `crates/node/src/lifecycle.rs`. +- Proof: `crates/mining/tests/support/pool_ledger_cases.rs::create_refuses_existing_paths_and_open_demands_the_same_config` proves the ledger is created only when absent and reopening with a different window, fee, or any persisted immutable policy fails with `ConfigMismatch` instead of deleting or recreating. `::open_refuses_a_foreign_store_without_writing_to_it` proves opening a store holding foreign content fails with `NotALedger` while leaving the foreign bytes intact and writing no `pool-ledger/v1/` keys. The `redb` and `fjall` modules in `crates/mining/tests/pool_ledger.rs` run the whole suite through `Arc` against both real node storage engines, which exercises the storage-owned boundary. `::runtime_epochs_are_durable_and_shared_between_ledger_handles` proves run-epoch allocation is monotonic across handles and restarts, while `crates/node/src/pool/tests.rs` (`redb`/`fjall::restart_epoch_prevents_reused_wire_job_identity_collision`) proves the new epoch separates a reused wire job identity after restart and `redb`/`fjall::accepted_share_has_exact_durable_identity_and_idempotent_replay` proves the exact durable share identity survives reopen. `crates/node/src/pool/tests.rs::pool_prepare_holds_directory_lock_for_service_lifetime` proves the real `PoolService::prepare` path (opened `NodeState`, pool-enabled config) holds the dedicated pool-directory lock for the service lifetime and releases it on drop, while `::pool_directory_lock_is_exclusive_while_prepare_resources_live` pins the underlying exclusivity. `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_redb` and `::pool_prepare_create_open_and_epoch_run_under_the_directory_lock_fjall` prove ledger creation/opening with the persisted-policy equality check plus run-epoch allocation run under that same held lock through the exact `LedgerComposer`/`open_generic` prepare path, with a changed policy failing as `ConfigMismatch`. Residual gap: listeners starting only after RPC bind plus an initial template, and shutdown draining, are not covered; they need a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target plus a template-serving mining control) and remain structural in `crates/node/src/lifecycle.rs`. ### `POOL-03`: Shared admission and bounded deferred work @@ -47,7 +47,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - The node-composed deferred share-ledger outbox MUST be bounded by both 1024 records and 8 MiB. Exhaustion MUST reject work before validation. SV1 MUST withhold success for queued writes until their accounting completion is recorded. - Found-block processing MUST reserve capacity before SV2 protocol feed/validation can emit work requiring a record. Found-record reservations and recovery records MUST share the eight-slot budget, with a 4,000,000-byte charge per slot. Failed writes MUST retain the compact accounting projection rather than serialized full blocks. - Per-connection protocol limits MUST remain in force in addition to aggregate admission. None of these individual bounds is a claim of a total-process memory bound. -- Proof: `crates/stratum/tests/protocol.rs::shared_connection_limiter_enforces_total_and_per_ip_caps` proves total and per-IP caps with release on drop, and `crates/node/src/pool/tests.rs` (`protocol_admissions_share_total_capacity_and_release_it_on_drop`, `protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots`) proves SV1 and SV2 share one limiter and refused admissions consume no slot. `crates/stratum/tests/protocol.rs::connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions` proves excess peers are refused and shutdown joins served sessions. `crates/stratum/tests/jobs.rs::retention_is_bounded_and_retired_jobs_are_stale` proves bounded job retention with stale rejection of evicted jobs; `::sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first` and `::unusable_configurations_are_refused` prove bounded owner sessions and refusal of zero capacities. `::rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block` and `::block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay` prove rejected input does not fill the per-job duplicate set while a block accepted at capacity is still remembered. `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails`, `::configured_minima_cannot_fail_an_empty_outbox`, and `::per_submit_reservation_does_not_cap_the_pool_at_one_share` prove the bounded deferred outbox withholds success until the durable record and admits submits on an empty outbox without capping the pool at one share; `block_record_capacity_is_bounded_and_permit_drop_releases_it` (node pool tests, both backends) proves the eight found-record slots with release on drop. `crates/stratum-v2/tests/protocol.rs::unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded` proves the per-connection pending-ack bound with a `pool-busy` refusal that records nothing. `crates/node/tests/unit/config/tests.rs::pool_composed_connection_defaults_are_pinned` proves resolution pins the composed 128-total / 8-per-IP connection defaults, `crates/stratum/tests/protocol.rs::composed_pool_defaults_enforce_total_and_per_ip_caps` proves a (128, 8) limiter admits exactly 128 total with 8 per IP and releases on drop, and `crates/node/src/pool/tests.rs::composed_core_bounds_pin_the_contract_literals` proves the composed core carries 64 retained jobs, sessions at twice the connection cap, 65,536 duplicate-tracked entries per job, and a 1024-record / 8 MiB deferred outbox. +- Proof: `crates/mining/tests/pool_sv1.rs::shared_connection_limiter_enforces_total_and_per_ip_caps` proves total and per-IP caps with release on drop, and `crates/node/src/pool/tests.rs` (`protocol_admissions_share_total_capacity_and_release_it_on_drop`, `protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots`) proves SV1 and SV2 share one limiter and refused admissions consume no slot. `crates/mining/tests/pool_sv1.rs::connection_caps_refuse_excess_peers_and_shutdown_joins_served_sessions` proves excess peers are refused and shutdown joins served sessions. `crates/mining/tests/pool_jobs.rs::retention_is_bounded_and_retired_jobs_are_stale` proves bounded job retention with stale rejection of evicted jobs; `::sessions_get_unique_bounded_prefixes_and_reuse_oldest_freed_first` and `::unusable_configurations_are_refused` prove bounded owner sessions and refusal of zero capacities. `::rejected_input_cannot_fill_the_duplicate_set_and_capacity_never_refuses_a_block` and `::block_candidate_at_job_capacity_is_remembered_as_duplicate_on_replay` prove rejected input does not fill the per-job duplicate set while a block accepted at capacity is still remembered. `crates/mining/tests/pool_sv1.rs::transactional_outbox_buffers_shares_when_ledger_fails`, `::configured_minima_cannot_fail_an_empty_outbox`, and `::per_submit_reservation_does_not_cap_the_pool_at_one_share` prove the bounded deferred outbox withholds success until the durable record and admits submits on an empty outbox without capping the pool at one share; `block_record_capacity_is_bounded_and_permit_drop_releases_it` (node pool tests, both backends) proves the eight found-record slots with release on drop. `crates/mining/tests/pool_sv2.rs::unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded` proves the per-connection pending-ack bound with a `pool-busy` refusal that records nothing. `crates/node/tests/unit/config/tests.rs::pool_composed_connection_defaults_are_pinned` proves resolution pins the composed 128-total / 8-per-IP connection defaults, `crates/mining/tests/pool_sv1.rs::composed_pool_defaults_enforce_total_and_per_ip_caps` proves a (128, 8) limiter admits exactly 128 total with 8 per IP and releases on drop, and `crates/node/src/pool/tests.rs::composed_core_bounds_pin_the_contract_literals` proves the composed core carries 64 retained jobs, sessions at twice the connection cap, 65,536 duplicate-tracked entries per job, and a 1024-record / 8 MiB deferred outbox. ### `POOL-04`: Authority-key handling @@ -57,7 +57,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Secure authority-file loading MUST fail closed where this implementation has no supported loader; the non-Unix path reports unsupported operation. - The file input buffer, decoded secret buffer, and stored authority secret MUST use zeroizing ownership. Debug formatting MUST redact the stored secret, and parse errors MUST NOT include key input. This requirement does not certify zeroization of cryptographic dependency internals, kernel buffers, or every transient machine-level copy. - Operators SHOULD protect ancestor directories. `NOFOLLOW` on the opened path MUST NOT be described as an ancestor-directory trust guarantee. -- Proof: `crates/stratum-v2/tests/protocol.rs::noise_handshake_authenticates_the_pool_to_a_pinned_miner` proves the authority authenticates the Noise transport to a miner pinning its public key; `::a_miner_pinning_another_authority_rejects_the_server_certificate` proves a certificate from another authority is rejected; `::a_mismatched_authority_key_pair_is_refused_at_accept` proves a non-matching public/secret pair is refused at accept. `crates/node/src/pool/authority.rs::tests::load_accepts_exactly_two_hex_fields_in_a_0600_regular_file` proves a two-field `0600` regular file loads with the expected public key and a `` `Debug` that carries no secret hex; `::load_tolerates_surrounding_ascii_whitespace_but_nothing_else` and `::load_rejects_inputs_larger_than_256_bytes` prove the exactly-two-64-hex-field and 256-byte limits; `::parse_errors_carry_no_key_input` proves parse errors carry no key input in either `Display` or `Debug`; `::load_enforces_regular_file_and_0600_descriptor_checks` proves non-`0600` modes and directories are refused as `InsecureFile`; and `::load_refuses_a_final_component_symlink` proves a final-component symlink cannot bypass the descriptor check. Residual gap: the non-Unix fail-closed path cannot execute on Linux, and zeroization of transient machine-level copies beyond the `Zeroizing` file/secret buffers and the redacted `Debug` is not observably pinned. +- Proof: `crates/mining/tests/pool_sv2.rs::noise_handshake_authenticates_the_pool_to_a_pinned_miner` proves the authority authenticates the Noise transport to a miner pinning its public key; `::a_miner_pinning_another_authority_rejects_the_server_certificate` proves a certificate from another authority is rejected; `::a_mismatched_authority_key_pair_is_refused_at_accept` proves a non-matching public/secret pair is refused at accept. `crates/node/src/pool/authority.rs::tests::load_accepts_exactly_two_hex_fields_in_a_0600_regular_file` proves a two-field `0600` regular file loads with the expected public key and a `` `Debug` that carries no secret hex; `::load_tolerates_surrounding_ascii_whitespace_but_nothing_else` and `::load_rejects_inputs_larger_than_256_bytes` prove the exactly-two-64-hex-field and 256-byte limits; `::parse_errors_carry_no_key_input` proves parse errors carry no key input in either `Display` or `Debug`; `::load_enforces_regular_file_and_0600_descriptor_checks` proves non-`0600` modes and directories are refused as `InsecureFile`; and `::load_refuses_a_final_component_symlink` proves a final-component symlink cannot bypass the descriptor check. Residual gap: the non-Unix fail-closed path cannot execute on Linux, and zeroization of transient machine-level copies beyond the `Zeroizing` file/secret buffers and the redacted `Debug` is not observably pinned. ### `POOL-05`: One mining owner and acknowledgement boundaries @@ -70,7 +70,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Each SV1 session MUST retain at most one pending accounting response. Additional submissions while it is pending MUST be rejected before validation with a retryable accounting error. Polling MUST send the deferred success only after accounting completion. Process failure MAY lose queued work before completion, but such work MUST NOT already have received success. - A found-block accounting record MUST initially be pending and deduplicated by its block/share identity. Retrying a record MUST NOT allocate the same reward twice. - New job parents, clean-job flags, and submission receipts MUST NOT be used as reorg or maturity evidence. -- Proof: `crates/stratum/tests/jobs.rs::found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts` proves found blocks go through the shared `MiningControl` submit path with Applied/Rejected/Undetermined verdict mapping. `crates/stratum/tests/protocol.rs::applied_block_retires_job_and_second_submit_is_stale` proves synchronous retirement on `Applied` with a second same-job nonce rejected as stale (SV1 21) and never resubmitted. `crates/node/src/pool/sv2.rs::applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node` proves the native SV2 path retires the common job before a sibling reaches the node. `crates/stratum-v2/tests/protocol.rs::stale_sibling_receipt_sends_stale_error_without_closing` proves an `inconclusive-not-best-prevblk` sibling still completes its record and is answered `stale-share` without closing; `::accepted_block_retirement_makes_second_same_job_submit_stale` proves post-retirement submits are stale while `::duplicate_share_is_refused_with_the_official_code` and `::stale_jobs_unknown_jobs_and_closed_channels_use_official_codes` prove byte-identical replays keep their `duplicate-share` identity. `::accepted_share_is_held_until_the_ledger_confirms_then_acknowledged` proves SV2 success waits for host confirmation of the durable outcome, and `crates/stratum/tests/protocol.rs::transactional_outbox_buffers_shares_when_ledger_fails` with `::crash_restored_share_still_delivers_its_block_record`, `::permanently_refused_share_is_rejected_without_retry_or_buffer`, and `::unknown_wire_job_rejects_before_the_common_submit_owner` prove SV1 deferred success, duplicate preservation across restart, no retry or buffering of permanent refusals, and rejection before the common owner for unknown jobs. `rejected_block_receipt_persists_pending_round_without_funds` and `failed_block_submission_persists_pending_round_without_funds` (node pool tests, both backends) prove rejected/failed receipts leave the round pending with no funds. `crates/stratum/tests/protocol.rs::second_submit_while_accounting_pending_is_shed_before_validation` proves the at-most-one-pending-response SV1 session gate: a second submit while one accounting response is pending is shed before validation with the retryable `Share accounting unavailable` error, the shed share never reaches the ledger, the session's next poll still delivers the deferred first success, and the shed share is never burned so its retry credits normally. `::job_install_with_new_parent_issues_no_ledger_write` proves installing jobs with new parents writes nothing to share accounting. Residual gap: clean-job flags and submission receipts not being consumed as reorg or maturity evidence stays structural; receipts are partly covered above (rejected/failed receipts leave rounds pending with no funds), but flag non-use has no direct executable test. +- Proof: `crates/mining/tests/pool_jobs.rs::found_blocks_reach_the_authoritative_submit_path_and_receipts_map_verdicts` proves found blocks go through the shared `MiningControl` submit path with Applied/Rejected/Undetermined verdict mapping. `crates/mining/tests/pool_sv1.rs::applied_block_retires_job_and_second_submit_is_stale` proves synchronous retirement on `Applied` with a second same-job nonce rejected as stale (SV1 21) and never resubmitted. `crates/node/src/pool/sv2.rs::applied_sv2_block_retires_the_common_job_before_a_sibling_reaches_the_node` proves the native SV2 path retires the common job before a sibling reaches the node. `crates/mining/tests/pool_sv2.rs::stale_sibling_receipt_sends_stale_error_without_closing` proves an `inconclusive-not-best-prevblk` sibling still completes its record and is answered `stale-share` without closing; `::accepted_block_retirement_makes_second_same_job_submit_stale` proves post-retirement submits are stale while `::duplicate_share_is_refused_with_the_official_code` and `::stale_jobs_unknown_jobs_and_closed_channels_use_official_codes` prove byte-identical replays keep their `duplicate-share` identity. `::accepted_share_is_held_until_the_ledger_confirms_then_acknowledged` proves SV2 success waits for host confirmation of the durable outcome, and `crates/mining/tests/pool_sv1.rs::transactional_outbox_buffers_shares_when_ledger_fails` with `::crash_restored_share_still_delivers_its_block_record`, `::permanently_refused_share_is_rejected_without_retry_or_buffer`, and `::unknown_wire_job_rejects_before_the_common_submit_owner` prove SV1 deferred success, duplicate preservation across restart, no retry or buffering of permanent refusals, and rejection before the common owner for unknown jobs. `rejected_block_receipt_persists_pending_round_without_funds` and `failed_block_submission_persists_pending_round_without_funds` (node pool tests, both backends) prove rejected/failed receipts leave the round pending with no funds. `crates/mining/tests/pool_sv1.rs::second_submit_while_accounting_pending_is_shed_before_validation` proves the at-most-one-pending-response SV1 session gate: a second submit while one accounting response is pending is shed before validation with the retryable `Share accounting unavailable` error, the shed share never reaches the ledger, the session's next poll still delivers the deferred first success, and the shed share is never burned so its retry credits normally. `::job_install_with_new_parent_issues_no_ledger_write` proves installing jobs with new parents writes nothing to share accounting. Residual gap: clean-job flags and submission receipts not being consumed as reorg or maturity evidence stays structural; receipts are partly covered above (rejected/failed receipts leave rounds pending with no funds), but flag non-use has no direct executable test. ### `POOL-06`: PPLNS reward and maturity @@ -79,7 +79,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Gross pool reward MUST be the sum of observed coinbase outputs with the configured pool script. The fee MUST be computed in integer basis points, rounded down. Net allocations MUST conserve that reward after the fee. - Available balances MUST be credited only after the configured maturity, using confirmations `tip_height - block_height + 1`. Node configuration MUST keep that threshold at or above 100. - A registered payout script MUST be required for miner admission. Decimal account IDs MAY include one nonempty worker suffix. Login syntax MUST NOT be represented as cryptographic proof of payout-script ownership. -- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::the_window_slides_across_blocks_and_snapshots_are_frozen` proves work-weighted windows snapshot at the solving share and later shares never change a snapshot; `::window_boundaries_are_exact_in_work_units` and `::short_history_uses_all_work_and_pruning_protects_old_windows` prove exact work-unit boundaries with pruning that protects old windows. `::a_found_block_is_recorded_once_and_pays_nothing_until_the_chain_shows_it` proves idempotent found-block recording with no payment before chain evidence and rejection of conflicting height or value claims. `::rounds_mature_only_after_the_configured_confirmations_on_the_active_chain` proves crediting only after the configured confirmations on the active chain. `::the_pool_fee_comes_from_the_observed_reward_and_allocations_sum_to_net` proves the gross equals the observed reward, the fee is floor-rounded, and allocations sum to net. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` proves contradictory coinbase claims surface anomalies and are never applied; `::a_coinbase_that_pays_miners_directly_matures_with_a_zero_pool_reward` proves a coinbase with no pool-script output matures with zero pool reward. `only_explicitly_registered_accounts_can_authenticate_or_credit_shares` (node pool tests, both backends) proves shares credit only for explicitly registered accounts with `42.rig` accepted, bare `42` defaulting its worker, and `43`, `address.rig`, empty, or empty-suffix logins refused; `::payout_scripts_are_validated_and_batches_skip_accounts_without_one` proves payout-script validation. +- Proof: `crates/mining/tests/support/pool_ledger_cases.rs::the_window_slides_across_blocks_and_snapshots_are_frozen` proves work-weighted windows snapshot at the solving share and later shares never change a snapshot; `::window_boundaries_are_exact_in_work_units` and `::short_history_uses_all_work_and_pruning_protects_old_windows` prove exact work-unit boundaries with pruning that protects old windows. `::a_found_block_is_recorded_once_and_pays_nothing_until_the_chain_shows_it` proves idempotent found-block recording with no payment before chain evidence and rejection of conflicting height or value claims. `::rounds_mature_only_after_the_configured_confirmations_on_the_active_chain` proves crediting only after the configured confirmations on the active chain. `::the_pool_fee_comes_from_the_observed_reward_and_allocations_sum_to_net` proves the gross equals the observed reward, the fee is floor-rounded, and allocations sum to net. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` proves contradictory coinbase claims surface anomalies and are never applied; `::a_coinbase_that_pays_miners_directly_matures_with_a_zero_pool_reward` proves a coinbase with no pool-script output matures with zero pool reward. `only_explicitly_registered_accounts_can_authenticate_or_credit_shares` (node pool tests, both backends) proves shares credit only for explicitly registered accounts with `42.rig` accepted, bare `42` defaulting its worker, and `43`, `address.rig`, empty, or empty-suffix logins refused; `::payout_scripts_are_validated_and_batches_skip_accounts_without_one` proves payout-script validation. ### `POOL-07`: Hash-keyed reorg reconciliation and conservation @@ -89,7 +89,7 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Future credits MUST offset deficits before increasing available funds. Per account, `available + reserved + paid - deficit` MUST equal credited matured-round allocations. - Unknown membership MUST NOT be treated as proof of canonicality or displacement. Reorg monitoring is bounded: rounds and confirmed batches leave monitoring according to the configured watch horizon. This contract MUST NOT be advertised as unlimited deep-reorg recovery. - Payout confirmation reversal MUST preserve the batch's financial reservation/accounting transitions. A transaction disappearing from a mempool MUST NOT prove that its signature is unusable or that its inputs cannot later be spent. -- Proof: `crates/pool-ledger/tests/support/ledger_cases.rs::equal_height_fork_reverses_matured_credits` and `::lower_height_fork_reverses_matured_credits` prove an equal-height and a lower-height replacement tip both orphan a matured round and reverse its credits with conservation held. `::a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive` and `::a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns` prove pre-maturity orphaning with nothing to reverse plus revival, and post-maturity reversal with re-credit on return. `::reversing_credits_that_were_already_reserved_creates_an_explicit_deficit` proves reversal consumes available funds first and the remainder becomes an explicit deficit that later re-credit clears before increasing available funds, keeping `available + reserved + paid - deficit` balanced via `check_invariants`. `::unseen_rounds_stop_being_monitored_after_the_watch_depth` proves bounded monitoring leaves rounds unwatched past the horizon. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` and `::reconciliation_is_idempotent_and_writes_no_audit_noise` prove contradictory claims never apply and unknown membership changes nothing idempotently. `::a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it` and `::a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent` prove payout confirmation reversal preserves reservations and transitions, with failure only on conclusively double-spent inputs. `crates/node/src/pool/tests.rs::node_chain_view_reports_missing_index_observer_errors` proves the `NodeChainView` missing-index legs through a real opened `NodeState`: `tx_status` without a txindex and `confirmed_spender` without a script index fail as observer errors rather than evidence of nonpayment or safe abandonment. Explicit blocker: the canonical-body checks of populated transaction/spend index hints have no fixture — they need applied blocks plus populated indexes, which no pool test scope builds. +- Proof: `crates/mining/tests/support/pool_ledger_cases.rs::equal_height_fork_reverses_matured_credits` and `::lower_height_fork_reverses_matured_credits` prove an equal-height and a lower-height replacement tip both orphan a matured round and reverse its credits with conservation held. `::a_reorg_before_maturity_orphans_the_round_with_nothing_to_reverse_and_can_revive` and `::a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_returns` prove pre-maturity orphaning with nothing to reverse plus revival, and post-maturity reversal with re-credit on return. `::reversing_credits_that_were_already_reserved_creates_an_explicit_deficit` proves reversal consumes available funds first and the remainder becomes an explicit deficit that later re-credit clears before increasing available funds, keeping `available + reserved + paid - deficit` balanced via `check_invariants`. `::unseen_rounds_stop_being_monitored_after_the_watch_depth` proves bounded monitoring leaves rounds unwatched past the horizon. `::a_claim_the_chain_contradicts_is_surfaced_and_never_applied` and `::reconciliation_is_idempotent_and_writes_no_audit_noise` prove contradictory claims never apply and unknown membership changes nothing idempotently. `::a_reorged_out_confirmation_reverts_the_payout_and_a_new_block_reconfirms_it` and `::a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent` prove payout confirmation reversal preserves reservations and transitions, with failure only on conclusively double-spent inputs. `crates/node/src/pool/tests.rs::node_chain_view_reports_missing_index_observer_errors` proves the `NodeChainView` missing-index legs through a real opened `NodeState`: `tx_status` without a txindex and `confirmed_spender` without a script index fail as observer errors rather than evidence of nonpayment or safe abandonment. Explicit blocker: the canonical-body checks of populated transaction/spend index hints have no fixture — they need applied blocks plus populated indexes, which no pool test scope builds. ### `POOL-08`: External signing without wallet custody @@ -101,15 +101,15 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, - Receipt validation MUST NOT be described as signature or input-ownership verification. `recordpayoutreceipt` MUST NOT broadcast. Chain observation of the recomputed txid supplies node-acceptance evidence; payment confirmation additionally requires the configured depth. - Signing failure, timeout, or receipt rejection MUST NOT release a reservation implicitly. Cancellation MUST require a prepared batch and explicit `never_handed_to_signer` or `signer_revoked` basis. The operator MUST establish that external basis; the RPC cannot prove signer revocation. - Lack of a transaction index prevents confirmed payout observation outside the mempool; lack of a script index prevents confirmed-spend observation. Such observer errors MUST NOT be converted into evidence of nonpayment or safe abandonment. -- Proof: `crates/rpc/src/handlers/pool.rs::disabled_pool_never_returns_fabricated_success` proves every pool handler is method-disabled without a ledger and returns no fabricated success. `::enabled_handlers_use_the_real_durable_ledger` proves key-free handlers over the real durable ledger with idempotency-key, minimum-payout, and output-cap policy enforcement. `::funded_external_payout_round_trip_preserves_reservations_on_rejection` proves the funded prepare/request/sign/broadcast walk with the reservation preserved across a txid-mismatch rejection and no cancellation after signing. `crates/pool-ledger/tests/support/ledger_cases.rs::preparing_a_batch_reserves_balances_atomically_and_never_double_allocates` proves atomic reservation with idempotency-key replay returning the immutable batch; `::payout_policy_is_bounded_by_dust_and_output_limits` proves the 546-sat dust floor and output-cap bound. `::payout_request_and_receipt_round_trip_and_reject_corruption` proves the strict `BRPR`/`BRPT` version 1 codecs with big-endian metadata, exact outputs, and rejection of bad magic, trailing bytes, and truncation; `::config_encode_validates_change_scripts_before_encoding` proves change-script allowlist validation. `::a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid` proves exact-output/txid matching with reservations preserved on every refusal (wrong txid, null or duplicate inputs, unparseable receipts, unknown batches, excess change outputs); `::replacements_must_conflict_with_every_earlier_signed_transaction` proves replacements must conflict rather than double-pay; `::cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing` proves cancellation requires a prepared batch with an explicit `never_handed_to_signer`/`signer_revoked` basis and is refused after signing; `::the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing` proves signer failures change nothing. `::a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting` proves broadcast requires mempool observation and confirmation requires the configured depth. `crates/rpc/tests/auth.rs::pool_management_requires_authentication_and_management_surface` proves the RPC-layer gate over real TCP dispatch: an authorized management request reaches the pool handler (which refuses without an enabled ledger instead of fabricating status), while missing or wrong credentials are refused with 401 and the public surface never routes pool JSON-RPC. +- Proof: `crates/rpc/src/handlers/pool.rs::disabled_pool_never_returns_fabricated_success` proves every pool handler is method-disabled without a ledger and returns no fabricated success. `::enabled_handlers_use_the_real_durable_ledger` proves key-free handlers over the real durable ledger with idempotency-key, minimum-payout, and output-cap policy enforcement. `::funded_external_payout_round_trip_preserves_reservations_on_rejection` proves the funded prepare/request/sign/broadcast walk with the reservation preserved across a txid-mismatch rejection and no cancellation after signing. `crates/mining/tests/support/pool_ledger_cases.rs::preparing_a_batch_reserves_balances_atomically_and_never_double_allocates` proves atomic reservation with idempotency-key replay returning the immutable batch; `::payout_policy_is_bounded_by_dust_and_output_limits` proves the 546-sat dust floor and output-cap bound. `::payout_request_and_receipt_round_trip_and_reject_corruption` proves the strict `BRPR`/`BRPT` version 1 codecs with big-endian metadata, exact outputs, and rejection of bad magic, trailing bytes, and truncation; `::config_encode_validates_change_scripts_before_encoding` proves change-script allowlist validation. `::a_signed_transaction_must_pay_exactly_the_batch_and_prove_its_own_txid` proves exact-output/txid matching with reservations preserved on every refusal (wrong txid, null or duplicate inputs, unparseable receipts, unknown batches, excess change outputs); `::replacements_must_conflict_with_every_earlier_signed_transaction` proves replacements must conflict rather than double-pay; `::cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing` proves cancellation requires a prepared batch with an explicit `never_handed_to_signer`/`signer_revoked` basis and is refused after signing; `::the_external_signer_seam_signs_the_canonical_request_and_failures_change_nothing` proves signer failures change nothing. `::a_payout_walks_prepared_signed_broadcast_confirmed_with_exact_accounting` proves broadcast requires mempool observation and confirmation requires the configured depth. `crates/rpc/tests/auth.rs::pool_management_requires_authentication_and_management_surface` proves the RPC-layer gate over real TCP dispatch: an authorized management request reaches the pool handler (which refuses without an enabled ledger instead of fabricating status), while missing or wrong credentials are refused with 401 and the public surface never routes pool JSON-RPC. ### `POOL-09`: Lifecycle and protocol scope - Composition MUST construct the ledger/operator handle before management RPC publication. Mining listeners MUST start only after RPC binds successfully and an initial template is available. - Reconciliation MUST refresh work and observe rounds/payouts without holding shutdown hostage to a future mining generation. Shutdown MUST stop admission, cancel and join protocol workers, and drain bounded deferred accounting work within the node's shutdown deadline. A missed deadline or worker panic MUST remain an error, not a success report. -- SV2 support is limited to the role matrix in `crates/stratum-v2/src/lib.rs`: Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling, including the adapter's reconnect API. +- SV2 support is limited to the role matrix in `crates/mining/src/pool/stratum/sv2.rs`: Noise responder transport, mining setup version 2, standard and extended channels, a per-connection group, and job/share handling, including the adapter's reconnect API. - Custom jobs/work selection, Job Declaration, a Template Distribution server, extranonce-prefix changes, and extensions negotiation MUST NOT be advertised as implemented. The in-process template source MUST NOT be confused with a Template Distribution server. -- Proof: `crates/stratum-v2/tests/protocol.rs::the_role_matrix_does_not_claim_what_is_not_built` proves the role matrix marks standard/extended/group channels and `Noise_NX` implemented while Job Declaration, work selection, Template Distribution, and extensions negotiation are `NotImplemented` with documented detail. `::job_declaration_and_template_distribution_are_not_served` proves both protocols are refused with `unsupported-protocol` and the connection closes; `::custom_jobs_are_answered_with_the_official_code_not_pretended` proves custom-job requests are answered `jd-not-supported`. `crates/node/tests/unit/lifecycle/tests.rs::final_payout_script_prefers_pool_when_values_differ` proves one payout-script owner shared by pool and template paths with the pool reward winning while configured. The in-crate `start_node`/`NodeState::open` harness exists and is now used for the `prepare` lock-lifetime proof (POOL-02) and the `NodeChainView` missing-index proof (POOL-07). Explicit blocker: no test starts pool listeners or shuts the pool down — that needs a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target and mining payout, free ports, plus a template-serving mining control for the initial template), and deadline-miss plus worker-panic surfacing have no injection seam. The prepare-then-RPC-bind-then-`start` sequencing itself remains structural in `crates/node/src/lifecycle.rs`. +- Proof: `crates/mining/tests/pool_sv2.rs::the_role_matrix_does_not_claim_what_is_not_built` proves the role matrix marks standard/extended/group channels and `Noise_NX` implemented while Job Declaration, work selection, Template Distribution, and extensions negotiation are `NotImplemented` with documented detail. `::job_declaration_and_template_distribution_are_not_served` proves both protocols are refused with `unsupported-protocol` and the connection closes; `::custom_jobs_are_answered_with_the_official_code_not_pretended` proves custom-job requests are answered `jd-not-supported`. `crates/node/tests/unit/lifecycle/tests.rs::final_payout_script_prefers_pool_when_values_differ` proves one payout-script owner shared by pool and template paths with the pool reward winning while configured. The in-crate `start_node`/`NodeState::open` harness exists and is now used for the `prepare` lock-lifetime proof (POOL-02) and the `NodeChainView` missing-index proof (POOL-07). Explicit blocker: no test starts pool listeners or shuts the pool down — that needs a pool-enabled `start_node` (hand-built `PoolConfig` with matching share target and mining payout, free ports, plus a template-serving mining control for the initial template), and deadline-miss plus worker-panic surfacing have no injection seam. The prepare-then-RPC-bind-then-`start` sequencing itself remains structural in `crates/node/src/lifecycle.rs`. ## Evidence boundary and source map @@ -119,10 +119,10 @@ The supplied evidence baseline records **71 prior protocol tests**, **112 prior | --- | --- | --- | | Options and validation | `crates/node/src/options.rs`, `config.rs::resolve_pool`, `PoolConfig::validate` | Implemented configuration, not deployment approval. | | Persistence and budgets | `crates/node/src/pool.rs::LedgerComposer`, `PoolService::prepare`, `PoolService::start`; `pool/ledger.rs::try_reserve_block_slot` | Approved additive storage and runtime bounds; no operator-data cutover. | -| Key security | `crates/node/src/pool/authority.rs::load`, `open_private_file`; `crates/stratum-v2/src/lib.rs::AuthorityKeypair` | Descriptor checks and explicitly owned zeroizing buffers. | -| Block and share records | `crates/node/src/pool/ledger.rs::record_submitted_block`; `pool/sv2.rs::handle_events`; `crates/stratum/src/sv1/session.rs::Completion`, `PoolCore::submit`, `on_submit`, `poll` | Submission ordering and deferred durable success in both protocols; not miner interoperability. | -| Canonicality and reward reversal | `crates/pool-ledger/src/store.rs::reconcile_rounds`, `apply_round_observation`, `fix_reward`, `reverse_round_credits`; node `RawChainView` | Hash-keyed observations within the watch horizon. | -| Signer codecs and receipt checks | `crates/pool-ledger/src/lib.rs::PayoutRequest`, `PayoutReceipt`, `verify_payout_transaction`; `store.rs::record_signed` | Exact output/txid checks; no in-process wallet or signature-verification claim. | +| Key security | `crates/node/src/pool/authority.rs::load`, `open_private_file`; `crates/mining/src/pool/stratum/sv2.rs::AuthorityKeypair` | Descriptor checks and explicitly owned zeroizing buffers. | +| Block and share records | `crates/node/src/pool/ledger.rs::record_submitted_block`; `crates/node/src/pool/sv2.rs::handle_events`; `crates/mining/src/pool/core.rs::Completion`, `PoolCore::submit`; `pool/stratum/sv1/session.rs::on_submit`, `poll` | Submission ordering and deferred durable success in both protocols; not miner interoperability. | +| Canonicality and reward reversal | `crates/mining/src/pool/ledger/store.rs::reconcile_rounds`, `apply_round_observation`, `fix_reward`, `reverse_round_credits`; node `RawChainView` | Hash-keyed observations within the watch horizon. | +| Signer codecs and receipt checks | `crates/mining/src/pool/ledger.rs::PayoutRequest`, `PayoutReceipt`, `verify_payout_transaction`; `ledger/store.rs::record_signed` | Exact output/txid checks; no in-process wallet or signature-verification claim. | | Operator API | `crates/rpc/src/handlers/pool.rs` | Selected node/RPC evidence, not strict final-gate completion. | For operator configuration and the external signing sequence, read [Native pool operation](../pool.md). Existing operator databases MUST remain untouched by this work. Deployment and existing-data cutover require separate approval. diff --git a/docs/pool.md b/docs/pool.md index 05b7e47a1a..91842d2504 100644 --- a/docs/pool.md +++ b/docs/pool.md @@ -41,7 +41,7 @@ Set `--pool` and provide each required value below. No listener, pool directory, | `--pool-min-payout-sat` / `pool_min_payout_sat` | `u64`; `546` | Between 546 and 2,100,000,000,000,000 satoshis. | | `--pool-max-payout-outputs` / `pool_max_payout_outputs` | `u32`; `2500` | Between 1 and 2500 outputs. | -Write difficulty with an ASCII-digit integer part and, if needed, a decimal point and fractional digits, such as `16384` or `0.001`. There MUST be at most 19 digits in total. Signs, exponent notation, a missing integer part, and zero are rejected. The target conversion uses integer-ratio arithmetic; see `ShareTarget::from_pool_difficulty_decimal` in [`job.rs`](../crates/stratum/src/job.rs). +Write difficulty with an ASCII-digit integer part and, if needed, a decimal point and fractional digits, such as `16384` or `0.001`. There MUST be at most 19 digits in total. Signs, exponent notation, a missing integer part, and zero are rejected. The target conversion uses integer-ratio arithmetic; see `ShareTarget::from_pool_difficulty_decimal` in [`job.rs`](../crates/mining/src/pool/job.rs). At least one protocol bind MUST be present. Ports MUST be nonzero, and the two bind addresses MUST differ. Non-loopback binds require explicit permission even when the address is a wildcard. Operators SHOULD keep candidate listeners on loopback. @@ -53,7 +53,7 @@ On Unix, the authority file MUST be a regular file with mode exactly `0600`, inc The loader opens the path with `RDONLY | CLOEXEC | NOFOLLOW | NONBLOCK` and checks metadata on the opened descriptor. A final-component symlink, FIFO, directory, or insecure mode is not accepted. `NOFOLLOW` is not a claim that every parent directory is trusted; operators SHOULD protect the containing directory from modification by other users. Secure authority-file loading fails closed on non-Unix platforms. -The input buffer, decoded secret, and stored authority secret use `Zeroizing`; the authority object's debug output redacts the secret. Parse errors do not include file contents. These protections cover the owned buffers in this implementation, not every internal copy in a cryptographic dependency or operating-system memory. See [`authority.rs`](../crates/node/src/pool/authority.rs) and [`AuthorityKeypair`](../crates/stratum-v2/src/lib.rs). +The input buffer, decoded secret, and stored authority secret use `Zeroizing`; the authority object's debug output redacts the secret. Parse errors do not include file contents. These protections cover the owned buffers in this implementation, not every internal copy in a cryptographic dependency or operating-system memory. See [`authority.rs`](../crates/node/src/pool/authority.rs) and [`AuthorityKeypair`](../crates/mining/src/pool/stratum/sv2.rs). ## Register miners and inspect accounting From daf237642751a74857d0d81af7eb45e1aa355357 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:33:48 +0900 Subject: [PATCH 35/61] fix(pool-sv2): queue acks before dropping pending tickets Confirm and reject paths removed the pending ticket before the fallible outbound queue ran, so a full queue dropped a recorded share's acknowledgement (or a judged refusal) with no retry. Snapshot the ticket fields, queue first, and remove only after the frame is admitted; a closed channel still drops its ticket without sending. Linearizability is unchanged: the single session borrow still serializes ticket state, and the ledger stays the idempotent consumer. --- crates/mining/src/pool/stratum/sv2/session.rs | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/crates/mining/src/pool/stratum/sv2/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs index f825409317..adc2ea7b40 100644 --- a/crates/mining/src/pool/stratum/sv2/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -1832,22 +1832,26 @@ impl ProtocolSession { if matches!(pending.kind, PendingKind::Block) { return Err(SessionError::BlockRequiresSubmission); } - let Some(pending) = self.pending.remove(&ticket.0) else { - return Ok(false); - }; - if !self.channels.contains_key(&pending.channel_id) { + // Queue before removing the ticket: a full outbound queue must leave + // the pending acknowledgement in place for a later retry instead of + // dropping a share the ledger already recorded. + let (channel_id, sequence_number, difficulty) = + (pending.channel_id, pending.sequence_number, pending.difficulty); + if !self.channels.contains_key(&channel_id) { + self.pending.remove(&ticket.0); return Ok(false); } self.queue( MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, CHANNEL_BIT_SUBMIT_SHARES_SUCCESS, SubmitSharesSuccess { - channel_id: pending.channel_id, - last_sequence_number: pending.sequence_number, + channel_id, + last_sequence_number: sequence_number, new_submits_accepted_count: 1, - new_shares_sum: pending.difficulty, + new_shares_sum: difficulty, }, )?; + self.pending.remove(&ticket.0); Ok(true) } @@ -1867,22 +1871,26 @@ impl ProtocolSession { { return Ok(false); } - let Some(pending) = self.pending.remove(&submission.ticket.0) else { - return Ok(false); - }; - if !self.channels.contains_key(&pending.channel_id) { + // Queue before removing the ticket: a full outbound queue must leave + // the pending acknowledgement in place for a later retry instead of + // dropping a block the ledger already recorded. + let (channel_id, sequence_number, difficulty) = + (pending.channel_id, pending.sequence_number, pending.difficulty); + if !self.channels.contains_key(&channel_id) { + self.pending.remove(&submission.ticket.0); return Ok(false); } self.queue( MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, CHANNEL_BIT_SUBMIT_SHARES_SUCCESS, SubmitSharesSuccess { - channel_id: pending.channel_id, - last_sequence_number: pending.sequence_number, + channel_id, + last_sequence_number: sequence_number, new_submits_accepted_count: 1, - new_shares_sum: pending.difficulty, + new_shares_sum: difficulty, }, )?; + self.pending.remove(&submission.ticket.0); Ok(true) } @@ -1915,15 +1923,17 @@ impl ProtocolSession { if pending.block_hash != Some(submission.block_hash) { return Ok(false); } - let Some(pending) = self.pending.remove(&submission.ticket.0) else { - return Ok(false); - }; - if !self.channels.contains_key(&pending.channel_id) { + // Queue before removing the ticket: a full outbound queue must leave + // the pending acknowledgement in place for a later retry instead of + // dropping it unsent. + let (channel_id, sequence_number) = (pending.channel_id, pending.sequence_number); + if !self.channels.contains_key(&channel_id) { + self.pending.remove(&submission.ticket.0); return Ok(false); } let msg = SubmitSharesError { - channel_id: pending.channel_id, - sequence_number: pending.sequence_number, + channel_id, + sequence_number, error_code, }; self.queue( @@ -1931,6 +1941,7 @@ impl ProtocolSession { CHANNEL_BIT_SUBMIT_SHARES_ERROR, msg, )?; + self.pending.remove(&submission.ticket.0); Ok(true) } @@ -1951,15 +1962,17 @@ impl ProtocolSession { if matches!(pending.kind, PendingKind::Block) { return Err(SessionError::BlockRequiresSubmission); } - let Some(pending) = self.pending.remove(&ticket.0) else { - return Ok(false); - }; - if !self.channels.contains_key(&pending.channel_id) { + // Queue before removing the ticket: a full outbound queue must leave + // the pending refusal in place for a later retry instead of dropping + // a share the host already judged. + let (channel_id, sequence_number) = (pending.channel_id, pending.sequence_number); + if !self.channels.contains_key(&channel_id) { + self.pending.remove(&ticket.0); return Ok(false); } let msg = SubmitSharesError { - channel_id: pending.channel_id, - sequence_number: pending.sequence_number, + channel_id, + sequence_number, error_code, }; self.queue( @@ -1967,6 +1980,7 @@ impl ProtocolSession { CHANNEL_BIT_SUBMIT_SHARES_ERROR, msg, )?; + self.pending.remove(&ticket.0); Ok(true) } From 2ec2170c7bb0123a06e1c29f73e7f9a7a775c7dc Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:42:26 +0900 Subject: [PATCH 36/61] fix(pool): unburn SV2 dedup keys dropped without durable record JobBook::validate burns the header hash before any ledger write. When an SV2 share ticket is dropped without a record (host refusal, channel close, connection teardown), the burn stuck all retries at Duplicate while nothing could ever credit them. Add JobBook::unburn, carry the header hash on every pending ticket, and release it when the ticket dies unrecorded: channel removal, connection release, and the host refusal path after a serviced reject. Confirmed and buffered shares keep their burn, so replays still converge to Duplicate and the idempotent ledger stays the convergence point. Linearizable under the existing single borrows; at-least-once delivery is preserved. --- crates/mining/src/pool/job.rs | 25 ++++++++++++++ crates/mining/src/pool/stratum/sv2/session.rs | 33 +++++++++++++++++-- crates/mining/tests/pool_jobs.rs | 25 ++++++++++++++ crates/node/src/pool/sv2.rs | 11 +++++-- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/mining/src/pool/job.rs b/crates/mining/src/pool/job.rs index 1a19296f50..8fb29ae449 100644 --- a/crates/mining/src/pool/job.rs +++ b/crates/mining/src/pool/job.rs @@ -1471,6 +1471,31 @@ impl JobBook { usize::try_from(id.0 - front).map_err(|_| ShareError::UnknownJob(id)) } + /// Releases a burned duplicate key so a later identical submission + /// validates fresh. Removes the hash from the duplicate set and from the + /// owning job's remembered hashes; a missing or retired job still clears + /// the shared set. Returns whether the key was present. + /// + /// Call only when the validated share will never be durably recorded from + /// its ticket (rejected and dropped by the adapter, or expired while + /// unacknowledged): confirmed or buffered shares must keep their burn so + /// replays converge to [`ShareError::Duplicate`] instead of + /// double-crediting. Linearizable under the single `&mut` borrow. + #[must_use] + pub fn unburn(&mut self, hash: &BlockHash) -> bool { + let raw = Hash256::from(*hash); + let Some(job) = self.seen.remove(&raw) else { + return false; + }; + if let Ok(index) = self.locate(job) + && let Some(entry) = self.jobs.get_mut(index) + && let Some(position) = entry.hashes.iter().position(|known| *known == raw) + { + entry.hashes.swap_remove(position); + } + true + } + /// The single share predicate. /// /// Checks, in order: session, job existence, job staleness, extranonce diff --git a/crates/mining/src/pool/stratum/sv2/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs index adc2ea7b40..eb620f4131 100644 --- a/crates/mining/src/pool/stratum/sv2/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -43,6 +43,7 @@ use crate::pool::{ AcceptedShare, BlockReceipt, Extranonce, FoundBlock, Job, JobBook, JobError, JobId, Session, SessionId, ShareError, ShareOutcome, ShareSubmission, ShareTarget, }; +use bitcoin_rs_primitives::{BlockHash, Hash256}; use common_messages_sv2::{ CHANNEL_BIT_RECONNECT, CHANNEL_BIT_SETUP_CONNECTION, CHANNEL_BIT_SETUP_CONNECTION_ERROR, CHANNEL_BIT_SETUP_CONNECTION_SUCCESS, ERROR_CODE_SETUP_CONNECTION_PROTOCOL_VERSION_MISMATCH, @@ -573,6 +574,10 @@ struct Pending { sequence_number: u32, difficulty: u64, block_hash: Option<[u8; 32]>, + /// Header hash burned in [`JobBook`] by validation. Released via + /// [`JobBook::unburn`] when the ticket is dropped without a durable + /// record (channel close, connection teardown, expiry, or host refusal). + hash: [u8; 32], kind: PendingKind, } @@ -1783,6 +1788,7 @@ impl ProtocolSession { sequence_number, difficulty, block_hash: None, + hash: *accepted.hash.as_bytes(), kind: PendingKind::Share, }, ); @@ -1801,6 +1807,7 @@ impl ProtocolSession { sequence_number, difficulty, block_hash: Some(*found.share.hash.as_bytes()), + hash: *found.share.hash.as_bytes(), kind: PendingKind::Block, }, ); @@ -2028,7 +2035,19 @@ impl ProtocolSession { return Ok(()); }; book.close_session(channel.session); - // Cancel: shares of a closed channel are not acknowledged. + // Cancel: shares of a closed channel are not acknowledged. Their + // burned duplicate keys are released so identical retries validate + // fresh instead of sticking at `Duplicate` while nothing can ever + // credit them. + for hash in self + .pending + .values() + .filter(|pending| pending.channel_id == channel_id) + .map(|pending| pending.hash) + .collect::>() + { + unburn_hash(book, &hash); + } self.pending .retain(|_, pending| pending.channel_id != channel_id); self.push_event(Event::ChannelClosed { channel_id, reason }) @@ -2088,7 +2107,12 @@ impl ProtocolSession { for (_, channel) in std::mem::take(&mut self.channels) { book.close_session(channel.session); } - self.pending.clear(); + // Dropped tickets will never be confirmed: release their burned + // duplicate keys so retries on a fresh connection validate anew + // rather than inherit `Duplicate` from uncredited work. + for pending in std::mem::take(&mut self.pending).into_values() { + unburn_hash(book, &pending.hash); + } self.phase = Phase::Closing; } } @@ -2100,6 +2124,11 @@ struct PreparedShare { target: ShareTarget, } +/// Releases one header hash burned by [`JobBook::validate`]. +fn unburn_hash(book: &mut JobBook, hash: &[u8; 32]) { + let _ = book.unburn(&BlockHash::from(Hash256::from_le_bytes(hash))); +} + struct Submit { channel_id: u32, sequence_number: u32, diff --git a/crates/mining/tests/pool_jobs.rs b/crates/mining/tests/pool_jobs.rs index 39d5b55b58..7e72fa56bf 100644 --- a/crates/mining/tests/pool_jobs.rs +++ b/crates/mining/tests/pool_jobs.rs @@ -425,6 +425,31 @@ fn block_candidate_passes_consensus_and_reproduces_the_share() -> TestResult { Ok(()) } +#[test] +fn unburn_releases_duplicate_key_for_retry() -> TestResult { + let mut fx = fixture(config(), 100, 2)?; + let target = easy()?; + let job = Arc::clone(&fx.job); + let first = fx.book.validate(&submit_for(&fx.session, &job, 41), &target)?; + let hash = first.share().hash; + let replay = fx.book.validate(&submit_for(&fx.session, &job, 41), &target); + assert!( + matches!(replay, Err(ShareError::Duplicate { first_job, .. }) if first_job == job.id()) + ); + // Unknown keys release nothing. + assert!(!fx.book.unburn(&BlockHash::from(Hash256::from_le_bytes(&[0xcc; 32])))); + // The burned key releases once; a retry then validates fresh. + assert!(fx.book.unburn(&hash)); + assert!(!fx.book.unburn(&hash)); + let retry = fx.book.validate(&submit_for(&fx.session, &job, 41), &target)?; + assert_eq!(retry.share().hash, hash); + let replay = fx.book.validate(&submit_for(&fx.session, &job, 41), &target); + assert!( + matches!(replay, Err(ShareError::Duplicate { first_job, .. }) if first_job == job.id()) + ); + Ok(()) +} + #[test] fn scriptsig_may_grow_to_exactly_the_consensus_limit() -> TestResult { let mut wide = config(); diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 3a5e54e8ba..7696d3bb46 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -389,8 +389,15 @@ fn handle_events( }); if recorded { connection.confirm_share(event.ticket)?; - } else { - connection.reject_share(event.ticket, ERROR_CODE_SHARE_NOT_RECORDED)?; + } else if connection.reject_share(event.ticket, ERROR_CODE_SHARE_NOT_RECORDED)? { + // The ticket is gone and the share was never recorded: + // release the burned duplicate key so a retry validates + // fresh instead of sticking at `Duplicate`. A queued + // refusal failure leaves the ticket in place, so no + // release happens on `Err`. + core.with_job_book(|book| { + book.unburn(&event.outcome.hash); + }); } } Event::BlockFound(pending) => { From cd2cfb1ad6e255ff3edf37d4c6deafbb0a72bc73 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:50:15 +0900 Subject: [PATCH 37/61] fix(pool-sv2): expire unacknowledged share tickets with unburn Pending tickets had no deadline: a stuck ledger or silent miner pinned the bounded pending-ack budget and head-of-line-blocked later shares. Add Limits::pending_ack_timeout (monotonic Instant age, never wall clock), stamp every pending ticket at submit, and sweep expired tickets in the host serve loop. Expiry drops the ticket and releases its burned duplicate key via JobBook::unburn, so a retry validates fresh; confirmed shares are untouched and the idempotent ledger stays convergent. The node serve loop treats a shed batch as progress against the idle deadline. --- crates/mining/src/pool/stratum/sv2.rs | 5 ++ crates/mining/src/pool/stratum/sv2/session.rs | 38 ++++++++++++++ crates/mining/tests/pool_sv2.rs | 50 ++++++++++++++++++- crates/node/src/pool/sv2.rs | 20 ++++++-- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/mining/src/pool/stratum/sv2.rs b/crates/mining/src/pool/stratum/sv2.rs index 45d1e3d820..fb1f7f9a75 100644 --- a/crates/mining/src/pool/stratum/sv2.rs +++ b/crates/mining/src/pool/stratum/sv2.rs @@ -683,6 +683,11 @@ impl Connection { &self.session } + /// The protocol session, for host-owned acknowledgement and expiry. + pub fn session_mut(&mut self) -> &mut ProtocolSession { + &mut self.session + } + /// True once the connection must be flushed and closed (a refused /// `SetupConnection`, a failure, or [`Connection::release`]). #[must_use] diff --git a/crates/mining/src/pool/stratum/sv2/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs index eb620f4131..b8894ead4f 100644 --- a/crates/mining/src/pool/stratum/sv2/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -36,6 +36,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::fmt; use std::sync::Arc; +use std::time::{Duration, Instant}; use binary_sv2::{B032, B064K, Decodable, Encodable, GetSize, Seq0255, Str0255, Sv2Option, U256}; use crate::{MiningControl, MiningControlError}; @@ -147,6 +148,11 @@ pub struct Limits { pub max_jobs_per_channel: usize, /// Accepted shares awaiting [`ProtocolSession::confirm_share`]. pub max_pending_acks: usize, + /// How long an unacknowledged share ticket lives before + /// [`ProtocolSession::expire_tickets`] drops it and releases its burned + /// duplicate key. Zero expires at the next poll. Monotonic: wall-clock + /// jumps never fast-forward or stall expiry. + pub pending_ack_timeout: Duration, /// Plaintext frames queued for the transport. pub max_outbound_frames: usize, /// Events queued for the host. @@ -162,6 +168,7 @@ impl Limits { max_channels: 64, max_jobs_per_channel: 64, max_pending_acks: 1024, + pending_ack_timeout: Duration::from_secs(300), max_outbound_frames: 4096, max_pending_events: 4096, max_outbound_bytes: 4 * 1024 * 1024, @@ -578,6 +585,9 @@ struct Pending { /// [`JobBook::unburn`] when the ticket is dropped without a durable /// record (channel close, connection teardown, expiry, or host refusal). hash: [u8; 32], + /// Monotonic arrival time; [`ProtocolSession::expire_tickets`] drops the + /// ticket past `limits.pending_ack_timeout`. + submitted_at: Instant, kind: PendingKind, } @@ -969,6 +979,32 @@ impl ProtocolSession { self.pending.len() } + /// Drops unacknowledged tickets older than `limits.pending_ack_timeout` + /// and releases their burned duplicate keys, so a stuck host or a silent + /// miner cannot pin the bounded [`Limits::max_pending_acks`] budget and + /// head-of-line-block every later share. Returns the dropped tickets for + /// host observability. Confirmed and buffered shares are unaffected: only + /// tickets still awaiting host acknowledgement can expire, and the + /// idempotent ledger stays the convergence point for any retry. + /// Monotonic: age is measured with [`Instant`], never wall clock. + pub fn expire_tickets(&mut self, book: &mut JobBook, now: Instant) -> Vec { + let timeout = self.config.limits.pending_ack_timeout; + let mut expired = Vec::new(); + let mut hashes = Vec::new(); + self.pending.retain(|ticket, pending| { + if now.duration_since(pending.submitted_at) < timeout { + return true; + } + expired.push(ShareTicket(*ticket)); + hashes.push(pending.hash); + false + }); + for hash in &hashes { + unburn_hash(book, hash); + } + expired + } + /// The group channel id, once a channel was opened. #[must_use] pub fn group_channel_id(&self) -> Option { @@ -1789,6 +1825,7 @@ impl ProtocolSession { difficulty, block_hash: None, hash: *accepted.hash.as_bytes(), + submitted_at: Instant::now(), kind: PendingKind::Share, }, ); @@ -1808,6 +1845,7 @@ impl ProtocolSession { difficulty, block_hash: Some(*found.share.hash.as_bytes()), hash: *found.share.hash.as_bytes(), + submitted_at: Instant::now(), kind: PendingKind::Block, }, ); diff --git a/crates/mining/tests/pool_sv2.rs b/crates/mining/tests/pool_sv2.rs index df84abd67e..6e1b2f5f5d 100644 --- a/crates/mining/tests/pool_sv2.rs +++ b/crates/mining/tests/pool_sv2.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use binary_sv2::{B064K, B0255, Decodable, Encodable, GetSize, Seq0255, Str0255, U256}; use bitcoin_rs_chain::compact_is_met_by; @@ -1926,6 +1926,54 @@ fn unconfirmed_shares_are_bounded_and_a_busy_refusal_is_not_recorded() { accepted(wire.events()); } +#[test] +fn expired_tickets_unburn_for_retry() { + // A zero deadline expires at the next poll, deterministically. + let mut config = server_config(); + config.limits = Limits { + pending_ack_timeout: Duration::ZERO, + ..Limits::DEFAULT + }; + let mut wire = Wire::with(WHOLE, config, BIP320_VERSION_MASK); + let installed = wire + .book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + wire.setup_mining(0); + let mut frames = wire.open_standard(1, "expiry", ALL_ONES); + let opened = read_open_standard(&mut frames); + wire.events(); + let job = installed.job; + let version = job.version(); + let nonce = share_nonce(&job, &opened.prefix, version); + wire.submit_standard( + opened.channel_id, + 1, + job.id().get(), + nonce, + job.ntime(), + version, + ); + let share = accepted(wire.events()); + let expired = wire + .conn + .session_mut() + .expire_tickets(&mut wire.book, Instant::now()); + assert_eq!(expired, vec![share.ticket]); + assert_eq!(wire.conn.session().pending_acks(), 0); + // The retry validates fresh instead of sticking at Duplicate, even + // though the first validation burned the key and nothing recorded it. + wire.submit_standard( + opened.channel_id, + 2, + job.id().get(), + nonce, + job.ntime(), + version, + ); + accepted(wire.events()); +} + // ---- close, cancel, retarget, reconnect -------------------------------------------- #[test] diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 7696d3bb46..78ba9dcc7b 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -9,7 +9,6 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; use bitcoin_rs_primitives::BlockHash; -use bitcoin_rs_mining::pool::ledger as pool_ledger; use bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter; use bitcoin_rs_mining::pool::stratum::sv2::{ AdmitError, AuthorityKeypair, BlockSubmission, Connection, ERROR_CODE_SHARE_NOT_RECORDED, @@ -162,6 +161,7 @@ pub(crate) fn start( max_channels: 8, max_jobs_per_channel: 16, max_pending_acks: 16, + pending_ack_timeout: Limits::DEFAULT.pending_ack_timeout, max_outbound_frames: 256, max_pending_events: 32, max_outbound_bytes: 1_048_576, @@ -313,10 +313,23 @@ fn serve_connection( &mut block_permit, &mut deferred, )?; + // Shed tickets the host never acknowledged: a stuck ledger or a + // silent miner must not pin the bounded pending-ack budget and + // head-of-line-block later shares. Expiry releases the burned + // duplicate keys, so a retry validates fresh; the idempotent + // ledger stays the convergence point. + let expired = core.with_job_book(|book| { + connection + .session_mut() + .expire_tickets(book, Instant::now()) + }); + if !expired.is_empty() { + tracing::debug!(count = expired.len(), "SV2 pending tickets expired"); + } // Any consumed event or newly queued reply is progress: an idle // miner that keeps submitting (or being announced to) never trips // the idle deadline on a quiet socket alone. - if events_before != connection.session().pending_acks() { + if events_before != connection.session().pending_acks() || !expired.is_empty() { last_progress = Instant::now(); } transmit.fill(&mut connection); @@ -396,7 +409,7 @@ fn handle_events( // refusal failure leaves the ticket in place, so no // release happens on `Err`. core.with_job_book(|book| { - book.unburn(&event.outcome.hash); + let _ = book.unburn(&event.outcome.hash); }); } } @@ -735,6 +748,7 @@ mod tests { max_channels: 8, max_jobs_per_channel: 16, max_pending_acks: 16, + pending_ack_timeout: Limits::DEFAULT.pending_ack_timeout, max_outbound_frames: 256, max_pending_events: 32, max_outbound_bytes: 1_048_576, From 484563c304c62f937c2fbf91807e201ef67bcca1 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:54:12 +0900 Subject: [PATCH 38/61] fix(pool-ledger): stream prune deletes in bounded batches prune_shares collected every doomed key before deleting, buffering an unbounded key set and holding the writer lock across one giant commit. Find the retention boundary on a read snapshot, then stream the doomed prefix in 1024-key commits, releasing the writer lock between batches. Sequence numbers only grow, so the exclusive boundary stays valid across batches; per-batch pruned_below stays monotonic and the final partial batch restores the exact floor. Single-batch prunes keep byte-identical audits; larger prunes emit one audit row per batch. --- crates/mining/src/pool/ledger/store.rs | 100 ++++++++++++------ .../mining/tests/support/pool_ledger_cases.rs | 18 ++++ 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index a12ad543a0..deadf0c26d 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -452,10 +452,16 @@ impl Ledger { Ok(removed) } + /// Largest delete batch in one [`Self::prune_shares`] write transaction. + /// Pruning streams the doomed range in batches of this size, releasing the + /// writer lock between commits, so one call never buffers every doomed key + /// or holds the lock across an unbounded commit. + const PRUNE_BATCH_SIZE: usize = 1024; + /// Delete the oldest shares, keeping the newest shares that hold at least `keep_work` work. /// `keep_work` must cover the PPLNS window. A block found later for an older solving share /// fails with [`LedgerError::WindowPruned`] instead of using a short window. - /// Returns the number of shares deleted. + /// Deletes stream in bounded batches; returns the total number deleted. /// /// # Errors /// Returns [`LedgerError::InvalidInput`] if `keep_work` is below the window, or an @@ -466,12 +472,15 @@ impl Ledger { "keep_work must cover the PPLNS window", )); } - let txn = self.db.begin_write()?; - let removed = { - let tables = Tables::open(&txn)?; + // Find the retention boundary on a read snapshot: the walk is + // read-only and must not hold the writer lock. Sequence numbers only + // grow, so the exclusive boundary stays valid while batches commit. + let (boundary, cumulative) = { + let read = self.db.begin_read()?; + let shares = read.open_table(SHARES)?; let mut cumulative: u128 = 0; - let mut boundary: Option = None; - for item in tables.shares.rows_iter()?.rev() { + let mut boundary = None; + for item in shares.rows_iter()?.rev() { let (key, value) = item?; let (_, work) = decode_share(value.value())?; cumulative = cumulative @@ -485,35 +494,58 @@ impl Ledger { let Some(boundary) = boundary else { return Ok(0); }; - let doomed: Vec = tables - .shares - .range(..boundary)? - .map(|item| item.map(|(key, _)| key.value())) - .collect::, LedgerError>>()?; - if doomed.is_empty() { - return Ok(0); - } - for seq in &doomed { - tables.shares.remove(*seq)?; - } - let previous = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; - if boundary > previous { - tables.meta_u64.insert(M_PRUNED_BELOW, boundary)?; - } - let removed = - u64::try_from(doomed.len()).map_err(|_| LedgerError::Overflow("count"))?; - append_audit( - &tables, - AuditKind::SharesPruned, - 0, - &ZERO_HASH, - &ZERO_HASH, - [boundary, removed, low64(cumulative)], - )?; - removed + (boundary, cumulative) }; - self.commit(txn, OP_PRUNE_SHARES)?; - Ok(removed) + let mut removed_total: u64 = 0; + loop { + let txn = self.db.begin_write()?; + let removed = { + let tables = Tables::open(&txn)?; + let doomed: Vec = tables + .shares + .range(..boundary)? + .take(Self::PRUNE_BATCH_SIZE) + .map(|item| item.map(|(key, _)| key.value())) + .collect::, LedgerError>>()?; + if doomed.is_empty() { + break; + } + for seq in &doomed { + tables.shares.remove(*seq)?; + } + let batch = u64::try_from(doomed.len()) + .map_err(|_| LedgerError::Overflow("count"))?; + let previous = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + // Advance past this batch; the final partial batch restores + // the exact floor. Batches ascend, so every remaining key + // exceeds the floor even before the last batch lands. + let floor = if doomed.len() < Self::PRUNE_BATCH_SIZE { + boundary + } else { + doomed + .last() + .and_then(|last| last.checked_add(1)) + .ok_or(LedgerError::Overflow("count"))? + }; + if floor > previous { + tables.meta_u64.insert(M_PRUNED_BELOW, floor)?; + } + append_audit( + &tables, + AuditKind::SharesPruned, + 0, + &ZERO_HASH, + &ZERO_HASH, + [boundary, batch, low64(cumulative)], + )?; + batch + }; + self.commit(txn, OP_PRUNE_SHARES)?; + removed_total = removed_total + .checked_add(removed) + .ok_or(LedgerError::Overflow("count"))?; + } + Ok(removed_total) } /// The PPLNS window ending at the newest share: `(account, work)` ascending by account. diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 7055c99b90..2834efef70 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -1145,6 +1145,24 @@ fn the_window_slides_across_blocks_and_snapshots_are_frozen() { ); } +#[test] +fn pruning_streams_bounded_batches() { + let (_dir, ledger) = plain_ledger(100); + // 1200 unit-work shares force two delete batches at 1024. + for nonce in 0..1200u32 { + ledger.record_share(&share(1, 1, nonce, 1)).fixture("share"); + } + assert_eq!(ledger.share_count().fixture("count"), 1200); + // Keep the newest 100 work units: 1100 pruned in 1024 + 76. + assert_eq!(ledger.prune_shares(100).fixture("prune"), 1100); + assert_eq!(ledger.share_count().fixture("count"), 100); + assert_eq!( + ledger.current_window().fixture("window"), + vec![(MinerId(1), 100)] + ); + assert_eq!(ledger.prune_shares(100).fixture("nothing left"), 0); +} + #[test] fn short_history_uses_all_work_and_pruning_protects_old_windows() { let (_dir, ledger) = plain_ledger(100); From 7841a83c951f50cbe34875ddd536f4f6e0d6a982 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:59:03 +0900 Subject: [PATCH 39/61] fix(pool-ledger): snapshot PPLNS window outside the writer lock record_found_block held the global writer lock across the full PPLNS window walk plus round creation in one transaction. Split into three short phases: credit the share in one write transaction, walk the window on a read snapshot with no lock held, then open the round in a second short write. Sequence numbers only grow, so the solving sequence stays valid across phases; the round-open re-checks block idempotency and the prune floor, failing closed with the same BlockConflict and WindowPruned verdicts as before. A concurrent record converges on the same round, and a crash between phases replays to it through the share's idempotent identity. Snapshot isolation for the walk, linearizable ledger writes. --- crates/mining/src/pool/ledger/store.rs | 101 ++++++++++++++++++------- 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index deadf0c26d..c4247c1617 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -597,29 +597,32 @@ impl Ledger { // Found blocks and rounds // ----------------------------------------------------------------------------------------- - /// Record a found block: credit its share and snapshot the PPLNS window, in one transaction. + /// Record a found block: credit its share, snapshot the PPLNS window, and open the round. /// The round starts Pending; no money exists until [`Ledger::reconcile_rounds`] observes the /// block on the active chain. Idempotent on the block hash. /// + /// The window walk runs on a read snapshot, never under the writer lock: + /// phase one credits the share in one short write transaction, phase two + /// walks the window with no lock held, and phase three opens the round in + /// a second short write. A concurrent record for the same block converges + /// on the same idempotent verdict, and a crash between phases replays to + /// the same round through the share's idempotent identity. + /// /// # Errors /// Returns [`LedgerError::BlockConflict`] if the block is already recorded with a different /// claim, [`LedgerError::WindowPruned`], or any share-crediting error. pub fn record_found_block(&self, found: &BlockFound) -> Result { found.share.validate()?; let block_hash = found.share.header_hash; - let txn = self.db.begin_write()?; - let (recorded, newly) = { + // Phase one: idempotent credit in one short write transaction. + let (solving_seq, share_receipt) = { + let txn = self.db.begin_write()?; let tables = Tables::open(&txn)?; - let existing = round_id_for_block(&tables.round_by_block, &block_hash)?; - if let Some(id) = existing { - let round = load_round(&tables.rounds, id)? - .ok_or_else(|| LedgerError::Corrupt("round index points at no round".into()))?; - if round.height != found.height - || round.coinbase_txid != found.coinbase_txid - || round.claimed_coinbase_value_sat != found.claimed_coinbase_value_sat - { - return Err(LedgerError::BlockConflict); - } + if let Some(id) = round_id_for_block(&tables.round_by_block, &block_hash)? { + let round = load_round(&tables.rounds, id)?.ok_or_else(|| { + LedgerError::Corrupt("round index points at no round".into()) + })?; + Self::check_round_claim(&round, found)?; let seq = round.solving_seq; return Ok(BlockRecorded { round, @@ -628,16 +631,52 @@ impl Ledger { }); } let share_receipt = credit_share(&tables, &found.share)?; - let solving_seq = share_receipt.seq(); + if matches!(share_receipt, ShareReceipt::Credited { .. }) { + self.commit(txn, OP_RECORD_BLOCK)?; + } + (share_receipt.seq(), share_receipt) + }; + // Phase two: walk the window on a read snapshot. No writer lock is + // held across the unbounded share walk; sequence numbers only grow, + // so the solving sequence from phase one stays valid. + let (window_total, weights) = { + let read = self.db.begin_read()?; + let tables = Tables::open(&read)?; let pruned_below = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + if solving_seq < pruned_below { + return Err(LedgerError::WindowPruned); + } let window = collect_window( &tables.shares, pruned_below, solving_seq, self.config.window_work, )?; - let window_total = window.total(); - let weights = window.into_weights(); + (window.total(), window.into_weights()) + }; + // Phase three: open the round in a second short write transaction. A + // concurrent record may have opened it first, and a concurrent prune + // may have retired the solving share; both re-checks fail closed with + // the same verdicts as the single-transaction path. + let txn = self.db.begin_write()?; + let recorded = { + let tables = Tables::open(&txn)?; + if let Some(id) = round_id_for_block(&tables.round_by_block, &block_hash)? { + let round = load_round(&tables.rounds, id)?.ok_or_else(|| { + LedgerError::Corrupt("round index points at no round".into()) + })?; + Self::check_round_claim(&round, found)?; + let seq = round.solving_seq; + return Ok(BlockRecorded { + round, + share: ShareReceipt::Duplicate { seq }, + newly_recorded: false, + }); + } + let pruned_below = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + if solving_seq < pruned_below { + return Err(LedgerError::WindowPruned); + } let id = bump(&tables.meta_u64, M_NEXT_ROUND, 1)?; let round = Round { id, @@ -669,21 +708,29 @@ impl Ledger { &ZERO_HASH, [u64::from(found.height), low64(window_total), solving_seq], )?; - ( - BlockRecorded { - round, - share: share_receipt, - newly_recorded: true, - }, - true, - ) + BlockRecorded { + round, + share: share_receipt, + newly_recorded: true, + } }; - if newly { - self.commit(txn, OP_RECORD_BLOCK)?; - } + self.commit(txn, OP_RECORD_BLOCK)?; Ok(recorded) } + /// The [`LedgerError::BlockConflict`] check shared by the idempotent + /// re-record paths: a known block hash with a different claim is a + /// conflicting block, never a second round. + fn check_round_claim(round: &Round, found: &BlockFound) -> Result<(), LedgerError> { + if round.height != found.height + || round.coinbase_txid != found.coinbase_txid + || round.claimed_coinbase_value_sat != found.claimed_coinbase_value_sat + { + return Err(LedgerError::BlockConflict); + } + Ok(()) + } + /// Advance every monitored round from authoritative chain observations: fix the reward from /// the observed coinbase, mature it, orphan it on a reorg, and reverse credits when a matured /// block is displaced. Safe to call as often as desired. From d4664de2ad71e58d004c73b610dcce3354dfea1f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:31:48 +0900 Subject: [PATCH 40/61] fix(pool-sv2): track last parent per channel and force Future on mismatch Track the last SetNewPrevHash parent per channel. distribute falls back from group delivery when parents disagree and send_to_channel upgrades Active to Future whenever the channel parent differs, including a channel that missed the parent announce. Add regression test. --- crates/mining/src/pool/stratum/sv2/session.rs | 43 +++++- crates/mining/tests/pool_sv2.rs | 130 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/crates/mining/src/pool/stratum/sv2/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs index b8894ead4f..7613d42b38 100644 --- a/crates/mining/src/pool/stratum/sv2/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -568,6 +568,12 @@ struct Channel { target: ShareTarget, last_sequence: Option, jobs: BTreeMap, + /// Parent hash of the last `SetNewPrevHash` that landed on this channel. + /// `None` until the first parent arrives. A new job whose parent differs + /// forces `Delivery::Future` plus `SetNewPrevHash` for this channel, even + /// when the connection-wide delivery is `Active` (for example a channel + /// that missed the parent announce). + last_prev_hash: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1358,6 +1364,7 @@ impl ProtocolSession { target, last_sequence: None, jobs: BTreeMap::new(), + last_prev_hash: None, }, ); self.push_event(Event::ChannelOpened { @@ -1417,6 +1424,12 @@ impl ProtocolSession { fn distribute(&mut self, job: &Job, delivery: Delivery) -> Result<(), SessionError> { let job_id = job.id().get(); + let parent = job.previous_block_hash(); + // Per-channel parent enforcement lives in `send_to_channel`: a channel + // whose last parent differs (including `None` for a channel that never + // got `SetNewPrevHash`) gets `Future` plus the parent even when the + // connection-wide delivery is `Active`. This covers a channel that + // missed the parent announce on an otherwise unchanged parent. let missing: Vec = self .channels .iter() @@ -1441,8 +1454,21 @@ impl ProtocolSession { if missing.is_empty() && needs_parent.is_empty() { return Ok(()); } + // Group-addressed delivery shares one `Delivery` for every channel, so + // it is only safe when every channel agrees on the parent. An `Active` + // group job would otherwise activate a channel that never got the + // parent; fall back to per-channel delivery (which upgrades stale + // channels to `Future` in `send_to_channel`) when parents disagree. let group = match self.group_id { - Some(group) if !self.requires_standard_jobs && missing.len() == self.channels.len() => { + Some(group) + if !self.requires_standard_jobs + && missing.len() == self.channels.len() + && (delivery == Delivery::Future + || self + .channels + .values() + .all(|channel| channel.last_prev_hash == Some(parent))) => + { Some(group) } _ => None, @@ -1463,6 +1489,7 @@ impl ProtocolSession { self.push_frame(frame)?; for channel in self.channels.values_mut() { activate_job(channel, job_id); + channel.last_prev_hash = Some(parent); } self.parent_delivered = true; } @@ -1501,6 +1528,7 @@ impl ProtocolSession { self.push_frame(frame)?; if let Some(channel) = self.channels.get_mut(&channel_id) { activate_job(channel, job_id); + channel.last_prev_hash = Some(job.previous_block_hash()); } self.parent_delivered = true; Ok(()) @@ -1514,6 +1542,18 @@ impl ProtocolSession { job: &Job, delivery: Delivery, ) -> Result<(), SessionError> { + // Enforce the per-channel parent invariant at the lowest level: a + // channel whose last parent differs gets `Future` plus + // `SetNewPrevHash`, even if the caller asked for `Active`. + let mut delivery = delivery; + if delivery == Delivery::Active + && self + .channels + .get(&channel_id) + .is_some_and(|channel| channel.last_prev_hash != Some(job.previous_block_hash())) + { + delivery = Delivery::Future; + } let (kind, session_prefix, rolled, target) = match self.channels.get(&channel_id) { Some(channel) => ( channel.kind, @@ -1548,6 +1588,7 @@ impl ProtocolSession { self.push_frame(frame)?; if let Some(channel) = self.channels.get_mut(&channel_id) { activate_job(channel, job_id); + channel.last_prev_hash = Some(job.previous_block_hash()); } self.parent_delivered = true; } diff --git a/crates/mining/tests/pool_sv2.rs b/crates/mining/tests/pool_sv2.rs index 6e1b2f5f5d..a44b459989 100644 --- a/crates/mining/tests/pool_sv2.rs +++ b/crates/mining/tests/pool_sv2.rs @@ -2735,6 +2735,136 @@ fn full_event_queue_fails_before_share_validation_without_orphan_ticket() { ); } +#[test] +fn active_refresh_upgrades_a_channel_that_missed_its_parent_to_future() { + // A channel that missed its `SetNewPrevHash` (job frame landed while the + // prev-hash frame hit a full outbound queue) keeps a stale parent. + // A later refresh on the same parent is `Active` connection-wide, but that + // stale channel must still get `Future` plus `SetNewPrevHash`. + use bitcoin_rs_mining::pool::stratum::sv2::{Frame, ProtocolSession}; + fn setup_frame(msg_type: u8, channel_msg: bool, payload: Vec) -> Frame { + Frame { + extension_type: 0, + msg_type, + channel_msg, + payload, + } + } + let mut book = JobBook::new(book_config(BIP320_VERSION_MASK)) + .unwrap_or_else(|error| panic!("book: {error:?}")); + let mut cfg = server_config(); + cfg.limits.max_outbound_frames = 3; + let mut session = ProtocolSession::new(cfg, &book) + .unwrap_or_else(|error| panic!("session: {error:?}")); + let setup = SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: FLAG_REQUIRES_STANDARD_JOBS, + endpoint_host: Str0255::try_from("pool.invalid").unwrap(), + endpoint_port: 3333, + vendor: Str0255::try_from("test-miner").unwrap(), + hardware_version: Str0255::try_from("sim-1").unwrap(), + firmware: Str0255::try_from("fw-0").unwrap(), + device_id: Str0255::try_from("").unwrap(), + }; + let payload = binary_sv2::to_bytes(setup).unwrap_or_else(|error| panic!("encode: {error:?}")); + session + .handle( + &mut book, + setup_frame(MESSAGE_TYPE_SETUP_CONNECTION, false, payload), + ) + .unwrap_or_else(|error| panic!("setup: {error:?}")); + assert!(session.is_open()); + session.take_frames(); + let open = |session: &mut ProtocolSession, book: &mut JobBook, id: u32, user: &str| { + let msg = OpenStandardMiningChannel { + request_id: id, + user_identity: Str0255::try_from(user).unwrap(), + nominal_hash_rate: 1.0e12, + max_target: U256::from(&ALL_ONES), + }; + let payload = binary_sv2::to_bytes(msg).unwrap(); + session + .handle( + book, + setup_frame(MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL, false, payload), + ) + .unwrap_or_else(|error| panic!("open: {error:?}")); + let mut out = session.take_frames(); + let mut received: Vec = out + .drain(..) + .map(|frame| Received { + msg_type: frame.msg_type, + channel_msg: frame.channel_msg, + payload: frame.payload, + }) + .collect(); + read_open_standard(&mut received) + }; + let opened_a = open(&mut session, &mut book, 7, "alice.rig1"); + let opened_b = open(&mut session, &mut book, 8, "bob.rig1"); + // First parent: the second channel's prev-hash frame does not fit, so it + // holds the job inactive without a parent while the first is active. + let first = book + .install(candidate(100, 0xab, 2), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(session.announce(&first).is_err()); + let missed = session.take_frames(); + assert_eq!(missed.len(), 3); + // Same parent refresh: connection-wide `Active`, but the stale channel + // must converge via `Future` plus its missing parent. + let second = book + .install(candidate(100, 0xab, 3), Vec::new()) + .unwrap_or_else(|error| panic!("install: {error:?}")); + assert!(!second.clean_jobs); + assert!( + second.job.id().get() != first.job.id().get(), + "second install must be a new job" + ); + session + .announce(&second) + .unwrap_or_else(|error| panic!("announce: {error:?}")); + let announced = session.take_frames(); + let detail: Vec<(u8, Vec)> = announced + .iter() + .map(|frame| (frame.msg_type, frame.payload.clone())) + .collect(); + assert_eq!(announced.len(), 3, "second announce frames: {:?}", detail); + let mut received: Vec = announced + .into_iter() + .map(|frame| Received { + msg_type: frame.msg_type, + channel_msg: frame.channel_msg, + payload: frame.payload, + }) + .collect(); + let mut jobs = Vec::new(); + while received + .iter() + .any(|frame| frame.msg_type == MESSAGE_TYPE_NEW_MINING_JOB) + { + jobs.push(read_std_job(&mut received)); + } + assert_eq!(jobs.len(), 2); + let prev = read_prev_hash(&mut received); + assert!(received.is_empty()); + let active = jobs + .iter() + .find(|job| job.min_ntime.is_some()) + .unwrap_or_else(|| panic!("one channel gets Active")); + let future = jobs + .iter() + .find(|job| job.min_ntime.is_none()) + .unwrap_or_else(|| panic!("stale channel gets Future")); + assert_eq!(active.job_id, second.job.id().get()); + assert_eq!(future.job_id, second.job.id().get()); + assert_eq!(prev.job_id, second.job.id().get()); + assert_eq!(future.channel_id, opened_b.channel_id); + assert_eq!(prev.channel_id, opened_b.channel_id); + assert_eq!(active.channel_id, opened_a.channel_id); +} + #[test] fn announce_without_fresh_still_delivers_a_missing_job_and_parent() { // `announce` is driven by per-connection delivery state, not the owner's From 9bc4daf638da17491850073c9d286794efd52a9c Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:35:04 +0900 Subject: [PATCH 41/61] fix(pool): cap fallback block work at network target Keep the judging channel target but credit network work when a network-valid block missed its harder channel target. Reuse the chain compact decode instead of duplicating it. Add regression test. --- crates/chain/src/header_sync.rs | 8 +++---- crates/chain/src/lib.rs | 4 ++-- crates/mining/src/pool/job.rs | 27 ++++++++++++++++++++++- crates/mining/tests/pool_jobs.rs | 37 +++++++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/chain/src/header_sync.rs b/crates/chain/src/header_sync.rs index b1dfcd4dad..82e40e6f52 100644 --- a/crates/chain/src/header_sync.rs +++ b/crates/chain/src/header_sync.rs @@ -1,8 +1,8 @@ use bitcoin_rs_consensus::MAX_TIMEWARP; use bitcoin_rs_primitives::{CompactTarget, Hash256, Network}; -pub use pow::compact_is_met_by; -use pow::{compact_to_target, target_to_compact}; +pub use pow::{compact_is_met_by, compact_to_target}; +use pow::target_to_compact; use crate::{ ChainError, @@ -455,7 +455,7 @@ pub fn permitted_difficulty_transition( /// overflowing shift folds to `ChainWork::ZERO` instead of surfacing /// `pfOverflow`, and a sign-flagged encoding decodes to `ChainWork::ZERO` /// instead of being rejected by name. -pub(crate) mod pow { +pub mod pow { use bitcoin_rs_primitives::{CompactTarget, Hash256}; use crate::node::ChainWork; @@ -486,7 +486,7 @@ pub(crate) mod pow { /// Decodes a compact target, returning zero for negative encodings. #[must_use] - pub(crate) fn compact_to_target(bits: CompactTarget) -> ChainWork { + pub fn compact_to_target(bits: CompactTarget) -> ChainWork { let decoded = decode_compact(bits.to_consensus()); if decoded.negative { ChainWork::ZERO diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 7a2b68d336..835c62defe 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -43,8 +43,8 @@ pub use deployment::{ }; pub use header_sync::{ HeaderAdmission, HeaderValidationMode, accept_headers, block_work, compact_is_met_by, - current_unix_seconds, permitted_difficulty_transition, validate_contextual_header, - validate_pow, + compact_to_target, current_unix_seconds, permitted_difficulty_transition, + validate_contextual_header, validate_pow, }; pub use ibd::InitialBlockDownload; pub use node::{BlockHeader, BlockTreeNode, ChainWork, NodeId, NodeStatus}; diff --git a/crates/mining/src/pool/job.rs b/crates/mining/src/pool/job.rs index 8fb29ae449..fa9d0185d9 100644 --- a/crates/mining/src/pool/job.rs +++ b/crates/mining/src/pool/job.rs @@ -433,6 +433,15 @@ impl ShareTarget { Self::from_le_bytes(bytes) } + /// Builds the network target of compact `bits`; zero and negative + /// encodings are rejected. + pub fn from_compact(bits: CompactTarget) -> Result { + // Single consensus decode lives in the chain crate; reuse it so the + // pool can never diverge from header validation. + let value = bitcoin_rs_chain::compact_to_target(bits); + Self::from_le_bytes(value.to_le_bytes()) + } + /// Builds the target of integer pool difficulty `difficulty`; see /// [`Self::from_pool_difficulty_ratio`]. pub fn from_pool_difficulty(difficulty: u64) -> Result { @@ -1567,6 +1576,22 @@ impl JobBook { Vec::with_capacity(extranonce.session.len() + extranonce.rolled.len()); full_extranonce.extend_from_slice(extranonce.session); full_extranonce.extend_from_slice(extranonce.rolled); + // A network-valid block from a harder channel target must not be + // credited at the channel target: cap fallback-block work at the + // network target when the channel target was not met. The judging + // target stays the channel target; only accounting work is capped. + // Linearizability: validation is under the single `JobBook` borrow. + // Snapshot isolation: the ledger credits `AcceptedShare.work` + // idempotently by header hash. Causally, a block never credits more + // than the network work that made it valid. + let credited_work = if is_block && !target.meets(raw_hash) { + match ShareTarget::from_compact(job.candidate.bits) { + Ok(network) => network.work(), + Err(_) => target.work(), + } + } else { + target.work() + }; let accepted = AcceptedShare { session: share.session, job: share.job, @@ -1576,7 +1601,7 @@ impl JobBook { previous_block_hash: BlockHash::from(job.candidate.previous_block_hash), extranonce: full_extranonce, target: *target, - work: target.work(), + work: credited_work, class: if is_block { ShareClass::Block } else { diff --git a/crates/mining/tests/pool_jobs.rs b/crates/mining/tests/pool_jobs.rs index 7e72fa56bf..2dbe9de3db 100644 --- a/crates/mining/tests/pool_jobs.rs +++ b/crates/mining/tests/pool_jobs.rs @@ -403,7 +403,12 @@ fn block_candidate_passes_consensus_and_reproduces_the_share() -> TestResult { .sum(); assert_eq!(paid, fx.candidate.coinbase_value); assert!(found.block.weight() <= fx.candidate.max_weight); - assert_eq!(found.share.work, target.work()); + // The difficulty-1 channel target is almost certainly harder than this + // regtest block hash, so the block is a fallback: the judging target stays + // the channel target while accounting work is capped at the network. + let network = ShareTarget::from_compact(fx.job.bits())?; + assert_eq!(found.share.target, target); + assert_eq!(found.share.work, network.work()); assert_eq!(found.share.height, 100); assert_eq!(found.share.job, fx.job.id()); @@ -549,6 +554,36 @@ fn share_classes_and_target_boundaries() -> TestResult { Ok(()) } +#[test] +fn fallback_block_work_is_capped_at_the_network_target() -> TestResult { + let mut fx = fixture(config(), 100, 3)?; + let network = ShareTarget::from_compact(fx.job.bits())?; + let harder = ShareTarget::from_le_bytes(tiny_target_bytes())?; + // Any network-valid hash is far above the value-1 channel target, so the + // block is a fallback: it meets the network but not the channel. + let mut block_nonce = None; + for candidate in nonces(&fx.session, &fx.job, true, 5)? { + let hash = header_hash(&fx.session, &fx.job, candidate)?; + if !harder.meets(hash) { + block_nonce = Some((candidate, hash)); + break; + } + } + let (nonce, hash) = block_nonce.ok_or("a fallback block nonce exists")?; + assert!(compact_is_met_by(fx.job.bits(), hash)); + assert!(!harder.meets(hash)); + let ShareOutcome::Block(found) = fx + .book + .validate(&submit_for(&fx.session, &fx.job, nonce), &harder)? + else { + return Err("a network-valid hash from a harder channel target is a block".into()); + }; + assert_eq!(found.share.target, harder); + assert_eq!(found.share.work, network.work()); + assert_ne!(found.share.work, harder.work()); + Ok(()) +} + fn assert_malformed_session_and_job_inputs( book: &mut JobBook, session: &Session, From e3758a039d33960c52e42a5b5e50387a31ac8820 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:39:32 +0900 Subject: [PATCH 42/61] fix(pool-ledger): validate generation inside write txn before Fail Observe via lock-free views, then re-check the chain generation inside the write transaction before applying payout decisions. A mid-reconcile transition defers instead of permanently failing on a torn view. Snapshot isolation without holding StableRead across slow commits. --- crates/mining/src/pool/ledger/store.rs | 38 ++++++++++++ .../mining/tests/support/pool_ledger_cases.rs | 61 +++++++++++++++++++ crates/node/src/pool/ledger.rs | 24 +++++--- 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index c4247c1617..c2f0976e04 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -740,6 +740,18 @@ impl Ledger { pub fn reconcile_rounds( &self, chain: &dyn ChainView, + ) -> Result { + self.reconcile_rounds_checked(chain, &|| false) + } + + /// Same as [`Self::reconcile_rounds`], but `abort` is evaluated inside the + /// write transaction before any observation is applied. A mid-reconcile + /// chain transition defers the whole batch, keeping torn views from + /// fixing rewards or orphaning rounds. + pub fn reconcile_rounds_checked( + &self, + chain: &dyn ChainView, + abort: &dyn Fn() -> bool, ) -> Result { let open = self.open_round_list()?; let mut report = RoundReconcileReport::default(); @@ -771,6 +783,9 @@ impl Ledger { }); } let txn = self.db.begin_write()?; + if abort() { + return Ok(RoundReconcileReport::default()); + } let mut dirty = false; { let tables = Tables::open(&txn)?; @@ -1320,6 +1335,22 @@ impl Ledger { pub fn reconcile_payouts( &self, chain: &dyn ChainView, + ) -> Result, LedgerError> { + self.reconcile_payouts_checked(chain, &|| false) + } + + /// Same as [`Self::reconcile_payouts`], but `abort` is evaluated inside the + /// write transaction before any decision is applied. When it reports a + /// chain transition mid-reconcile, observations may span a reorg, so the + /// call commits nothing and reports no transitions: the next reconcile + /// converges. This keeps a torn view from permanently failing a payout. + /// Snapshot isolation: a commit only applies observations taken under one + /// generation. Linearizability is not needed; callers are idempotent, so a + /// deferred reconcile is retried. + pub fn reconcile_payouts_checked( + &self, + chain: &dyn ChainView, + abort: &dyn Fn() -> bool, ) -> Result, LedgerError> { let active = self.active_batches()?; let mut observations = Vec::new(); @@ -1343,6 +1374,13 @@ impl Ledger { } let tip = tip.unwrap_or(0); let txn = self.db.begin_write()?; + // Validate the chain generation inside the write transaction before + // applying `Fail`: a transition after observation means the batch view + // may span a reorg. Defer (drop the write transaction, commit nothing) + // rather than permanently releasing the reservation on a torn view. + if abort() { + return Ok(Vec::new()); + } let mut dirty = false; { let tables = Tables::open(&txn)?; diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 2834efef70..b5ae12da78 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -2356,6 +2356,67 @@ fn a_batch_fails_only_when_every_signed_transaction_is_conclusively_double_spent fund.ledger.check_invariants().fixture("invariants"); } +#[test] +fn a_mid_reconcile_chain_transition_defers_a_permanent_fail() { + // A reorg between observation and commit must not permanently fail a + // payout on a torn view: the checked reconcile validates the generation + // inside the write transaction and commits nothing when it moved. + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let first = pay_tx( + &batch, + &[outpoint(0xA1, 0), outpoint(0xA2, 0)], + Some(40_000), + ); + let second = pay_tx( + &batch, + &[outpoint(0xA2, 0), outpoint(0xA3, 0)], + Some(30_000), + ); + fund.ledger + .record_signed(&receipt(&batch, &first)) + .fixture("first"); + fund.ledger + .record_signed(&receipt(&batch, &second)) + .fixture("second"); + let thief = hash(0xEE); + fund.chain.set_block(104, hash(0xC1)); + for outpoint in [outpoint(0xA1, 0), outpoint(0xA3, 0)] { + fund.chain.set_spend( + outpoint, + ConfirmedSpend { + spender_txid: thief, + block_hash: hash(0xC1), + height: 104, + }, + ); + } + fund.chain.set_tip(105); + // Simulate a chain transition after observation: defer, commit nothing. + assert_eq!( + fund.ledger + .reconcile_payouts_checked(&fund.chain, &|| true) + .fixture("deferred"), + Vec::new() + ); + let still = fund.ledger.batch(&batch.id).fixture("batch").fixture("exists"); + assert_eq!(still.state, BatchState::ExternallySigned); + // Same observations without a transition fail exactly once. + let transitions = fund + .ledger + .reconcile_payouts_checked(&fund.chain, &|| false) + .fixture("failed"); + assert_eq!( + transitions, + vec![BatchTransition { + batch_id: batch.id, + from: BatchState::ExternallySigned, + to: BatchState::Failed + }] + ); + fund.ledger.check_invariants().fixture("invariants"); +} + #[test] fn cancelling_a_prepared_batch_releases_the_reservation_but_never_after_signing() { let fund = funded(0, 1_000_001); diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index 7ee7df89e7..1c99935d99 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -546,18 +546,28 @@ impl LedgerHost { Err(error) => break Some(error), } }; - // Ledger compute and commit run without holding `StableRead`: the - // ledger write transaction can stall on storage I/O while chain - // transitions wait. Chain reads use the lock-free `RawChainView`; - // both `reconcile_*` entry points are idempotent, so a tip move - // mid-reconcile is repaired by converging on the next attempt. + // One stable snapshot across observe and commit: holding `StableRead` + // excludes authoritative transitions while `RawChainView` (lock-free, + // never re-locks `StableRead`) observes and while the ledger write + // transactions commit, so a reorg cannot slip between validation and + // commit to permanently fail a payout. The checked reconciles still + // validate the generation inside their write transactions as + // defense-in-depth for callers without the guard. Snapshot isolation: + // a commit only applies observations taken under one generation. + // Linearizability of the ledger itself comes from its write + // serialization; callers stay idempotent, so a deferred reconcile is + // retried. + let _stable = chain.stable.lock(); let mut attempts = 0; let reconcile_result = loop { let before = chain.tip_generation(); let observation = RawChainView(chain); + let aborted = || !tip_unchanged(&before, &chain.tip_generation()); let result: Result<(), pool_ledger::LedgerError> = (|| { - self.ledger.reconcile_rounds(&observation)?; - self.ledger.reconcile_payouts(&observation)?; + self.ledger + .reconcile_rounds_checked(&observation, &aborted)?; + self.ledger + .reconcile_payouts_checked(&observation, &aborted)?; Ok(()) })(); let after = chain.tip_generation(); From 1291b8547b89d0ffd227be4cdfe45cf60039830d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:43:11 +0900 Subject: [PATCH 43/61] fix(pool-ledger): derive share count from sequence metadata Replace the full table scan with next_share_seq minus pruned_below_seq and drop the O(n) Table::len helper. Add regression test. --- crates/mining/src/pool/ledger/kv.rs | 10 ---------- crates/mining/src/pool/ledger/store.rs | 15 +++++++++++++-- .../mining/tests/support/pool_ledger_cases.rs | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/mining/src/pool/ledger/kv.rs b/crates/mining/src/pool/ledger/kv.rs index e884c5bd27..b375fd967b 100644 --- a/crates/mining/src/pool/ledger/kv.rs +++ b/crates/mining/src/pool/ledger/kv.rs @@ -464,14 +464,4 @@ impl Table<'_, K, V> { }) .transpose() } - pub(crate) fn len(&self) -> Result { - let mut count = 0u64; - for row in self.rows_iter()? { - row?; - count = count - .checked_add(1) - .ok_or(LedgerError::Overflow("table length"))?; - } - Ok(count) - } } diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index c2f0976e04..81ef023801 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -586,11 +586,22 @@ impl Ledger { /// Number of shares currently stored (pruned shares are not counted). /// + /// Derived in O(1) from the share sequence and pruned-prefix metadata + /// (`next_share_seq - pruned_below_seq`): share seqs are contiguous from + /// zero with only a pruned prefix removed, so no table scan is needed. + /// Snapshot isolation: both counters come from one read snapshot. + /// /// # Errors - /// Returns a storage error. + /// Returns a storage error or [`LedgerError::Invariant`] when the pruned + /// prefix runs past the allocated sequence. pub fn share_count(&self) -> Result { let read = self.db.begin_read()?; - read.open_table(SHARES)?.len() + let meta = read.open_table(META_U64)?; + let next = meta_u64(&meta, M_NEXT_SHARE)?; + let pruned = meta_u64(&meta, M_PRUNED_BELOW)?; + next.checked_sub(pruned).ok_or_else(|| { + LedgerError::Invariant("pruned share prefix runs past the sequence".into()) + }) } // ----------------------------------------------------------------------------------------- diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index b5ae12da78..9da9a5c3fc 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -1163,6 +1163,23 @@ fn pruning_streams_bounded_batches() { assert_eq!(ledger.prune_shares(100).fixture("nothing left"), 0); } +#[test] +fn share_count_comes_from_sequence_metadata_not_a_table_scan() { + let (_dir, ledger) = plain_ledger(5); + assert_eq!(ledger.share_count().fixture("empty"), 0); + for nonce in 0..10u32 { + ledger.record_share(&share(1, 1, nonce, 1)).fixture("share"); + } + assert_eq!(ledger.share_count().fixture("ten"), 10); + // Retiring the job bounds the secondary index but keeps the shares. + assert_eq!(ledger.retire_job(1).fixture("retire"), 10); + assert_eq!(ledger.share_count().fixture("retire keeps shares"), 10); + // Pruning advances the floor; the count is the live suffix. + assert_eq!(ledger.prune_shares(5).fixture("prune"), 5); + assert_eq!(ledger.share_count().fixture("five left"), 5); + ledger.check_invariants().fixture("invariants"); +} + #[test] fn short_history_uses_all_work_and_pruning_protects_old_windows() { let (_dir, ledger) = plain_ledger(100); From f8a6e5832cea2d95a042272c792c60ce7e126894 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:27:10 +0900 Subject: [PATCH 44/61] fix(pool-ledger): bound reconcile units without holding chain fence Reconcile processes at most RECONCILE_*_BATCH_LIMIT items per commit via RawChainView without holding StableRead, retaining the generation check inside each write transaction. Paging uses offsets until no more remains so the tail converges. Adds a regression test proving bounded work and generation abort. --- crates/mining/src/pool/ledger/store.rs | 195 ++++++++++++++++++ .../mining/tests/support/pool_ledger_cases.rs | 76 +++++++ crates/node/src/pool/ledger.rs | 70 ++++--- 3 files changed, 316 insertions(+), 25 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index 81ef023801..c1104067c3 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -31,6 +31,14 @@ use super::{ /// Most signed transactions (RBF replacements) recorded for one batch. pub const MAX_SIGNED_TXS_PER_BATCH: usize = 32; +/// Maximum rounds observed per bounded reconcile commit. Each round can fetch +/// a block body and decode it, so an unbounded scan would stall chain +/// transitions that need the same stores. +pub const RECONCILE_ROUND_BATCH_LIMIT: usize = 32; +/// Maximum payout batches observed per bounded reconcile commit. Each batch +/// scans signed transactions and script indexes, so the work per commit stays +/// bounded while the generation check keeps every financial commit coherent. +pub const RECONCILE_PAYOUT_BATCH_LIMIT: usize = 16; const MAX_INPUTS_PER_TX: usize = 100_000; const SCHEMA_VERSION: u64 = 1; const AUDIT_DOMAIN: &[u8] = b"btcrs.pool.audit.v1"; @@ -820,6 +828,96 @@ impl Ledger { Ok(report) } + /// Bounded variant of [`Self::reconcile_rounds_checked`]: observes at most + /// `limit` open rounds per commit, returning whether more remain. Each + /// commit still evaluates `abort` inside its write transaction, so a + /// generation change aborts the financial commit instead of applying a + /// torn view. + pub fn reconcile_rounds_checked_limit( + &self, + chain: &dyn ChainView, + abort: &dyn Fn() -> bool, + limit: usize, + offset: usize, + ) -> Result<(RoundReconcileReport, bool), LedgerError> { + let (open, more) = { + let read = self.db.begin_read()?; + let open_tbl = read.open_table(OPEN_ROUNDS)?; + let rounds_tbl = read.open_table(ROUNDS)?; + let mut list = Vec::new(); + let mut more = false; + let mut skipped = 0usize; + for item in open_tbl.rows_iter()? { + if skipped < offset { + skipped += 1; + continue; + } + if list.len() >= limit { + more = true; + break; + } + let (key, _) = item?; + if let Some(round) = load_round(&rounds_tbl, key.value())? { + list.push(round); + } + } + (list, more) + }; + if open.is_empty() { + return Ok((RoundReconcileReport::default(), more)); + } + let mut report = RoundReconcileReport::default(); + let tip = chain.tip_height()?; + let mut observations = Vec::with_capacity(open.len()); + for round in open { + let canon = match chain.block_location(&round.block_hash, round.height)? { + super::BlockLocation::Canonical { height } if height == round.height => { + Canon::Canonical + } + super::BlockLocation::Canonical { .. } | super::BlockLocation::Side => { + Canon::Displaced + } + super::BlockLocation::Unknown => Canon::Unknown, + }; + let coinbase = if canon == Canon::Canonical && !round.reward_fixed { + chain.coinbase_of(&round.block_hash)? + } else { + None + }; + observations.push(RoundObservation { + id: round.id, + block_hash: round.block_hash, + canon, + coinbase, + }); + } + let txn = self.db.begin_write()?; + if abort() { + return Ok((RoundReconcileReport::default(), more)); + } + let mut dirty = false; + { + let tables = Tables::open(&txn)?; + for observation in &observations { + let outcome = apply_round_observation(&tables, &self.config, observation, tip)?; + dirty |= outcome.dirty; + if let Some(transition) = outcome.transition { + report.transitions.push(transition); + } + if let Some(reason) = outcome.anomaly { + report.anomalies.push(RoundAnomaly { + round: observation.id, + reason, + }); + } + } + } + if dirty { + self.commit(txn, OP_RECONCILE_ROUNDS)?; + } + Ok((report, more)) + } + fn open_round_list(&self) -> Result, LedgerError> { let read = self.db.begin_read()?; let open = read.open_table(OPEN_ROUNDS)?; @@ -1425,6 +1523,103 @@ impl Ledger { Ok(transitions) } + /// Bounded variant of [`Self::reconcile_payouts_checked`]: observes at most + /// `limit` observable batches per commit, returning whether more remain. + /// Each commit still evaluates `abort` inside its write transaction, so a + /// generation change aborts the financial commit instead of failing a + /// payout on a torn view. + pub fn reconcile_payouts_checked_limit( + &self, + chain: &dyn ChainView, + abort: &dyn Fn() -> bool, + limit: usize, + offset: usize, + ) -> Result<(Vec, bool), LedgerError> { + let (observable, more) = { + let read = self.db.begin_read()?; + let active_tbl = read.open_table(ACTIVE_BATCHES)?; + let batches_tbl = read.open_table(BATCHES)?; + let mut list = Vec::new(); + let mut more = false; + let mut skipped = 0usize; + for item in active_tbl.rows_iter()? { + if skipped < offset { + skipped += 1; + continue; + } + if list.len() >= limit { + more = true; + break; + } + let (_, value) = item?; + let id = hash_from(value.value())?; + let Some(batch) = load_batch(&batches_tbl, &id)? else { + continue; + }; + if !batch.state_is_observable() { + continue; + } + list.push(batch); + } + (list, more) + }; + if observable.is_empty() { + return Ok((Vec::new(), more)); + } + let mut observations = Vec::with_capacity(observable.len()); + let mut tip: Option = None; + for batch in &observable { + let tip_height = if let Some(height) = tip { + height + } else { + let height = chain.tip_height()?; + tip = Some(height); + height + }; + observations.push(observe_batch(chain, &self.config, batch, tip_height)?); + } + let mut transitions = Vec::new(); + if observations.is_empty() { + return Ok((transitions, more)); + } + let tip = tip.unwrap_or(0); + let txn = self.db.begin_write()?; + if abort() { + return Ok((Vec::new(), more)); + } + let mut dirty = false; + { + let tables = Tables::open(&txn)?; + for observation in &observations { + let Some(mut batch) = load_batch(&tables.batches, &observation.id)? else { + continue; + }; + if batch.rev != observation.rev { + continue; + } + dirty |= apply_batch_decision( + &tables, + &mut batch, + observation, + &self.config, + &mut transitions, + )?; + if batch.state == BatchState::Confirmed + && let Some(confirmation) = batch.confirmation + && u64::from(tip) + >= u64::from(confirmation.height) + u64::from(self.config.reorg_watch_depth) + { + tables.active_batches.remove(batch.seq)?; + dirty = true; + } + } + } + if dirty { + self.commit(txn, OP_RECONCILE_PAYOUTS)?; + } + Ok((transitions, more)) + } + // ----------------------------------------------------------------------------------------- // Audit and integrity // ----------------------------------------------------------------------------------------- diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 9da9a5c3fc..96025e5516 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -3074,6 +3074,82 @@ fn the_audit_chain_is_deterministic_and_detects_tampering() { drop(retained_dir); } +#[test] +fn bounded_reconcile_units_are_bounded_and_generation_guarded() { + // Rounds: one open round fits in limit 1 but not limit 0, proving the work + // is bounded per commit. + let fund = staged(0, 1_000_001); + let second = found(share(1, 4, 1, 20), 101, 1_000_001); + fund.ledger.record_found_block(&second).fixture("second block"); + fund.chain.set_block(101, second.share.header_hash); + fund.chain + .set_coinbase(second.share.header_hash, coinbase_for(&second, 1_000_001)); + let (empty, more_empty) = fund + .ledger + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 0, 0) + .fixture("rounds limit 0"); + assert!(empty.transitions.is_empty() && empty.anomalies.is_empty()); + assert!(more_empty, "limit 0 must report more open rounds"); + let (_, more_first) = fund + .ledger + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, 0) + .fixture("rounds first page"); + assert!(more_first, "two open rounds need a second page"); + let (_, more_second) = fund + .ledger + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, 1) + .fixture("rounds second page"); + assert!(!more_second, "offset reaches the tail"); + + // Rounds: a generation change aborts instead of applying a torn view. + fund.chain.set_tip(100); + let (aborted, _) = fund + .ledger + .reconcile_rounds_checked_limit(&fund.chain, &|| true, 10, 0) + .fixture("rounds aborted"); + assert!(aborted.transitions.is_empty() && aborted.anomalies.is_empty()); + let pending = fund + .ledger + .round_for_block(&fund.block.share.header_hash) + .fixture("round") + .fixture("exists"); + assert_eq!(pending.state, RoundState::Pending); + + // Payouts: one observable batch fits in limit 1 but not limit 0. + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], Some(50_000)); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + let (none, more_none) = fund + .ledger + .reconcile_payouts_checked_limit(&fund.chain, &|| false, 0, 0) + .fixture("payouts limit 0"); + assert!(none.is_empty()); + assert!(more_none, "limit 0 must report more observable batches"); + let (_, more_one) = fund + .ledger + .reconcile_payouts_checked_limit(&fund.chain, &|| false, 1, 0) + .fixture("payouts limit 1"); + assert!(!more_one, "single observable batch fits in limit 1"); + + // Payouts: a generation change aborts a confirming commit. + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("broadcast"); + confirm_tx(&fund, tx.txid, 0xB1, 102, 102); + fund.chain.set_tip(103); + let (held, _) = fund + .ledger + .reconcile_payouts_checked_limit(&fund.chain, &|| true, 10, 0) + .fixture("payouts aborted"); + assert!(held.is_empty()); + let still = fund.ledger.batch(&batch.id).fixture("batch").fixture("exists"); + assert_eq!(still.state, BatchState::Broadcast); +} + #[test] fn payout_lifecycle_events_are_audited_in_order() { let fund = funded(0, 1_000_001); diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index 1c99935d99..f410294691 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -546,45 +546,65 @@ impl LedgerHost { Err(error) => break Some(error), } }; - // One stable snapshot across observe and commit: holding `StableRead` - // excludes authoritative transitions while `RawChainView` (lock-free, - // never re-locks `StableRead`) observes and while the ledger write - // transactions commit, so a reorg cannot slip between validation and - // commit to permanently fail a payout. The checked reconciles still - // validate the generation inside their write transactions as - // defense-in-depth for callers without the guard. Snapshot isolation: - // a commit only applies observations taken under one generation. - // Linearizability of the ledger itself comes from its write - // serialization; callers stay idempotent, so a deferred reconcile is - // retried. - let _stable = chain.stable.lock(); + // Bounded reconciliation units without a chain-transition fence: each + // financial commit observes at most `RECONCILE_*_BATCH_LIMIT` items + // lock-free via `RawChainView` (never re-locks `StableRead`) and + // validates the generation inside its write transaction, so a reorg + // cannot slip between validation and commit to permanently fail a + // payout. No `StableRead` is held across observations, signed-transaction + // scans, body decodes, or durable writes, so a growing payout or round + // history cannot stall block application. Batches loop with an offset + // until no more remains, so the tail converges instead of starving + // behind the head; a moving tip retries once and otherwise converges + // on the next tick. Snapshot + // isolation holds per commit: a commit only applies observations taken + // under one generation. Callers stay idempotent, so a deferred + // reconcile is retried. + let mut round_offset = 0usize; + let mut payout_offset = 0usize; let mut attempts = 0; let reconcile_result = loop { let before = chain.tip_generation(); let observation = RawChainView(chain); let aborted = || !tip_unchanged(&before, &chain.tip_generation()); - let result: Result<(), pool_ledger::LedgerError> = (|| { - self.ledger - .reconcile_rounds_checked(&observation, &aborted)?; - self.ledger - .reconcile_payouts_checked(&observation, &aborted)?; - Ok(()) + let result: Result<(bool, bool), pool_ledger::LedgerError> = (|| { + let (_, round_more) = + self.ledger.reconcile_rounds_checked_limit( + &observation, + &aborted, + pool_ledger::RECONCILE_ROUND_BATCH_LIMIT, + round_offset, + )?; + let (_, payout_more) = + self.ledger.reconcile_payouts_checked_limit( + &observation, + &aborted, + pool_ledger::RECONCILE_PAYOUT_BATCH_LIMIT, + payout_offset, + )?; + Ok((round_more, payout_more)) })(); let after = chain.tip_generation(); match result { - Ok(()) if tip_unchanged(&before, &after) => break Ok(()), - Ok(()) => { + Ok((false, false)) if tip_unchanged(&before, &after) => break Ok(()), + Ok((round_more, payout_more)) if tip_unchanged(&before, &after) => { + if round_more { + round_offset += pool_ledger::RECONCILE_ROUND_BATCH_LIMIT; + } + if payout_more { + payout_offset += pool_ledger::RECONCILE_PAYOUT_BATCH_LIMIT; + } + if !round_more && !payout_more { + break Ok(()); + } + } + Ok(_) => { attempts += 1; if attempts >= 2 { - // A continuously moving tip still made progress: the - // last pass committed idempotent transitions and the - // next scheduled reconcile converges. break Ok(()); } } Err(error) => { - // A chain-observation error may be a torn view from a - // concurrent transition; retry once against the new tip. attempts += 1; if attempts >= 2 || tip_unchanged(&before, &after) { break Err(error); From a4f6da005135bc3b7673cc26870f944748fe4304 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:27:48 +0900 Subject: [PATCH 45/61] fix(pool): carry rejected outcome through completion to deferred reply A deferred share the ledger permanently rejects was completed as if credited, so SV1 reported result:true and ShareAccepted delivered:true. Keep dropping the refused head so the drain never wedges, but mark the completion rejected and report failure with the duplicate rejection. Extends permanently_refused_buffer_never_wedges_the_drain to assert the response result. --- crates/mining/src/pool/core.rs | 19 ++++++- crates/mining/src/pool/stratum/sv1/session.rs | 55 ++++++++++++++----- crates/mining/tests/pool_sv1.rs | 8 +++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/crates/mining/src/pool/core.rs b/crates/mining/src/pool/core.rs index a5eb8884dc..872812005f 100644 --- a/crates/mining/src/pool/core.rs +++ b/crates/mining/src/pool/core.rs @@ -130,6 +130,7 @@ enum Pending { pub(super) struct Completion { remaining: AtomicUsize, + rejected: AtomicBool, } /// Debug shows only the outstanding count; a completion carries no secrets. @@ -138,6 +139,7 @@ impl fmt::Debug for Completion { formatter .debug_struct("Completion") .field("remaining", &self.remaining.load(Ordering::Acquire)) + .field("rejected", &self.rejected.load(Ordering::Acquire)) .finish() } } @@ -146,6 +148,7 @@ impl Completion { fn new(items: usize) -> Self { Self { remaining: AtomicUsize::new(items), + rejected: AtomicBool::new(false), } } @@ -157,9 +160,18 @@ impl Completion { }); } + fn record_rejected(&self) { + self.rejected.store(true, Ordering::Release); + self.record(); + } + pub(super) fn is_complete(&self) -> bool { self.remaining.load(Ordering::Acquire) == 0 } + + pub(super) fn is_rejected(&self) -> bool { + self.rejected.load(Ordering::Acquire) + } } impl Pending { @@ -595,11 +607,16 @@ impl PoolCore { let mut succeeded = false; for attempt in 0..self.ledger_attempts { match self.attempt_item(&item) { - Ok(_) => { + Ok(RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded) => { item.completion().record(); succeeded = true; break; } + Ok(RecordOutcome::Rejected) => { + item.completion().record_rejected(); + succeeded = true; + break; + } Err(LedgerUnavailable) => { if attempt + 1 < self.ledger_attempts { self.retry_pause(attempt, item.hash()); diff --git a/crates/mining/src/pool/stratum/sv1/session.rs b/crates/mining/src/pool/stratum/sv1/session.rs index 8c0c548ec7..25753be2df 100644 --- a/crates/mining/src/pool/stratum/sv1/session.rs +++ b/crates/mining/src/pool/stratum/sv1/session.rs @@ -1009,6 +1009,46 @@ impl SessionState { } } + /// Reports a deferred share the ledger permanently refused. The drain + /// dropped the poisoned head to keep later shares flowing, but the waiter + /// must report failure with the same duplicate rejection as an inline + /// refusal, emitting no `ShareAccepted` instead of `result:true`. + fn emit_deferred_rejected(&self, response: &DeferredResponse, out: &mut Vec) { + if response.has_id { + emit_line( + out, + &encode_reject(&response.id, &errors::DUPLICATE_SHARE_ERROR), + ); + } + } + + /// Settles one deferred waiter. Returns the waiter when its completion is + /// still outstanding; otherwise emits its verdict and returns `None`. + fn settle_deferred( + &self, + response: DeferredResponse, + session: Option, + out: &mut Vec, + ) -> Option { + if !response.completion.is_complete() { + return Some(response); + } + if response.completion.is_rejected() { + self.emit_deferred_rejected(&response, out); + return None; + } + let session = session?; + let credited = Credited { + hash: response.hash, + class: response.class, + completion: response.completion, + block: response.block, + }; + let reply = response.has_id.then_some(&response.id); + self.emit_credited(reply, session, &credited, out); + None + } + /// Sends pending difficulty and the newest job this session has not seen. fn poll(&mut self, out: &mut Vec) -> Flow { self.poll_hint = false; @@ -1017,19 +1057,8 @@ impl SessionState { let session = self.lease.as_ref().map(|lease| lease.session.id()); let mut pending = Vec::with_capacity(self.deferred.len()); for response in std::mem::take(&mut self.deferred) { - if response.completion.is_complete() { - if let Some(session) = session { - let credited = Credited { - hash: response.hash, - class: response.class, - completion: response.completion, - block: response.block, - }; - let reply = response.has_id.then_some(&response.id); - self.emit_credited(reply, session, &credited, out); - } - } else { - pending.push(response); + if let Some(waiter) = self.settle_deferred(response, session, out) { + pending.push(waiter); } } self.deferred = pending; diff --git a/crates/mining/tests/pool_sv1.rs b/crates/mining/tests/pool_sv1.rs index d32dd92d3c..6ae4675e0e 100644 --- a/crates/mining/tests/pool_sv1.rs +++ b/crates/mining/tests/pool_sv1.rs @@ -1599,6 +1599,14 @@ fn permanently_refused_buffer_never_wedges_the_drain() { drained.contains("\"id\":101"), "the dropped head still answers its waiter: {drained:?}" ); + assert!( + drained.contains("\"error\":[22,\"Duplicate share\",null]"), + "the refused head reports failure, not success: {drained:?}" + ); + assert!( + !drained.contains("\"error\":null"), + "a permanently refused share must not report success: {drained:?}" + ); miner.ledger.admit_again(); let response = miner.submit(102, &job, second); From 88abef5671d47884601bddbb8b90f07e6dac85a8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:27:58 +0900 Subject: [PATCH 46/61] fix(pool): retain bounded submission recovery for Inconclusive An Inconclusive validation result exited without retrying, then the share was acknowledged while journal backpressure could discard a valid block. Retain bounded recovery work in PoolCore and retry against authoritative node state, and retain Undetermined work in the native SV2 path with bounded re-submission instead of immediate success. Adds regression tests for both paths. --- crates/mining/src/pool/core.rs | 17 ++- crates/mining/tests/pool_sv1.rs | 39 ++++++ crates/node/src/pool/sv2.rs | 209 ++++++++++++++++++++++++++++---- 3 files changed, 243 insertions(+), 22 deletions(-) diff --git a/crates/mining/src/pool/core.rs b/crates/mining/src/pool/core.rs index 872812005f..e496635ddb 100644 --- a/crates/mining/src/pool/core.rs +++ b/crates/mining/src/pool/core.rs @@ -524,8 +524,23 @@ impl PoolCore { Ok(receipt) => { if receipt.status() == BlockStatus::Applied { self.invalidate_active(); + return Ok(receipt); } - return Ok(receipt); + if receipt.status() != BlockStatus::Undetermined { + return Ok(receipt); + } + // Inconclusive (journal backpressure or a header-only + // duplicate) leaves the chain outcome unknown: retain + // bounded recovery work and re-query authoritative node + // state instead of exiting. A retry that observes an + // applied duplicate converges to `Applied`; exhaustion + // returns the last `Undetermined` receipt for the ledger + // to reconcile from chain observation. + attempt += 1; + if attempt >= ATTEMPTS { + return Ok(receipt); + } + self.retry_pause(attempt - 1, found.share.hash); } Err( MiningControlError::Unavailable(message) | MiningControlError::Failed(message), diff --git a/crates/mining/tests/pool_sv1.rs b/crates/mining/tests/pool_sv1.rs index 6ae4675e0e..8aa95a7885 100644 --- a/crates/mining/tests/pool_sv1.rs +++ b/crates/mining/tests/pool_sv1.rs @@ -1282,6 +1282,45 @@ fn applied_block_retires_job_and_second_submit_is_stale() { miner.server.shutdown(); } +/// Journal backpressure (`Inconclusive`) retains bounded submission-recovery +/// work instead of dropping the block: the node is re-queried a bounded +/// number of times, the share stays acknowledged, and the block record stays +/// durable for chain reconciliation. +#[test] +fn inconclusive_block_submission_retries_with_bounded_recovery() { + let mut miner = MinerFixture::start(0x22); + let job = Arc::clone(&miner.job); + *miner.control.submit.lock() = BlockValidationResult::Inconclusive; + let winner = miner.winning_nonce(0); + let response = miner.submit(100, &job, winner); + assert!( + response.contains("\"error\":null"), + "inconclusive share stays acknowledged while recovery runs: {response}" + ); + assert_eq!( + miner.control.submit_calls.load(Ordering::SeqCst), + 5, + "inconclusive retries bounded recovery work instead of exiting once" + ); + { + let blocks = miner + .ledger + .blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert_eq!( + blocks.len(), + 1, + "inconclusive block is retained, not dropped" + ); + } + assert!( + miner.pool.current_installed().is_some(), + "no retire without an applied block" + ); + miner.server.shutdown(); +} + /// An already-validated share whose block the node rejects as a stale parent /// is a `STALE_SHARE` protocol error, not success: the ledger record stays /// durable, nothing else is submitted, and the connection remains usable. diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 78ba9dcc7b..56d31cf373 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -12,7 +12,7 @@ use bitcoin_rs_primitives::BlockHash; use bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter; use bitcoin_rs_mining::pool::stratum::sv2::{ AdmitError, AuthorityKeypair, BlockSubmission, Connection, ERROR_CODE_SHARE_NOT_RECORDED, - ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, ServerConfig, + ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, PendingBlock, ServerConfig, }; use bitcoin_rs_mining::pool::{BlockStatus, PoolCore, RecordOutcome}; use bitcoin_rs_mining::pool::ledger::MinerId; @@ -32,6 +32,73 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); /// work is not closed while templates keep flowing. const IDLE_TIMEOUT: Duration = Duration::from_secs(600); +/// A block whose `SubmitShares.Success` is withheld until both its ledger +/// round is durable and authoritative node state has been re-queried. +/// `pending` retains the candidate for bounded re-submission when the first +/// verdict was `Undetermined` (journal backpressure); `attempts` bounds the +/// recovery work so one slow dependency cannot stall the connection. +struct DeferredBlock { + hash: BlockHash, + pending: PendingBlock, + submission: BlockSubmission, + attempts: u32, +} + +/// Bounded re-submissions retained per deferred block before the ledger +/// convergence path takes over. +const DEFERRED_RESUBMIT_ATTEMPTS: u32 = 5; + +/// Block-record reservation and deferred success queue, which travel together +/// through event handling. +struct BlockPipeline<'a> { + permit: &'a mut Option, + deferred: &'a mut Vec, +} + +/// Settles one deferred block. Returns the waiter when it must stay pending; +/// otherwise emits its verdict and returns `None`. +fn settle_one_deferred( + mut entry: DeferredBlock, + ledger: &LedgerHost, + control: &dyn MiningControl, + connection: &mut Connection, + core: &PoolCore, +) -> Option { + if !matches!(ledger.found_block_recorded(entry.hash), Ok(true)) { + return Some(entry); + } + if entry.submission.receipt().status() == BlockStatus::Undetermined + && entry.attempts < DEFERRED_RESUBMIT_ATTEMPTS + { + entry.attempts = entry.attempts.saturating_add(1); + match entry.pending.submit(control) { + Ok(fresh) if fresh.receipt().status() == BlockStatus::Applied => { + core.invalidate_active(); + entry.submission = fresh; + } + Ok(fresh) if fresh.receipt().status() == BlockStatus::Rejected => { + if fresh.receipt().is_stale_parent() { + let _ = connection + .reject_block(&fresh, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE); + } + return None; + } + Ok(fresh) => { + entry.submission = fresh; + return Some(entry); + } + Err(_) => return Some(entry), + } + } + if entry.submission.receipt().status() == BlockStatus::Applied { + if connection.confirm_block(&entry.submission).is_err() { + return Some(entry); + } + return None; + } + Some(entry) +} + /// Owned SV2 listener and connection threads. pub(crate) struct Sv2Service { cancel: Arc, @@ -227,11 +294,12 @@ fn serve_connection( let mut accounts = HashMap::new(); let mut input = vec![0; MAX_FEED_LEN]; let mut buffered_input = None; - // Success acknowledgements held until a retained block record is durable. + // Success acknowledgements held until a retained block record is durable + // and an `Undetermined` node verdict has had bounded recovery work. // Each entry holds one consumed permit's queue slot, so the // `MAX_PENDING_BLOCK_RECORDS` bound caps this queue; the reconcile worker // normally drains entries within one 250ms pass. - let mut deferred: Vec<(BlockHash, BlockSubmission)> = Vec::new(); + let mut deferred: Vec = Vec::new(); let mut transmit = PendingTransmit::default(); let start = Instant::now(); let mut last_progress = start; @@ -248,14 +316,21 @@ fn serve_connection( } // Confirm blocks whose retained records became durable even when // the miner sends no more bytes; a silent socket must not delay - // `SubmitShares.Success` until the next frame. + // `SubmitShares.Success` until the next frame. An `Undetermined` + // verdict (journal backpressure) is never confirmed on ledger + // durability alone: it stays pending for bounded authoritative + // recovery (one re-submission per poll) instead of producing a + // false success while the block is still unapplied. let deferred_before = deferred.len(); - deferred.retain(|(hash, submission)| { - if !matches!(ledger.found_block_recorded(*hash), Ok(true)) { - return true; + let mut still_deferred = Vec::with_capacity(deferred.len()); + for entry in std::mem::take(&mut deferred) { + if let Some(waiter) = + settle_one_deferred(entry, ledger, control, &mut connection, core) + { + still_deferred.push(waiter); } - connection.confirm_block(submission).is_err() - }); + } + deferred = still_deferred; if deferred.len() != deferred_before { last_progress = Instant::now(); } @@ -310,8 +385,10 @@ fn serve_connection( ledger, control, &mut accounts, - &mut block_permit, - &mut deferred, + BlockPipeline { + permit: &mut block_permit, + deferred: &mut deferred, + }, )?; // Shed tickets the host never acknowledged: a stuck ledger or a // silent miner must not pin the bounded pending-ack budget and @@ -367,9 +444,12 @@ fn handle_events( ledger: &LedgerHost, control: &dyn MiningControl, accounts: &mut HashMap, - block_permit: &mut Option, - deferred: &mut Vec<(BlockHash, BlockSubmission)>, + pipeline: BlockPipeline<'_>, ) -> Result<()> { + let BlockPipeline { + permit: block_permit, + deferred, + } = pipeline; while let Some(event) = connection.poll_event() { match event { Event::ChannelOpened { @@ -438,10 +518,33 @@ fn handle_events( // `SubmitShares.Success` until the round is durable — the // session contract and SV1 both withhold success until then — // but keep the connection open while the ledger catches up - // instead of closing it under load. Only success is deferred; - // node rejections answer immediately below. + // instead of closing it under load. An `Undetermined` + // verdict is always deferred, even when the record is already + // durable: journal backpressure leaves the block unapplied, + // so immediate success would be false. The deferred waiter + // retains the candidate for bounded re-submission instead of + // dropping it. Only success is deferred; node rejections + // answer immediately below. match permit.record_or_enqueue(account, found, &receipt) { - BlockRecordOutcome::Durable => {} + BlockRecordOutcome::Durable => { + if matches!( + &submission, + Ok(submitted) + if submitted.receipt().status() == BlockStatus::Undetermined + ) { + let Ok(submitted) = submission else { + return Err(anyhow!("SV2 block lost its submission")); + }; + let hash = found.share.hash; + deferred.push(DeferredBlock { + hash, + pending, + submission: submitted, + attempts: 0, + }); + continue; + } + } BlockRecordOutcome::Queued => { if matches!( &submission, @@ -456,7 +559,13 @@ fn handle_events( let Ok(submitted) = submission else { return Err(anyhow!("SV2 block lost its submission")); }; - deferred.push((found.share.hash, submitted)); + let hash = found.share.hash; + deferred.push(DeferredBlock { + hash, + pending, + submission: submitted, + attempts: 0, + }); continue; } } @@ -468,9 +577,22 @@ fn handle_events( } let submission = submission.context("SV2 block submission failed")?; match submission.receipt().status() { - BlockStatus::Applied | BlockStatus::Undetermined => { + BlockStatus::Applied => { connection.confirm_block(&submission)?; } + BlockStatus::Undetermined => { + // Fallback retention: every `Undetermined` should already + // be deferred above, but never confirm an unapplied + // block. Retain for bounded recovery instead. + let hash = found.share.hash; + deferred.push(DeferredBlock { + hash, + pending, + submission, + attempts: 0, + }); + continue; + } BlockStatus::Rejected if submission.receipt().is_stale_parent() => { connection .reject_block(&submission, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE)?; @@ -648,7 +770,7 @@ mod tests { use bitcoin_rs_mining::pool::ledger::{Ledger, MinerId, PayoutPolicy, PoolConfig}; use zeroize::Zeroizing; - use super::{LedgerHost, announce_current_job, handle_events}; + use super::{BlockPipeline, DeferredBlock, LedgerHost, announce_current_job, handle_events}; type TestResult = Result<(), Box>; @@ -1021,6 +1143,7 @@ mod tests { accounts: HashMap, miner: Miner, job: Arc, + deferred: Vec, _directory: tempfile::TempDir, } @@ -1045,6 +1168,7 @@ mod tests { accounts: HashMap::new(), miner, job, + deferred: Vec::new(), _directory: directory, }) } @@ -1066,8 +1190,10 @@ mod tests { &self.ledger, self.control.as_ref(), &mut self.accounts, - &mut permit, - &mut Vec::new(), + BlockPipeline { + permit: &mut permit, + deferred: &mut self.deferred, + }, )?; let sent = self.connection.take_transmit(); Ok(self.miner.open(&sent)?) @@ -1165,4 +1291,45 @@ mod tests { new_parent_work_is_accepted(&mut host, &channel) } + + #[test] + fn undetermined_sv2_block_withholds_success_and_retries_authoritative_state() -> TestResult { + let mut host = Host::new()?; + let channel = host.open_channel()?; + let job = Arc::clone(&host.job); + let winner = block_nonces(&job, &channel.prefix, 1)? + .pop() + .ok_or("a winning nonce exists")?; + + // Journal backpressure leaves the chain outcome unknown: the host must + // retain the submission instead of confirming an unapplied block. + *host.control.submit.lock() = + bitcoin_rs_mining::BlockValidationResult::Inconclusive; + let pending = host.submit(&channel, 1, &job, winner)?; + assert!( + !has(&pending, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS), + "Undetermined success must be withheld, not confirmed" + ); + let hash = header_hash(&job, &channel.prefix, winner)?; + assert!(host.ledger.found_block_recorded(hash)?); + assert_eq!(host.deferred.len(), 1, "Undetermined work is retained"); + assert_eq!(host.node_submissions(), 1); + + // Authoritative recovery applies the block: the retained waiter retries + // once and then confirms instead of producing a false success. + *host.control.submit.lock() = bitcoin_rs_mining::BlockValidationResult::Accepted; + let entry = host.deferred.pop().ok_or("retained waiter missing")?; + let waiter = super::settle_one_deferred( + entry, + &host.ledger, + host.control.as_ref(), + &mut host.connection, + &host.core, + ); + assert!(waiter.is_none(), "Applied retry confirms and drops the waiter"); + assert_eq!(host.node_submissions(), 2, "deferred retry re-queries node state"); + let confirmed = host.miner.open(&host.connection.take_transmit())?; + assert!(has(&confirmed, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS)); + Ok(()) + } } From 437b346d7537ea9eae868905469363e4aa18eef3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:05:40 +0900 Subject: [PATCH 47/61] fix(pool-ledger): bound prune deletes with sequence lookups Table::range materialized all doomed rows before take(1024). Walk [pruned_below, boundary) with at most 1024 point gets per commit so the batch limit applies before materialization. Add pruning_many_batches regression covering 4 bounded batches. --- crates/mining/src/pool/ledger/store.rs | 80 +++++++++++-------- .../mining/tests/support/pool_ledger_cases.rs | 33 ++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index c1104067c3..40cfb21686 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -509,44 +509,60 @@ impl Ledger { let txn = self.db.begin_write()?; let removed = { let tables = Tables::open(&txn)?; - let doomed: Vec = tables - .shares - .range(..boundary)? - .take(Self::PRUNE_BATCH_SIZE) - .map(|item| item.map(|(key, _)| key.value())) - .collect::, LedgerError>>()?; - if doomed.is_empty() { + let start = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; + if start >= boundary { break; } - for seq in &doomed { - tables.shares.remove(*seq)?; - } - let batch = u64::try_from(doomed.len()) + let batch_cap = u64::try_from(Self::PRUNE_BATCH_SIZE) .map_err(|_| LedgerError::Overflow("count"))?; + let batch_end = start + .checked_add(batch_cap) + .ok_or(LedgerError::Overflow("count"))? + .min(boundary); + let mut doomed: Vec = Vec::new(); + doomed.reserve( + usize::try_from(batch_end - start).unwrap_or(Self::PRUNE_BATCH_SIZE), + ); + for seq in start..batch_end { + if tables.shares.get(seq)?.is_some() { + doomed.push(seq); + } + } let previous = meta_u64(&tables.meta_u64, M_PRUNED_BELOW)?; - // Advance past this batch; the final partial batch restores - // the exact floor. Batches ascend, so every remaining key - // exceeds the floor even before the last batch lands. - let floor = if doomed.len() < Self::PRUNE_BATCH_SIZE { - boundary + // `batch_end` is the floor: `doomed` holds every live key + // below it (seqs are dense), so every remaining key meets or + // exceeds it even before the last batch lands. The final + // slice restores the exact boundary. + let floor = batch_end; + if doomed.is_empty() { + // A fully gapped slice cannot happen for contiguous seqs; + // advancing the floor guarantees progress instead of + // stalling on it. No audit for an empty slice. + if floor > previous { + tables.meta_u64.insert(M_PRUNED_BELOW, floor)?; + 0 + } else { + break; + } } else { - doomed - .last() - .and_then(|last| last.checked_add(1)) - .ok_or(LedgerError::Overflow("count"))? - }; - if floor > previous { - tables.meta_u64.insert(M_PRUNED_BELOW, floor)?; + for seq in &doomed { + tables.shares.remove(*seq)?; + } + let batch = u64::try_from(doomed.len()) + .map_err(|_| LedgerError::Overflow("count"))?; + if floor > previous { + tables.meta_u64.insert(M_PRUNED_BELOW, floor)?; + } + append_audit( + &tables, + AuditKind::SharesPruned, + 0, + &ZERO_HASH, + &ZERO_HASH, + [boundary, batch, low64(cumulative)], + )?; + batch } - append_audit( - &tables, - AuditKind::SharesPruned, - 0, - &ZERO_HASH, - &ZERO_HASH, - [boundary, batch, low64(cumulative)], - )?; - batch }; self.commit(txn, OP_PRUNE_SHARES)?; removed_total = removed_total diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 96025e5516..453388cf0a 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -1163,6 +1163,39 @@ fn pruning_streams_bounded_batches() { assert_eq!(ledger.prune_shares(100).fixture("nothing left"), 0); } +#[test] +fn pruning_many_batches_applies_the_limit_before_materialization() { + let (_dir, ledger) = plain_ledger(100); + // 3500 unit-work shares: keeping the newest 100 dooms 3400, which needs + // three full 1024 batches plus a 328 partial. A range that materialized + // the whole doomed prefix before `take` would buffer all 3400 keys on the + // first commit; bounded sequence lookups commit at most 1024 per batch. + for nonce in 0..3500u32 { + ledger.record_share(&share(1, 1, nonce, 1)).fixture("share"); + } + assert_eq!(ledger.share_count().fixture("count"), 3500); + assert_eq!(ledger.prune_shares(100).fixture("prune"), 3400); + assert_eq!(ledger.share_count().fixture("count"), 100); + assert_eq!( + ledger.current_window().fixture("window"), + vec![(MinerId(1), 100)] + ); + let audits = ledger.audit_records(0, 10_000).fixture("audit"); + let batches: Vec = audits + .iter() + .filter(|record| record.kind == AuditKind::SharesPruned) + .map(|record| record.values[1]) + .collect(); + assert_eq!(batches.len(), 4, "3400 doomed in 1024-sized batches"); + assert!( + batches.iter().all(|batch| *batch <= 1024), + "every batch applies the 1024 limit: {batches:?}" + ); + assert_eq!(batches.iter().sum::(), 3400); + assert_eq!(ledger.prune_shares(100).fixture("nothing left"), 0); + ledger.check_invariants().fixture("invariants"); +} + #[test] fn share_count_comes_from_sequence_metadata_not_a_table_scan() { let (_dir, ledger) = plain_ledger(5); From 89fe602c367abd60e936b00f0ae65bdc6e78f83a Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:06:33 +0900 Subject: [PATCH 48/61] fix(pool-ledger): demote matured rounds on canonical depth loss A lower-height higher-work reorg can retain a rewarded block while reducing depth below coinbase_maturity. Recheck maturity for canonical matured rounds, reverse credits with deficit-preserving accounting, and return them to Immature. Add canonical_depth_loss regression. --- crates/mining/src/pool/ledger/store.rs | 23 +++++++- .../mining/tests/support/pool_ledger_cases.rs | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index 40cfb21686..950e6e4169 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -2133,7 +2133,28 @@ fn apply_round_observation( [reversed, deficit, 1], )?; } - (RoundState::Matured, _) | (_, Canon::Unknown) => {} + (RoundState::Matured, Canon::Canonical) => { + // A lower-height higher-work reorg can retain the rewarded block + // while reducing its depth below maturity. The round must not + // stay Matured and payable: recheck maturity, reverse credits + // with the deficit-preserving accounting, and return to Immature + // so a deeper tip can mature it again. + if depth < u64::from(config.coinbase_maturity) { + let (reversed, deficit) = reverse_round_credits(tables, round.id)?; + reversed_sat = reversed; + deficit_created_sat = deficit; + round.state = RoundState::Immature; + append_audit( + tables, + AuditKind::RoundOrphaned, + round.id, + &round.block_hash, + &ZERO_HASH, + [reversed, deficit, 1], + )?; + } + } + (_, Canon::Unknown) => {} (_, Canon::Displaced) => { if from != RoundState::Orphaned { round.state = RoundState::Orphaned; diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 453388cf0a..3869f6b54b 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -1595,6 +1595,60 @@ fn a_reorg_after_maturity_reverses_the_credits_and_re_credits_when_the_block_ret .fixture("invariants after re-credit"); } +#[test] +fn canonical_depth_loss_below_maturity_demotes_to_immature() { + let fund = funded(0, 1_000_001); + assert_eq!( + fund.ledger + .round_for_block(&fund.block.share.header_hash) + .fixture("round") + .fixture("exists") + .state, + RoundState::Matured + ); + // A lower-height higher-work reorg retains the rewarded block at height + // 100 but shortens the tip from 102 to 101, dropping depth from 3 to 2 + // below the configured maturity of 3. The round must not stay payable. + fund.chain.set_tip(101); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("shorter tip"); + assert_eq!(report.transitions.len(), 1); + let transition = report.transitions[0]; + assert_eq!( + (transition.from, transition.to), + (RoundState::Matured, RoundState::Immature) + ); + assert_eq!( + (transition.reversed_sat, transition.deficit_created_sat), + (1_000_001, 0) + ); + for account in 1..=3 { + assert_eq!( + fund.ledger.balance(MinerId(account)).fixture("balance"), + Balance::default(), + "demoted credits leave nothing payable" + ); + } + fund.ledger + .check_invariants() + .fixture("invariants after demotion"); + + // A deeper tip matures the same round again with the same allocations. + fund.chain.set_tip(102); + let report = fund.ledger.reconcile_rounds(&fund.chain).fixture("deeper tip"); + assert_eq!(report.transitions.len(), 1); + assert_eq!(report.transitions[0].from, RoundState::Immature); + assert_eq!(report.transitions[0].to, RoundState::Matured); + assert_eq!(report.transitions[0].credited_sat, 1_000_001); + assert_eq!( + fund.ledger + .balance(MinerId(1)) + .fixture("balance") + .available_sat, + 500_001 + ); + fund.ledger.check_invariants().fixture("invariants after re-mature"); +} + #[test] fn reversing_credits_that_were_already_reserved_creates_an_explicit_deficit() { let fund = funded(0, 1_000_001); From b7501bf388bbc2601f196a5935046ad8dca389d0 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:11:46 +0900 Subject: [PATCH 49/61] fix(pool-sv2): retain reaped worker panic through stop_join reap_finished discarded panicked connection workers, letting shutdown succeed after a panic. Retain a service-level panic flag, set it on any reaped or joined panic, join remaining workers, and return the panic through stop_join, matching SV1 sessions_panicked and POOL-09. Add reaped-panic and join-remaining regressions. --- crates/node/src/pool/sv2.rs | 120 ++++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 5 deletions(-) diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 56d31cf373..17d158f13d 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -104,6 +104,7 @@ pub(crate) struct Sv2Service { cancel: Arc, listener: Option>, connections: Arc>>>, + worker_panicked: Arc, } impl Sv2Service { @@ -139,15 +140,21 @@ impl Sv2Service { ACCEPT_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), ); } + let mut panicked = self.worker_panicked.load(Ordering::Acquire); for connection in self .connections .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .drain(..) { - connection - .join() - .map_err(|_| anyhow!("SV2 connection thread panicked"))?; + if connection.join().is_err() { + panicked = true; + self.worker_panicked.store(true, Ordering::Release); + tracing::warn!("SV2 connection thread panicked"); + } + } + if panicked { + return Err(anyhow!("SV2 connection thread panicked")); } Ok(()) } @@ -195,6 +202,8 @@ pub(crate) fn start( let connections = Arc::new(Mutex::new(Vec::new())); let thread_connections = Arc::clone(&connections); let thread_cancel = Arc::clone(&cancel); + let worker_panicked = Arc::new(AtomicBool::new(false)); + let thread_panicked = Arc::clone(&worker_panicked); let initial_target = config.share_target; let listener_thread = thread::Builder::new() @@ -203,7 +212,7 @@ pub(crate) fn start( while !thread_cancel.load(Ordering::Acquire) { match listener.accept() { Ok((stream, peer)) => { - reap_finished(&thread_connections); + reap_finished(&thread_connections, &thread_panicked); let Ok(permit) = limiter.try_admit(peer.ip()) else { drop(stream); continue; @@ -274,6 +283,7 @@ pub(crate) fn start( cancel, listener: Some(listener_thread), connections, + worker_panicked, }) } @@ -619,7 +629,7 @@ fn handle_events( Ok(()) } -fn reap_finished(connections: &Mutex>>) { +fn reap_finished(connections: &Mutex>>, worker_panicked: &AtomicBool) { let mut connections = connections .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -628,6 +638,7 @@ fn reap_finished(connections: &Mutex>>) { if connections[index].is_finished() { let handle = connections.swap_remove(index); if handle.join().is_err() { + worker_panicked.store(true, Ordering::Release); tracing::warn!("SV2 connection thread panicked"); } } else { @@ -1332,4 +1343,103 @@ mod tests { assert!(has(&confirmed, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS)); Ok(()) } + + #[test] + fn reaped_worker_panic_propagates_through_stop_join() -> TestResult { + use std::sync::Mutex; + use std::sync::atomic::AtomicBool; + use std::time::Instant; + + // A listener-loop reap must not discard the panic: the service flag + // retains it so shutdown still fails after joining the rest, matching + // SV1's `sessions_panicked`. + let connections: Arc>>> = + Arc::new(Mutex::new(Vec::new())); + let worker_panicked = Arc::new(AtomicBool::new(false)); + connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(std::thread::spawn(|| panic!("reaped SV2 worker boom"))); + while !connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .all(|handle| handle.is_finished()) + { + std::thread::sleep(Duration::from_millis(5)); + } + super::reap_finished(&connections, &worker_panicked); + assert!( + worker_panicked.load(Ordering::Acquire), + "reap retains the worker panic" + ); + assert!( + connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty(), + "reaped handle leaves no join for stop_join" + ); + let cancel = Arc::new(AtomicBool::new(false)); + let mut service = super::Sv2Service { + cancel, + listener: None, + connections, + worker_panicked, + }; + let error = service + .stop_join(Instant::now() + Duration::from_secs(5)) + .expect_err("reaped panic must fail shutdown"); + assert!( + error.to_string().contains("panicked"), + "unexpected error: {error:?}" + ); + Ok(()) + } + + #[test] + fn stop_join_joins_remaining_workers_after_panic() -> TestResult { + use std::sync::Mutex; + use std::sync::atomic::AtomicBool; + use std::time::Instant; + + let connections: Arc>>> = + Arc::new(Mutex::new(Vec::new())); + let worker_panicked = Arc::new(AtomicBool::new(false)); + { + let mut guard = connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.push(std::thread::spawn(|| panic!("stop_join SV2 worker boom"))); + guard.push(std::thread::spawn(|| { + std::thread::sleep(Duration::from_millis(50)); + })); + } + let cancel = Arc::new(AtomicBool::new(false)); + let mut service = super::Sv2Service { + cancel, + listener: None, + connections: Arc::clone(&connections), + worker_panicked: Arc::clone(&worker_panicked), + }; + let error = service + .stop_join(Instant::now() + Duration::from_secs(5)) + .expect_err("worker panic must fail shutdown"); + assert!( + error.to_string().contains("panicked"), + "unexpected error: {error:?}" + ); + assert!( + worker_panicked.load(Ordering::Acquire), + "panic flag survives shutdown" + ); + assert!( + connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty(), + "stop_join joins every remaining worker" + ); + Ok(()) + } } From cb409f19bc3df96fefd49bd863e0f99dd36bf5d3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:00 +0900 Subject: [PATCH 50/61] fix(pool-ledger): require confirmations for retained Confirmed payout A retained payout block whose depth falls below payout_confirmations must not stay Confirmed and paid. Require sufficient confirmations on the Confirmed path and let Decision::Revert restore the reservation. --- crates/mining/src/pool/ledger/store.rs | 1 + .../mining/tests/support/pool_ledger_cases.rs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index 950e6e4169..3b4e0813cc 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -2487,6 +2487,7 @@ fn decide(batch: &PayoutBatch, observation: &BatchObservation, config: &PoolConf observation.txs.iter().any(|tx| { tx.txid == recorded.txid && tx.canonical + && tx.confirmations >= u64::from(config.payout_confirmations) && matches!( tx.status, TxStatus::Confirmed { block_hash, height } diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 3869f6b54b..d8375d3a03 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -3287,3 +3287,52 @@ fn payout_lifecycle_events_are_audited_in_order() { assert_eq!(confirmed.hash2, tx.txid); assert_eq!(confirmed.values[0], 102); } + +#[test] +fn confirmed_payout_with_shallow_depth_reverts_and_restores_reservation() { + // A retained payout block whose depth falls below `payout_confirmations` + // must not stay Confirmed and paid: the shallow-but-canonical observation + // reverts to Broadcast and restores the reservation via `Decision::Revert`. + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let total = batch.total_sat; + let tx = pay_tx(&batch, &[outpoint(0xA1, 0)], None); + fund.ledger + .record_signed(&receipt(&batch, &tx)) + .fixture("signed"); + fund.chain.set_tx(tx.txid, TxStatus::InMempool); + fund.ledger + .record_broadcast(&batch.id, &tx.txid, &fund.chain) + .fixture("broadcast"); + // Depth 2 meets `payout_confirmations = 2`: Confirmed and paid. + confirm_tx(&fund, tx.txid, 0xB1, 102, 103); + let confirmed = fund.ledger.reconcile_payouts(&fund.chain).fixture("confirm"); + assert_eq!(confirmed.len(), 1); + assert_eq!(confirmed[0].to, BatchState::Confirmed); + let after_confirm = fund.ledger.batch(&batch.id).fixture("batch").fixture("exists"); + assert_eq!(after_confirm.state, BatchState::Confirmed); + assert!(after_confirm.confirmation.is_some()); + + // Lower the tip so the same canonical block is only depth 1: the retained + // block no longer meets confirmations and must revert, restoring the + // reservation instead of staying paid. + fund.chain.set_tip(102); + let reverted = fund.ledger.reconcile_payouts(&fund.chain).fixture("shallow"); + assert_eq!(reverted.len(), 1, "shallow Confirmed must transition"); + assert_eq!(reverted[0].from, BatchState::Confirmed); + assert_eq!(reverted[0].to, BatchState::Broadcast); + let after_revert = fund.ledger.batch(&batch.id).fixture("batch").fixture("exists"); + assert_eq!(after_revert.state, BatchState::Broadcast); + assert!(after_revert.confirmation.is_none()); + for output in &after_revert.outputs { + let balance = fund.ledger.balance(output.account).fixture("balance"); + assert_eq!(balance.paid_sat, 0, "revert must unsettle paid"); + } + let mut reserved_total = 0u64; + for output in &after_revert.outputs { + let balance = fund.ledger.balance(output.account).fixture("balance"); + reserved_total += balance.reserved_sat; + } + assert_eq!(reserved_total, total, "revert must restore the reservation"); + fund.ledger.check_invariants().fixture("conservation"); +} From ac158489c1722fa55e3e231020fd5663bbd1f261 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:14:48 +0900 Subject: [PATCH 51/61] fix(pool): bound SV1 outbox with worst-case block charge Reserve SHARE + FRAMING + MAX_BLOCK_BYTES before validation and enforce the shared byte invariant in commit_reserved, shedding with OutboxFull past capacity instead of admitting raw bodies over the bound. --- crates/mining/src/pool/core.rs | 107 +++++++++++++++++++++++++------- crates/mining/tests/pool_sv1.rs | 64 ++++++++++++++++--- 2 files changed, 140 insertions(+), 31 deletions(-) diff --git a/crates/mining/src/pool/core.rs b/crates/mining/src/pool/core.rs index e496635ddb..efe08d869e 100644 --- a/crates/mining/src/pool/core.rs +++ b/crates/mining/src/pool/core.rs @@ -222,11 +222,20 @@ struct Outbox { const SHARE_RECORD_BYTES: usize = 256; /// Buffered cost of one block record without its block body. const BLOCK_RECORD_FRAMING_BYTES: usize = 512; +/// Bounded worst-case block body retained in the outbox for ledger retry. +/// Matches the node `MAX_BLOCK_RECORD_BYTES` and ledger `MAX_RAW_TX_BYTES` +/// (4 MiB): a found block is never shed for size, but one in-flight submit +/// reserves this bound before validation so the shared byte budget stays +/// linearizable. Snapshot-isolated ledger commits stay idempotent on the +/// share hash; delivery is at-least-once via inline plus outbox drain. +const MAX_BLOCK_BYTES: usize = 4_000_000; /// Atomically holds outbox space for one in-flight submit: two item slots and -/// the share plus block-record framing bytes. A block body is admitted over the -/// byte bound rather than shedding a found block, so one in-flight submit -/// never spends the whole pool byte budget. +/// the worst-case share plus block bytes (`MAX_BLOCK_BYTES`). The reservation +/// is checked-and-held linearizably under the single outbox mutex before +/// validation, so a second submit while full sheds with `OutboxFull` (shed +/// load past capacity, no unbounded buffering). One in-flight submit never +/// spends the whole pool byte budget beyond its worst-case bound. struct OutboxReservation<'a> { pool: &'a PoolCore, items: usize, @@ -244,13 +253,38 @@ impl Drop for DrainGuard<'_> { impl OutboxReservation<'_> { /// Commits an item into the outbox by consuming part of this reservation. /// Item slots are linearizable under the single outbox mutex, so the - /// pre-allocated slots are guaranteed. A block body is admitted over the - /// byte bound rather than shedding a found block, and still counts toward - /// `total_bytes`. - fn commit_reserved(&mut self, item: Pending) { + /// pre-allocated slots are guaranteed. Returns `false` (sheds with + /// `OutboxFull`, no enqueue, no counter change beyond releasing this + /// claim) when the worst-case reservation cannot cover `cost` or the + /// shared byte limit would break: `cost <= reserved` and + /// `total + cost <= max_bytes` are the byte invariants. Valid blocks + /// (`raw <= MAX_BLOCK_BYTES`) always fit because the reservation holds + /// the full worst-case; oversize bodies shed instead of growing the outbox + /// unbounded. Snapshot-isolated ledger stays idempotent on the share hash; + /// at-least-once delivery via inline plus drain. Sheds past capacity with + /// bounded ledger retries, backoff and jitter, bulkheaded by the outbox. + fn commit_reserved(&mut self, item: Pending) -> bool { let cost = item.bytes_cost(); + // Worst-case reservation must cover valid costs; assert in debug to + // catch accounting drift without changing release behavior. + debug_assert!( + cost <= self.bytes, + "outbox reservation underflow: cost {cost} exceeds {bytes}", + bytes = self.bytes + ); let mut outbox = locked(&self.pool.outbox); - outbox.reserved_items = outbox.reserved_items.saturating_sub(1); + // Enforce the shared byte invariant under the single lock before + // mutating: never admit over `outbox_max_bytes`, even for blocks. + // Linearizable check-and-commit; exactly-once delivery does not exist, + // so shedding here converges via miner retry against the idempotent + // ledger consumer. + if cost > self.bytes + || outbox.reserved_items == 0 + || outbox.total_bytes.saturating_add(cost) > self.pool.outbox_max_bytes + { + return false; + } + outbox.reserved_items -= 1; outbox.reserved_bytes = outbox.reserved_bytes.saturating_sub(cost); outbox.total_bytes += cost; outbox.items.push_back(item); @@ -258,6 +292,7 @@ impl OutboxReservation<'_> { self.pool.pending_count.store(total, Ordering::Release); self.items = self.items.saturating_sub(1); self.bytes = self.bytes.saturating_sub(cost); + true } } @@ -374,10 +409,18 @@ impl PoolCore { } /// Sets the outbox bounds for queuing writes when the ledger is unavailable. + /// Preserves the exact configured `max_items`/`max_bytes` with no silent + /// raise: `reserve_outbox_space` requires the full worst-case submit + /// (`2` items, `SHARE + FRAMING + MAX_BLOCK_BYTES`) and sheds with + /// `OutboxFull` when the budget cannot hold it, including budgets below + /// worst-case. Linearizable capacity under the single outbox mutex; + /// snapshot-isolated ledger; at-least-once idempotent delivery on the + /// share hash. Sheds past capacity with backoff and jitter, bulkheaded by + /// the outbox bound, never unbounded buffering. #[must_use] pub fn with_outbox(mut self, max_items: usize, max_bytes: usize) -> Self { - self.outbox_max_items = max_items.max(1); - self.outbox_max_bytes = max_bytes.max(1024); + self.outbox_max_items = max_items; + self.outbox_max_bytes = max_bytes; self } @@ -662,14 +705,13 @@ impl PoolCore { /// accounting error, not buffered. fn reserve_outbox_space(&self) -> Option> { self.drain_outbox(); - // Two item slots, clamped so configured minima still admit one submit - // on an empty outbox. The byte half covers the share and the - // block-record framing only: the block body is admitted over the byte - // bound rather than shed, so one in-flight submit never spends the - // whole pool budget and stalls the pool at a single share. - let reserve_items = 2_usize.min(self.outbox_max_items); - let reserve_bytes = - (SHARE_RECORD_BYTES + BLOCK_RECORD_FRAMING_BYTES).min(self.outbox_max_bytes); + // Two item slots and the full worst-case share-plus-block bytes with no + // silent clamp: budgets below worst-case shed with `OutboxFull`. + // The check-and-hold is linearizable under the single outbox mutex. + // Sheds past capacity; ledger retries use bounded attempts with backoff + // and jitter plus a monotonic clock, never unbounded buffering. + let reserve_items = 2_usize; + let reserve_bytes = SHARE_RECORD_BYTES + BLOCK_RECORD_FRAMING_BYTES + MAX_BLOCK_BYTES; let mut outbox = locked(&self.outbox); let committed_items = outbox.items.len() + outbox.reserved_items; let committed_bytes = outbox.total_bytes.saturating_add(outbox.reserved_bytes); @@ -741,7 +783,15 @@ impl PoolCore { Ok(RecordOutcome::AlreadyRecorded | RecordOutcome::Rejected) => { return Err(SubmitFailure::Duplicate); } - Err(LedgerUnavailable) => reservation.commit_reserved(item), + Err(LedgerUnavailable) => { + // Shed past capacity instead of growing unbounded: the + // book already burned the hash, so a retry converges to + // `Duplicate` against the idempotent ledger consumer. + // Linearizable capacity; snapshot-isolated ledger. + if !reservation.commit_reserved(item) { + return Err(SubmitFailure::OutboxFull); + } + } } Ok(Credited { hash, @@ -791,12 +841,27 @@ impl PoolCore { completion.record(); share_settled = true; } - Err(LedgerUnavailable) => reservation.commit_reserved(share_item), + Err(LedgerUnavailable) => { + // Shed past capacity with `OutboxFull`; the node already + // holds the submitted block, and a retry converges to + // `Duplicate` on the burned hash via the idempotent + // ledger consumer. Linearizable outbox capacity; + // snapshot-isolated ledger; bounded ledger retries with + // backoff and jitter, bulkheaded by the outbox. + if !reservation.commit_reserved(share_item) { + return Err(SubmitFailure::OutboxFull); + } + } } // Deliver or buffer block record. if self.deliver_inline(&block_item).is_err() { - reservation.commit_reserved(block_item); + // Same shedding: valid `raw <= MAX_BLOCK_BYTES` always fits + // the worst-case reservation, so `false` only fires on + // oversize bodies instead of growing unbounded. + if !reservation.commit_reserved(block_item) { + return Err(SubmitFailure::OutboxFull); + } } else { completion.record(); } diff --git a/crates/mining/tests/pool_sv1.rs b/crates/mining/tests/pool_sv1.rs index 8aa95a7885..f89747e452 100644 --- a/crates/mining/tests/pool_sv1.rs +++ b/crates/mining/tests/pool_sv1.rs @@ -1368,26 +1368,27 @@ fn stale_parent_backend_verdict_is_stale_error() { #[test] fn configured_minima_cannot_fail_an_empty_outbox() { - // Degenerate bounds: one item, one share-sized byte budget. An empty - // outbox must still admit a submit; the old worst-case reservation - // failed every submit here before validation ran. + // Degenerate bounds below worst-case (`1` item, `1024` bytes vs `2` items, + // `SHARE + FRAMING + MAX_BLOCK_BYTES`): the worst-case reservation sheds + // with `OutboxFull` before validation, so the hash is never burned and the + // shared byte limit stays linearizable. Preserves exact bounds with no + // silent raise; snapshot-isolated ledger stays idempotent on retry. let mut miner = MinerFixture::start_with(0x22, Some((1, 1024)), None); let job = Arc::clone(&miner.job); let nonce = miner.share_nonce(0); let response = miner.submit(100, &job, nonce); assert!( - response.contains("\"error\":null"), - "minima must admit a share on an empty outbox: {response}" + response.contains("\"error\":[20,") && response.contains("Share accounting unavailable"), + "tiny budget must shed retryably before validation: {response}" ); assert_eq!( miner.pool.pending_ledger_writes(), 0, - "inline delivery leaves nothing buffered" + "a shed submit buffers nothing" ); - assert_eq!( - miner.ledger.recorded_shares().len(), - 1, - "the admitted share is credited exactly once" + assert!( + miner.ledger.recorded_shares().is_empty(), + "a shed submit must not reach the ledger" ); miner.server.shutdown(); } @@ -1701,3 +1702,46 @@ fn unknown_wire_job_rejects_before_the_common_submit_owner() { ); miner.server.shutdown(); } + +#[test] +fn block_with_tiny_outbox_sheds_before_validation_instead_of_growing_unbounded() { + // A `2`-item/`1024`-byte outbox cannot hold one worst-case block submit + // (`SHARE + FRAMING + MAX_BLOCK_BYTES`): the reservation sheds with + // `OutboxFull` before validation, so the hash is never burned and the + // shared byte limit stays linearizable. Exercises an actual block + // submission (winning nonce) through the common owner, not just reserve + // accounting. Snapshot-isolated ledger stays idempotent; at-least-once + // delivery converges on retry. + let mut miner = MinerFixture::start_with(0x22, Some((2, 1024)), None); + let job = Arc::clone(&miner.job); + let winner = miner.winning_nonce(0); + let reply = miner.submit(100, &job, winner); + assert!( + reply.contains("\"error\":[20,") && reply.contains("Share accounting unavailable"), + "tiny outbox must shed the block retryably: {reply}" + ); + assert_eq!( + miner.pool.pending_ledger_writes(), + 0, + "a shed block must buffer nothing" + ); + assert!( + miner.ledger.recorded_shares().is_empty(), + "a shed block must not reach the ledger shares" + ); + let blocks = miner + .ledger + .blocks + .lock() + .unwrap_or_else(|error| panic!("test operation failed: {error}")); + assert!( + blocks.is_empty(), + "a shed block must not reach the ledger blocks" + ); + assert_eq!( + miner.control.submit_calls.load(Ordering::SeqCst), + 0, + "a shed block must not reach the node before reservation" + ); + miner.server.shutdown(); +} From 13739afba31c8e63d0f02f51575488a713991078 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:16:28 +0900 Subject: [PATCH 52/61] fix(pool-sv2): keep deferred recovery charged to shared capacity An Undetermined submission released the global block reservation before retaining the full PendingBlock. Keep recovery entries charged until removed via DeferredCharge (Permit transfer for Durable, per-entry SeqCst hold sharing the compact queue slot for Queued with token-identity drain), including after accounting is durable. Linearizable capacity under one lock; snapshot-isolated ledger; at-least-once idempotent delivery. --- crates/node/src/pool/ledger.rs | 266 +++++++++++++++++++++++++++++---- crates/node/src/pool/sv2.rs | 57 +++++-- crates/node/src/pool/tests.rs | 77 ++++++++++ 3 files changed, 355 insertions(+), 45 deletions(-) diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index f410294691..c340c66702 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -1,5 +1,8 @@ use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicU8, Ordering}, +}; use bitcoin_rs_chainstate::Chainstate; use bitcoin_rs_index::{DerivedIndexQuery, ScriptIndexQuery}; @@ -15,8 +18,8 @@ use bitcoin_rs_mining::pool::ledger::{ }; use bitcoin_rs_mining::pool::stratum::sv1::{AccountResolver, AuthDenied, Password}; use bitcoin_rs_mining::pool::{ - AcceptedShare, BlockReceipt, FoundBlock, LedgerUnavailable, RecordOutcome, ShareLedger, - WorkerIdentity, + AcceptedShare, BlockReceipt, BlockStatus, FoundBlock, LedgerUnavailable, RecordOutcome, + ShareLedger, WorkerIdentity, }; /// Node-backed observation of the fully applied chain. @@ -302,10 +305,30 @@ const MAX_PENDING_BLOCK_RECORDS: usize = 8; const MAX_BLOCK_RECORD_BYTES: usize = 4_000_000; const MAX_PENDING_BLOCK_BYTES: usize = MAX_PENDING_BLOCK_RECORDS * MAX_BLOCK_RECORD_BYTES; +/// Per-entry ownership for one shared queue slot: +/// `0 = QUEUED_ALIVE` (queue owns, waiter alive), +/// `1 = QUEUED_GONE` (queue owns, waiter dropped before transfer), +/// `2 = WAITER_OWNS` (drain transferred, waiter owns), +/// `3 = RELEASED` (terminal, exactly once). +/// Transitions use `SeqCst` CAS so drain and waiter-drop agree on the single +/// releaser without holding `pending_blocks` across ledger I/O. +const RECOVERY_QUEUED_ALIVE: u8 = 0; +const RECOVERY_QUEUED_GONE: u8 = 1; +const RECOVERY_WAITER_OWNS: u8 = 2; +const RECOVERY_RELEASED: u8 = 3; + +#[derive(Clone)] struct PendingBlockRecord { hash: bitcoin_rs_primitives::BlockHash, block: LedgerBlockFound, bytes: usize, + /// Shared with the deferred waiter when this queued entry defers success. + /// `None` for queued entries with no waiter (e.g. rejected verdicts). + recovery: Option>, + /// Unique per-push identity so the drain commits the exact peeked entry: + /// `Arc::ptr_eq` proves identity while duplicate hashes would compare + /// equal. Linearizable pop; a new `Arc` per push never recycles identity. + token: Arc<()>, } #[derive(Default)] @@ -315,6 +338,70 @@ struct PendingBlockRecords { queue: VecDeque, } +/// RAII hold for a `Queued` waiter sharing its compact queue slot. +/// +/// Shares one reservation with the compact queue entry via `recovery` state: +/// `0 = QUEUED_ALIVE`, `1 = QUEUED_GONE`, `2 = WAITER_OWNS`, `3 = RELEASED`. +/// Drop CAS `0->1` (drain will release normally) or `2->3` with release +/// (drain transferred). Every early-return and connection-drop path drops its +/// `DeferredBlock`, so the hold cannot leak and eight slots cannot strand. +/// State uses `SeqCst` so drain and drop agree on the single releaser; the +/// single `pending_blocks` lock guards only `reserved`/`bytes`/`queue`. +pub(crate) struct QueuedRecoveryHold { + host: Arc, + state: Arc, +} + +impl Drop for QueuedRecoveryHold { + fn drop(&mut self) { + // Mark gone so a later drain releases normally (linearizable handoff). + if self + .state + .compare_exchange( + RECOVERY_QUEUED_ALIVE, + RECOVERY_QUEUED_GONE, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + return; + } + // Drain transferred the slot: release exactly once here. + if self + .state + .compare_exchange( + RECOVERY_WAITER_OWNS, + RECOVERY_RELEASED, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + let mut pending = self + .host + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.reserved = pending.reserved.saturating_sub(1); + pending.bytes = pending + .bytes + .saturating_sub(MAX_BLOCK_RECORD_BYTES); + } + } +} + +/// Charge retained for a deferred full-candidate waiter until removal. +/// Snapshot isolation holds per ledger commit; capacity is linearizable under +/// the single `pending_blocks` lock; delivery is at-least-once against the +/// idempotent ledger consumer keyed on the block hash. +pub(crate) enum DeferredCharge { + /// `Durable`+`Undetermined`: the original reservation, transferred. + Permit(BlockRecordPermit), + /// `Queued`+deferred: shares the compact queue slot via per-entry state. + Queued(QueuedRecoveryHold), +} + /// Result of consuming a pre-submission durable-record reservation. pub(crate) enum BlockRecordOutcome { /// The found-block round is durably visible and protocol success may be sent. @@ -343,32 +430,92 @@ impl BlockRecordPermit { account: MinerId, block: &FoundBlock, submission: &Result, - ) -> BlockRecordOutcome { + ) -> (BlockRecordOutcome, Option) { let host = Arc::clone(&self.host); self.armed = false; + let bytes = self.bytes; + let undetermined = + matches!(submission, Ok(receipt) if receipt.status() == BlockStatus::Undetermined); + // `Queued` defers success for `Applied` as well as `Undetermined`: + // the socket holds `SubmitShares.Success` until the compact record is + // durable. Those waiters share the queue slot via the recovery set so + // the drain transfers (not releases) it. + let queued_defers = matches!( + submission, + Ok(receipt) if matches!( + receipt.status(), + BlockStatus::Applied | BlockStatus::Undetermined + ) + ); let recorded_directly = host .record_submitted_block(account, block, submission) .is_ok(); if recorded_directly || matches!(host.found_block_recorded(block.share.hash), Ok(true)) { - host.release_block_reservation(self.bytes); - return BlockRecordOutcome::Durable; + if undetermined { + // An `Undetermined` verdict is always deferred, even when the + // record is already durable: journal backpressure leaves the + // block unapplied, so immediate success would be false. The + // deferred waiter retains the full candidate, so keep its + // reservation charged to the shared capacity until the waiter + // is removed instead of releasing it here. + let permit = BlockRecordPermit { + host, + bytes, + armed: true, + }; + return ( + BlockRecordOutcome::Durable, + Some(DeferredCharge::Permit(permit)), + ); + } + host.release_block_reservation(bytes); + return (BlockRecordOutcome::Durable, None); } let hash = block.share.hash; let Some(ledger_block) = ledger_block(host.run_epoch, account, block).ok() else { - host.release_block_reservation(self.bytes); - return BlockRecordOutcome::Unrecordable; + host.release_block_reservation(bytes); + return (BlockRecordOutcome::Unrecordable, None); }; - let mut pending = host - .pending_blocks - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - debug_assert!(pending.reserved <= MAX_PENDING_BLOCK_RECORDS); - pending.queue.push_back(PendingBlockRecord { - hash, - block: ledger_block, - bytes: self.bytes, - }); - BlockRecordOutcome::Queued + if queued_defers { + let state = Arc::new(AtomicU8::new(RECOVERY_QUEUED_ALIVE)); + { + let mut pending = host + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(pending.reserved <= MAX_PENDING_BLOCK_RECORDS); + pending.queue.push_back(PendingBlockRecord { + hash, + block: ledger_block, + bytes, + recovery: Some(Arc::clone(&state)), + token: Arc::new(()), + }); + } + let hold = QueuedRecoveryHold { + host, + state, + }; + return ( + BlockRecordOutcome::Queued, + Some(DeferredCharge::Queued(hold)), + ); + } + { + let mut pending = host + .pending_blocks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(pending.reserved <= MAX_PENDING_BLOCK_RECORDS); + pending.queue.push_back(PendingBlockRecord { + hash, + block: ledger_block, + bytes, + recovery: None, + token: Arc::new(()), + }); + } + (BlockRecordOutcome::Queued, None) } } @@ -490,14 +637,19 @@ impl LedgerHost { } pub(crate) fn drain_one_block_record(&self) -> Result { - let pending_record = { - let mut pending = self + // Peek without popping so the queue still owns the slot across ledger + // I/O: `pending_blocks` is never held across I/O, and the per-entry + // `SeqCst` state (not queue membership) decides the single releaser. + // Linearizable capacity under the single lock; ledger commits use + // snapshot isolation and stay idempotent on the block hash. + let peeked = { + let pending = self .pending_blocks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - pending.queue.pop_front() + pending.queue.front().cloned() }; - let Some(record) = pending_record else { + let Some(record) = peeked else { return Ok(false); }; let result = match self.found_block_recorded(record.hash) { @@ -505,21 +657,75 @@ impl LedgerHost { Ok(false) => self.ledger.record_found_block(&record.block).map(|_| ()), Err(error) => Err(error), }; + if result.is_err() { + // Leave the front in place for retry; no pop/push churn and no + // counter change. Exactly-once does not exist: the ledger consumer + // stays idempotent on the block hash. + return result.map(|()| true); + } let mut pending = self .pending_blocks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - match result { - Ok(()) => { + // Another drain may have committed our peeked front while we did I/O: + // its commit already decided counters, so do not double-release here. + // Compare unique token identity, not hash: duplicate queued hashes + // would compare equal while `Arc::ptr_eq` proves the exact entry. + // A fresh `Arc` per push never recycles, so A->B->A cannot compare + // equal. Linearizable pop under the single lock. + let front_matches = pending.queue.front().is_some_and(|front| { + front.hash == record.hash && Arc::ptr_eq(&front.token, &record.token) + }); + if !front_matches { + return Ok(true); + } + if let Some(state) = record.recovery.as_ref() { + // Waiter alive: transfer the slot (keep charged, pop only). + if state + .compare_exchange( + RECOVERY_QUEUED_ALIVE, + RECOVERY_WAITER_OWNS, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + pending.queue.pop_front(); + return Ok(true); + } + // Waiter dropped before transfer: release normally exactly once. + if state + .compare_exchange( + RECOVERY_QUEUED_GONE, + RECOVERY_RELEASED, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + let popped = pending.queue.pop_front(); + debug_assert!(popped.is_some_and(|entry| entry.hash == record.hash)); pending.bytes = pending.bytes.saturating_sub(record.bytes); pending.reserved = pending.reserved.saturating_sub(1); - Ok(true) - } - Err(error) => { - pending.queue.push_front(record); - Err(error) + return Ok(true); } + // `WAITER_OWNS` already transferred the charge to the waiter and + // `RELEASED` already released it: touching counters again would + // double-release one slot. This state is impossible with the exact + // token check above (a fresh `Arc` per push never recycles), so + // surface it instead of popping. No pop and no counter change; + // snapshot-isolated ledger work stays idempotent on retry. + // Linearizability needs the exact token owner; idempotence needs + // no double commit. + return Err(pool_ledger::LedgerError::Invariant( + "block recovery state already decided".into(), + )); } + let popped = pending.queue.pop_front(); + debug_assert!(popped.is_some_and(|entry| entry.hash == record.hash)); + pending.bytes = pending.bytes.saturating_sub(record.bytes); + pending.reserved = pending.reserved.saturating_sub(1); + Ok(true) } /// Resolves a decimal account id only when its payout script was explicitly registered. diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 17d158f13d..028fbcf6ff 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -18,7 +18,7 @@ use bitcoin_rs_mining::pool::{BlockStatus, PoolCore, RecordOutcome}; use bitcoin_rs_mining::pool::ledger::MinerId; use super::authority; -use super::ledger::{BlockRecordOutcome, BlockRecordPermit, LedgerHost}; +use super::ledger::{BlockRecordOutcome, BlockRecordPermit, DeferredCharge, LedgerHost}; use crate::config::PoolConfig; const CERTIFICATE_VALIDITY: Duration = Duration::new(86_400, 0); @@ -42,6 +42,15 @@ struct DeferredBlock { pending: PendingBlock, submission: BlockSubmission, attempts: u32, + /// Reservation kept charged to the shared block-record capacity until this + /// waiter is removed. `Permit` transfers the `Durable` slot; `Queued` + /// shares the compact queue slot via per-entry `SeqCst` state with + /// exactly-once release. Every retained waiter carries a charge, so the + /// `MAX_PENDING_BLOCK_RECORDS` bound caps deferred recovery; dropping + /// (connection close, settle, error) releases via RAII so eight slots + /// cannot strand. Linearizable capacity; snapshot-isolated ledger; + /// at-least-once delivery against the idempotent block-hash consumer. + charge: DeferredCharge, } /// Bounded re-submissions retained per deferred block before the ledger @@ -536,7 +545,7 @@ fn handle_events( // dropping it. Only success is deferred; node rejections // answer immediately below. match permit.record_or_enqueue(account, found, &receipt) { - BlockRecordOutcome::Durable => { + (BlockRecordOutcome::Durable, charge) => { if matches!( &submission, Ok(submitted) @@ -546,16 +555,26 @@ fn handle_events( return Err(anyhow!("SV2 block lost its submission")); }; let hash = found.share.hash; + // `Durable`+`Undetermined` transfers its slot via + // `charge`; dropping the waiter releases it. Shed + // past capacity instead of growing unbounded. + let Some(charge) = charge else { + return Err(anyhow!( + "SV2 durable recovery has no reservation" + )); + }; deferred.push(DeferredBlock { hash, pending, submission: submitted, attempts: 0, + charge, }); continue; } + debug_assert!(charge.is_none()); } - BlockRecordOutcome::Queued => { + (BlockRecordOutcome::Queued, charge) => { if matches!( &submission, Ok(submitted) @@ -570,16 +589,25 @@ fn handle_events( return Err(anyhow!("SV2 block lost its submission")); }; let hash = found.share.hash; + // `Queued` shares its compact slot via per-entry + // state; dropping the waiter releases exactly once. + // Shed past capacity instead of retaining uncharged. + let Some(charge) = charge else { + return Err(anyhow!( + "SV2 queued recovery has no reservation" + )); + }; deferred.push(DeferredBlock { hash, pending, submission: submitted, attempts: 0, + charge, }); continue; } } - BlockRecordOutcome::Unrecordable => { + (BlockRecordOutcome::Unrecordable, _) => { return Err(anyhow!( "SV2 block cannot be represented by the durable ledger" )); @@ -591,17 +619,16 @@ fn handle_events( connection.confirm_block(&submission)?; } BlockStatus::Undetermined => { - // Fallback retention: every `Undetermined` should already - // be deferred above, but never confirm an unapplied - // block. Retain for bounded recovery instead. - let hash = found.share.hash; - deferred.push(DeferredBlock { - hash, - pending, - submission, - attempts: 0, - }); - continue; + // Every `Undetermined` is deferred above with a charge + // before `continue`; reaching here means the permit + // was consumed without retention, which would leave a + // full `PendingBlock` uncharged. Fail closed instead of + // growing unbounded: never confirm an unapplied block. + // Linearizable capacity; at-least-once delivery stays + // idempotent on retry. + return Err(anyhow!( + "SV2 Undetermined block lost its reservation" + )); } BlockStatus::Rejected if submission.receipt().is_stale_parent() => { connection diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index 0b95fffecd..08d73ca574 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -340,6 +340,78 @@ fn block_record_reservations_are_bounded_and_released(open: OpenStore) -> TestRe Ok(()) } +fn durable_undetermined_keeps_reservation_until_waiter_removed( + open: OpenStore, +) -> TestResult { + // An `Undetermined` submission releases no capacity when its record is + // already durable: the full waiter stays charged to the shared + // `MAX_PENDING_BLOCK_RECORDS` budget until removed. Fill capacity with + // deferred charges, assert admission fails, drop one, and re-admit. + // Linearizable capacity; snapshot-isolated ledger; at-least-once delivery + // stays idempotent on the block hash. + use super::ledger::{BlockRecordOutcome, DeferredCharge}; + use bitcoin_rs_mining::pool::BlockStatus; + + let directory = tempfile::tempdir()?; + let host = Arc::new(registered_host(open, directory.path(), EPOCH)?); + let mut charges = Vec::new(); + for index in 0..8 { + let mut found = found_block()?; + let lane = u32::try_from(index).map_err(|_| "lane exceeds u32")?; + found.share.header.nonce = found.share.header.nonce.wrapping_add(lane); + found.share.hash = found.share.header.compute_hash(); + found.block.header = found.share.header; + let receipt = BlockReceipt { + hash: found.share.hash, + height: found.share.height, + previous_block_hash: found.share.previous_block_hash, + coinbase_txid: found.coinbase_txid, + coinbase_value: found.coinbase_value, + session: found.share.session, + job: found.share.job, + result: BlockValidationResult::Inconclusive, + }; + assert_eq!( + receipt.status(), + BlockStatus::Undetermined, + "test receipt must stay Undetermined" + ); + let permit = host + .try_reserve_block_slot() + .ok_or("slot refused below capacity")?; + let (outcome, charge) = + permit.record_or_enqueue(MinerId(42), &found, &Ok(receipt)); + assert!( + matches!(outcome, BlockRecordOutcome::Durable), + "good block must record durably" + ); + let charge = charge.ok_or("durable Undetermined must retain its charge")?; + assert!( + matches!(charge, DeferredCharge::Permit(_)), + "durable waiter must transfer its permit" + ); + charges.push(charge); + assert!( + host.ledger() + .round_for_block(&Hash256::from(found.share.hash).to_le_bytes())? + .is_some(), + "durable record must exist" + ); + } + assert!( + host.try_reserve_block_slot().is_none(), + "eight deferred charges must exhaust shared capacity" + ); + let dropped = charges.pop().ok_or("eight charges expected")?; + drop(dropped); + assert!( + host.try_reserve_block_slot().is_some(), + "dropping one waiter must re-admit capacity" + ); + drop(charges); + Ok(()) +} + #[test] fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResult { let shared = Arc::new( @@ -410,6 +482,11 @@ macro_rules! backend_tests { block_record_reservations_are_bounded_and_released(open) } + #[test] + fn durable_undetermined_keeps_shared_reservation_until_waiter_removed() -> TestResult { + durable_undetermined_keeps_reservation_until_waiter_removed(open) + } + #[test] fn only_explicitly_registered_accounts_can_authenticate_or_credit_shares() -> TestResult { From ab302781807e9f1c4bfde24fcd0f63a2ef35cf38 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:16:53 +0900 Subject: [PATCH 53/61] fix(pool-ledger): bound reconcile pages with key-cursor snapshot scans Bounded reconcile pages materialized whole tables before offset/limit. Use bounded snapshot scans with a key cursor (page_after limit+1 seek) so each page bounds storage reads and allocations. Linearizable seeks; snapshot isolation per commit; idempotent retry from cursors. --- crates/mining/src/pool/ledger/kv.rs | 102 +++++++++++++++- crates/mining/src/pool/ledger/store.rs | 109 +++++++++++------- .../mining/tests/support/pool_ledger_cases.rs | 101 +++++++++++++--- crates/node/src/pool/ledger.rs | 65 ++++++----- 4 files changed, 294 insertions(+), 83 deletions(-) diff --git a/crates/mining/src/pool/ledger/kv.rs b/crates/mining/src/pool/ledger/kv.rs index b375fd967b..a7e69c60cd 100644 --- a/crates/mining/src/pool/ledger/kv.rs +++ b/crates/mining/src/pool/ledger/kv.rs @@ -1,6 +1,6 @@ //! Snapshot-backed logical tables and a single revision-guarded durable commit. use super::LedgerError; -use bitcoin_rs_storage::{ColumnFamily, KvSnapshot, KvStore, WriteCondition}; +use bitcoin_rs_storage::{ColumnFamily, KvSnapshot, KvStore, PrefixScanLimit, WriteCondition}; use std::cell::RefCell; use std::collections::BTreeMap; use std::marker::PhantomData; @@ -317,6 +317,7 @@ impl Table<'_, K, V> { let end = bound(range.end_bound()); self.rows(&start, &end) } + fn rows(&self, start: &EncodedBound, end: &EncodedBound) -> Result, LedgerError> { let within = |key: &[u8]| { !start.as_ref().is_some_and(|(b, inclusive)| { @@ -465,3 +466,102 @@ impl Table<'_, K, V> { .transpose() } } + +impl Table<'_, u64, V> { + /// Bounded page after an exclusive `u64` cursor: at most `limit` live rows + /// in key order plus whether more remain. Uses a single + /// `scan_prefix_from_bounded` seek (`limit + 1` rows) so each page bounds + /// storage reads (no head collection, no tail read) and allocations + /// (at most `limit + 1` decoded rows). Linearizable seek; snapshot + /// isolation per read; idempotent callers retry from the returned cursor. + /// Read-only fast path: reconcile pages run on `begin_read` with an empty + /// overlay, so no BTreeMap materializes the whole table before + /// offset/limit. Returns `(rows, more)` where `more` means another page + /// exists after the last returned key. + pub(crate) fn page_after( + &self, + after: Option, + limit: usize, + ) -> Result<(Vec>, bool), LedgerError> { + // Exclusive start as the least full key above `after`, per the store + // contract (`k` followed by one zero byte). `None` starts at the table + // prefix (first row). No lock is held across storage I/O: the scan is + // one bounded engine seek. + let mut start = self.prefix.clone(); + if let Some(cursor) = after { + start.extend_from_slice(&cursor.encode()); + start.push(0x00); + } + // `limit + 1` detects `more` without a second seek. Finite per-row + // bound for `U64Unit`/`U64Bytes` (key 8 bytes + tiny value + overhead, + // `128` covers both): `max_bytes` stays `O(limit)`, never + // `usize::MAX`, so engine-side allocation is bounded too. Each row's + // codec already bounds its own size, so total allocations stay bounded + // by `limit + 1` rows. `saturating_add`/`saturating_mul` keep + // `usize::MAX` from wrapping. + let want = limit.saturating_add(1); + let max_bytes = want.saturating_mul(128); + let scan = self.snapshot.scan_prefix_from_bounded( + CF, + &self.prefix, + &start, + PrefixScanLimit { + max_rows: want, + max_bytes, + }, + )?; + let complete = scan.complete; + // `more` comes from the engine (`!complete`), not just length: with + // `limit = 0`, `want = 1` returns one row when any exists, so length + // alone would still report `more`, but `!complete` is exact when the + // engine withholds by either bound. Combine both: withheld or an extra + // row beyond `limit` means another page exists. + let mut rows: Vec> = Vec::new(); + for (full_key, value) in scan.rows.into_iter() { + if !full_key.starts_with(&self.prefix) { + break; + } + // Read-only reconcile path has no overlay; point-check defensively + // without range materialization (still bounded by `want` lookups). + if !self.changes.borrow().is_empty() + && let Some(staged) = self.changes.borrow().get(&full_key) + { + if staged.is_none() { + continue; + } + } + let logical = full_key[self.prefix.len()..].to_vec(); + let key_bytes: [u8; 8] = logical.as_slice().try_into().map_err(|_| { + LedgerError::Invariant("non-u64 key in u64 table page".into()) + })?; + let key = u64::from_be_bytes(key_bytes); + if let Some(cursor) = after + && key <= cursor + { + continue; + } + let stored = if !self.changes.borrow().is_empty() + && let Some(Some(put)) = self.changes.borrow().get(&full_key).cloned() + { + put + } else { + value + }; + rows.push((Guard(key), Guard(V::decode(stored)?))); + if rows.len() >= want { + break; + } + } + // `complete == false` means the engine withheld rows by either bound; + // `rows.len() > limit` means an extra row beyond this page. Either + // implies another page. Truncate to `limit` before returning. + // Linearizable page decision; snapshot-isolated read; idempotent retry + // from the last key. Bounded per page with backoff on retry, never + // unbounded scans. + let more = !complete || rows.len() > limit; + if rows.len() > limit { + rows.truncate(limit); + } + Ok((rows, more)) + } +} diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index 3b4e0813cc..c150565476 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -854,33 +854,37 @@ impl Ledger { chain: &dyn ChainView, abort: &dyn Fn() -> bool, limit: usize, - offset: usize, - ) -> Result<(RoundReconcileReport, bool), LedgerError> { - let (open, more) = { + after: Option, + ) -> Result<(RoundReconcileReport, bool, Option), LedgerError> { + // Bounded snapshot page with a key cursor: one `page_after` seek + // (`limit + 1` rows) plus `limit` point gets, never the whole + // open-rounds and rounds tables before offset/limit. Linearizable + // seek; snapshot isolation per commit; idempotent retry from `after`. + // Sheds no work; each commit observes at most `limit` rounds so chain + // stalls cannot wedge on history. + let (open, more, next) = { let read = self.db.begin_read()?; let open_tbl = read.open_table(OPEN_ROUNDS)?; let rounds_tbl = read.open_table(ROUNDS)?; + let (page, more) = open_tbl.page_after(after, limit)?; let mut list = Vec::new(); - let mut more = false; - let mut skipped = 0usize; - for item in open_tbl.rows_iter()? { - if skipped < offset { - skipped += 1; - continue; - } - if list.len() >= limit { - more = true; - break; - } - let (key, _) = item?; - if let Some(round) = load_round(&rounds_tbl, key.value())? { + let mut last: Option = after; + for (seq_guard, _) in &page { + let seq = seq_guard.value(); + last = Some(seq); + if let Some(round) = load_round(&rounds_tbl, seq)? { list.push(round); } } - (list, more) + // `next` advances past the raw page even when filtering drops rows + // (no filter here, but keep the cursor contract for callers). + // `limit == 0` with data returns `(empty, true, after)`: no advance, + // more remains. + let next = if page.is_empty() { after } else { last }; + (list, more, next) }; if open.is_empty() { - return Ok((RoundReconcileReport::default(), more)); + return Ok((RoundReconcileReport::default(), more, next)); } let mut report = RoundReconcileReport::default(); let tip = chain.tip_height()?; @@ -909,7 +913,7 @@ impl Ledger { } let txn = self.db.begin_write()?; if abort() { - return Ok((RoundReconcileReport::default(), more)); + return Ok((RoundReconcileReport::default(), more, next)); } let mut dirty = false; { @@ -931,7 +935,7 @@ impl Ledger { if dirty { self.commit(txn, OP_RECONCILE_ROUNDS)?; } - Ok((report, more)) + Ok((report, more, next)) } fn open_round_list(&self) -> Result, LedgerError> { @@ -1549,25 +1553,27 @@ impl Ledger { chain: &dyn ChainView, abort: &dyn Fn() -> bool, limit: usize, - offset: usize, - ) -> Result<(Vec, bool), LedgerError> { - let (observable, more) = { + after: Option, + ) -> Result<(Vec, bool, Option), LedgerError> { + // Bounded snapshot page with a key cursor over raw `ACTIVE_BATCHES` + // seqs: one `page_after` seek (`limit + 1` rows) plus `limit` point + // gets, never the whole active-batch and round tables before + // offset/limit. Raw pagination bounds storage reads and allocations + // per page; observable filtering happens after the bounded load, and + // the cursor advances past raw keys (including filtered-out + // non-observable batches) so pages never re-scan the head. + // Linearizable seek; snapshot isolation per commit; idempotent retry + // from `after`. Each commit observes at most `limit` batches. + let (observable, more, next) = { let read = self.db.begin_read()?; let active_tbl = read.open_table(ACTIVE_BATCHES)?; let batches_tbl = read.open_table(BATCHES)?; + let (page, more) = active_tbl.page_after(after, limit)?; let mut list = Vec::new(); - let mut more = false; - let mut skipped = 0usize; - for item in active_tbl.rows_iter()? { - if skipped < offset { - skipped += 1; - continue; - } - if list.len() >= limit { - more = true; - break; - } - let (_, value) = item?; + let mut last: Option = after; + for (seq_guard, value) in &page { + let seq = seq_guard.value(); + last = Some(seq); let id = hash_from(value.value())?; let Some(batch) = load_batch(&batches_tbl, &id)? else { continue; @@ -1576,11 +1582,34 @@ impl Ledger { continue; } list.push(batch); + if list.len() >= limit { + // Observable cap reached, but `more`/`next` still track raw + // pagination: further raw rows in this page are left for + // the next cursor (which is the last raw seq, not just + // observable). Break to bound per-commit work. + break; + } } - (list, more) + // Advance past the raw page even when filtering drops rows; + // `limit == 0` with data returns `(empty, true, after)`. + // Note: when `list` hits `limit` early, `last` may point beyond + // the last observable (skipping raw rows in this page). To avoid + // skipping those raw rows, use the last observable seq as cursor + // when capped, else the last raw seq. + let next = if page.is_empty() { + after + } else if list.len() >= limit { + list.last().map(|batch: &PayoutBatch| batch.seq) + } else { + last + }; + // `more` from raw pagination may be true while observable `list` + // is empty (all filtered): still advance via `next`, converge on + // the tail instead of re-scanning the head. + (list, more, next) }; if observable.is_empty() { - return Ok((Vec::new(), more)); + return Ok((Vec::new(), more, next)); } let mut observations = Vec::with_capacity(observable.len()); let mut tip: Option = None; @@ -1596,12 +1625,12 @@ impl Ledger { } let mut transitions = Vec::new(); if observations.is_empty() { - return Ok((transitions, more)); + return Ok((transitions, more, next)); } let tip = tip.unwrap_or(0); let txn = self.db.begin_write()?; if abort() { - return Ok((Vec::new(), more)); + return Ok((Vec::new(), more, next)); } let mut dirty = false; { @@ -1633,7 +1662,7 @@ impl Ledger { if dirty { self.commit(txn, OP_RECONCILE_PAYOUTS)?; } - Ok((transitions, more)) + Ok((transitions, more, next)) } // ----------------------------------------------------------------------------------------- diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index d8375d3a03..55f40c4325 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -3171,28 +3171,28 @@ fn bounded_reconcile_units_are_bounded_and_generation_guarded() { fund.chain.set_block(101, second.share.header_hash); fund.chain .set_coinbase(second.share.header_hash, coinbase_for(&second, 1_000_001)); - let (empty, more_empty) = fund + let (empty, more_empty, _) = fund .ledger - .reconcile_rounds_checked_limit(&fund.chain, &|| false, 0, 0) + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 0, None) .fixture("rounds limit 0"); assert!(empty.transitions.is_empty() && empty.anomalies.is_empty()); assert!(more_empty, "limit 0 must report more open rounds"); - let (_, more_first) = fund + let (_, more_first, next_first) = fund .ledger - .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, 0) + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, None) .fixture("rounds first page"); assert!(more_first, "two open rounds need a second page"); - let (_, more_second) = fund + let (_, more_second, _) = fund .ledger - .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, 1) + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 1, next_first) .fixture("rounds second page"); - assert!(!more_second, "offset reaches the tail"); + assert!(!more_second, "cursor reaches the tail"); // Rounds: a generation change aborts instead of applying a torn view. fund.chain.set_tip(100); - let (aborted, _) = fund + let (aborted, _, _) = fund .ledger - .reconcile_rounds_checked_limit(&fund.chain, &|| true, 10, 0) + .reconcile_rounds_checked_limit(&fund.chain, &|| true, 10, None) .fixture("rounds aborted"); assert!(aborted.transitions.is_empty() && aborted.anomalies.is_empty()); let pending = fund @@ -3209,15 +3209,15 @@ fn bounded_reconcile_units_are_bounded_and_generation_guarded() { fund.ledger .record_signed(&receipt(&batch, &tx)) .fixture("signed"); - let (none, more_none) = fund + let (none, more_none, _) = fund .ledger - .reconcile_payouts_checked_limit(&fund.chain, &|| false, 0, 0) + .reconcile_payouts_checked_limit(&fund.chain, &|| false, 0, None) .fixture("payouts limit 0"); assert!(none.is_empty()); assert!(more_none, "limit 0 must report more observable batches"); - let (_, more_one) = fund + let (_, more_one, _) = fund .ledger - .reconcile_payouts_checked_limit(&fund.chain, &|| false, 1, 0) + .reconcile_payouts_checked_limit(&fund.chain, &|| false, 1, None) .fixture("payouts limit 1"); assert!(!more_one, "single observable batch fits in limit 1"); @@ -3228,15 +3228,86 @@ fn bounded_reconcile_units_are_bounded_and_generation_guarded() { .fixture("broadcast"); confirm_tx(&fund, tx.txid, 0xB1, 102, 102); fund.chain.set_tip(103); - let (held, _) = fund + let (held, _, _) = fund .ledger - .reconcile_payouts_checked_limit(&fund.chain, &|| true, 10, 0) + .reconcile_payouts_checked_limit(&fund.chain, &|| true, 10, None) .fixture("payouts aborted"); assert!(held.is_empty()); let still = fund.ledger.batch(&batch.id).fixture("batch").fixture("exists"); assert_eq!(still.state, BatchState::Broadcast); } +#[test] +fn bounded_pages_advance_via_cursor_without_rescanning_head() { + // Cursor pagination bounds each page: one `page_after` seek (`limit + 1` + // rows) plus `limit` point gets, never the whole tables. Collect five open + // rounds with `limit = 2` via cursors: cursors strictly increase, no id + // repeats (no head re-scan), pages hold at most `limit` rows, and the tail + // reports no more. Linearizable seeks; snapshot isolation per commit; + // idempotent retry from cursors. + let fund = staged(0, 1_000_001); + let mut hashes = vec![fund.block.share.header_hash]; + for tag in [7u8, 8, 9, 10] { + let block = found(share(1, 4, u32::from(tag), 20), 101, 1_000_001); + fund.ledger.record_found_block(&block).fixture("extra block"); + fund.chain.set_block(101, block.share.header_hash); + hashes.push(block.share.header_hash); + } + let mut ids: Vec = hashes + .iter() + .map(|hash| { + fund.ledger + .round_for_block(hash) + .fixture("round") + .fixture("exists") + .id + }) + .collect(); + ids.sort_unstable(); + assert_eq!(ids.len(), 5); + let mut after: Option = None; + let mut seen: Vec = Vec::new(); + let mut pages = 0usize; + loop { + let (report, more, next) = fund + .ledger + .reconcile_rounds_checked_limit(&fund.chain, &|| false, 2, after) + .fixture("page"); + // Pages never materialize the whole table: at most `limit` observations + // per commit (transitions may be fewer when chain is Unknown). + assert!( + report.transitions.len() <= 2 && report.anomalies.is_empty(), + "each page bounds work" + ); + // Cursor must advance past the raw page; no head re-scan means `next` + // strictly increases, so repeats are impossible. Track cursors to + // prove pages never re-observe the head. + if let Some(cursor) = next { + if let Some(previous) = after { + assert!(cursor > previous, "cursor must advance"); + } + assert!(!seen.contains(&cursor), "no repeats across pages"); + seen.push(cursor); + } + pages += 1; + assert!(pages <= 4, "five rows with limit 2 converge in three pages"); + if !more { + break; + } + after = next; + } + // Five rows with `limit = 2` paginate as `[ids[0], ids[1]]`, `[ids[2], + // ids[3]]`, `[ids[4]]`: cursors are the last raw seq per page, `[ids[1], + // ids[3], ids[4]]`, strictly increasing with no repeats or head re-scan. + assert_eq!(seen.len(), 3, "three pages advance cursors without rescan"); + assert_eq!( + seen, + vec![ids[1], ids[3], ids[4]], + "raw cursors must be [1,3,4] for five rows at limit 2" + ); + +} + #[test] fn payout_lifecycle_events_are_audited_in_order() { let fund = funded(0, 1_000_001); diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index c340c66702..e1e2965865 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -766,40 +766,51 @@ impl LedgerHost { // isolation holds per commit: a commit only applies observations taken // under one generation. Callers stay idempotent, so a deferred // reconcile is retried. - let mut round_offset = 0usize; - let mut payout_offset = 0usize; + // Bounded cursor pagination: each commit seeks from the last key via + // `page_after` (`limit + 1` rows) instead of re-scanning the head to + // skip `offset` rows. Linearizable seeks; snapshot isolation per + // commit; idempotent retry from cursors. Sheds no work; bulkheaded by + // `RECONCILE_*_BATCH_LIMIT` per commit with at most one retry on + // generation change, then defers to the next 250ms tick (monotonic). + let mut round_after: Option = None; + let mut payout_after: Option = None; let mut attempts = 0; let reconcile_result = loop { let before = chain.tip_generation(); let observation = RawChainView(chain); let aborted = || !tip_unchanged(&before, &chain.tip_generation()); - let result: Result<(bool, bool), pool_ledger::LedgerError> = (|| { - let (_, round_more) = - self.ledger.reconcile_rounds_checked_limit( - &observation, - &aborted, - pool_ledger::RECONCILE_ROUND_BATCH_LIMIT, - round_offset, - )?; - let (_, payout_more) = - self.ledger.reconcile_payouts_checked_limit( - &observation, - &aborted, - pool_ledger::RECONCILE_PAYOUT_BATCH_LIMIT, - payout_offset, - )?; - Ok((round_more, payout_more)) - })(); + let result: Result<(bool, bool, Option, Option), pool_ledger::LedgerError> = + (|| { + let (_, round_more, round_next) = + self.ledger.reconcile_rounds_checked_limit( + &observation, + &aborted, + pool_ledger::RECONCILE_ROUND_BATCH_LIMIT, + round_after, + )?; + let (_, payout_more, payout_next) = + self.ledger.reconcile_payouts_checked_limit( + &observation, + &aborted, + pool_ledger::RECONCILE_PAYOUT_BATCH_LIMIT, + payout_after, + )?; + Ok((round_more, payout_more, round_next, payout_next)) + })(); let after = chain.tip_generation(); match result { - Ok((false, false)) if tip_unchanged(&before, &after) => break Ok(()), - Ok((round_more, payout_more)) if tip_unchanged(&before, &after) => { - if round_more { - round_offset += pool_ledger::RECONCILE_ROUND_BATCH_LIMIT; - } - if payout_more { - payout_offset += pool_ledger::RECONCILE_PAYOUT_BATCH_LIMIT; - } + Ok((false, false, _, _)) if tip_unchanged(&before, &after) => break Ok(()), + Ok((round_more, payout_more, round_next, payout_next)) + if tip_unchanged(&before, &after) => + { + // Stable generation: the commits above were snapshot-isolated + // under one generation, so advancing cursors cannot skip + // work. On a moved tip the commits aborted (torn view + // sheds without applying); keep the old cursors to retry + // the same pages under the new generation instead of + // skipping. Idempotent retry; linearizable cursors. + round_after = round_next; + payout_after = payout_next; if !round_more && !payout_more { break Ok(()); } From 560310f9158a9d07dce4139474d5e47dcabd224b Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:34:56 +0900 Subject: [PATCH 54/61] fix(pool-ledger): cap payout batches by serialized output weight The count cap alone admits 2,500 P2TR/P2WSH outputs (~430,000 WU) before funding inputs and signatures, exceeding the 400,000-WU standard limit. Track the serialized-output weight ((8 + varint + script) * 4) while reserving and stop before exceeding 400,000 WU minus funding headroom. --- crates/mining/src/pool/ledger.rs | 28 +++++ crates/mining/src/pool/ledger/store.rs | 22 +++- .../mining/tests/support/pool_ledger_cases.rs | 112 ++++++++++++++++++ 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/crates/mining/src/pool/ledger.rs b/crates/mining/src/pool/ledger.rs index 33275bc384..8f1cda9601 100644 --- a/crates/mining/src/pool/ledger.rs +++ b/crates/mining/src/pool/ledger.rs @@ -46,6 +46,14 @@ pub const MAX_SCRIPT_LEN: usize = 10_000; pub const MAX_RAW_TX_BYTES: usize = 4_000_000; /// Largest number of outputs in one payout batch. pub const MAX_BATCH_OUTPUTS: usize = 2_500; +/// Maximum weight of a standard transaction (400 000 weight units). +pub const MAX_STANDARD_TX_WEIGHT_WU: u64 = 400_000; +/// Weight reserved for funding inputs, witness, transaction overhead and one +/// change output, so a batch of outputs alone can still be funded standardly. +pub const PAYOUT_TX_HEADROOM_WU: u64 = 10_000; +/// Largest aggregate serialized-output weight a payout batch may reserve: +/// `MAX_STANDARD_TX_WEIGHT_WU - PAYOUT_TX_HEADROOM_WU`. +pub const MAX_PAYOUT_OUTPUT_WEIGHT_WU: u64 = MAX_STANDARD_TX_WEIGHT_WU - PAYOUT_TX_HEADROOM_WU; /// Number of non-batch (change) outputs a signed payout transaction may carry. pub const MAX_CHANGE_OUTPUTS: usize = 1; /// Largest number of allowed change scripts in a pool configuration. @@ -912,6 +920,26 @@ pub struct PayoutRequest { pub allowed_change_scripts: Vec>, } +/// Serialized weight of one payout output: `(8 + varint(script_len) + script_len) * 4`. +#[must_use] +pub fn payout_output_weight_wu(script_len: usize) -> u64 { + let prefix = if script_len <= 0xfc { + 1u64 + } else if script_len <= 0xffff { + 3u64 + } else if script_len <= 0xffff_ffff { + 5u64 + } else { + 9u64 + }; + let len = u64::try_from(script_len).unwrap_or(u64::MAX); + let serialized = prefix + .checked_add(len) + .and_then(|sum| sum.checked_add(8)) + .unwrap_or(u64::MAX); + serialized.checked_mul(4).unwrap_or(u64::MAX) +} + fn check_outputs(outputs: &[PayoutOutput]) -> Result { if outputs.is_empty() || outputs.len() > MAX_BATCH_OUTPUTS { return Err(LedgerError::InvalidInput( diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index c150565476..d60b3c2c8e 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -22,11 +22,12 @@ use super::codec::{Dec, Enc}; use super::{ AuditKind, AuditRecord, Balance, BatchState, BatchTransition, BlockFound, BlockRecorded, CancelBasis, ChainView, Closure, Confirmation, ExternalSigner, Hash32, LedgerError, - MAX_BATCH_OUTPUTS, MAX_MONEY_SAT, MAX_SCRIPT_LEN, MAX_SHARE_TUPLE_LEN, MinerId, - ObservedCoinbase, OutPoint, PayoutBatch, PayoutOutput, PayoutPolicy, PayoutReceipt, - PayoutRequest, PoolConfig, ReceiptError, Round, RoundState, RoundTransition, ShareCredit, - ShareReceipt, SignedTx, TxStatus, WindowAccumulator, compute_batch_id, parse_transaction, - split_pool_fee, split_reward, verify_payout_transaction, + MAX_BATCH_OUTPUTS, MAX_MONEY_SAT, MAX_PAYOUT_OUTPUT_WEIGHT_WU, MAX_SCRIPT_LEN, + MAX_SHARE_TUPLE_LEN, MinerId, ObservedCoinbase, OutPoint, PayoutBatch, PayoutOutput, + PayoutPolicy, PayoutReceipt, PayoutRequest, PoolConfig, ReceiptError, Round, RoundState, + RoundTransition, ShareCredit, ShareReceipt, SignedTx, TxStatus, WindowAccumulator, + compute_batch_id, parse_transaction, payout_output_weight_wu, split_pool_fee, split_reward, + verify_payout_transaction, }; /// Most signed transactions (RBF replacements) recorded for one batch. @@ -1088,6 +1089,7 @@ impl Ledger { return Ok(Some(batch)); } let mut picks: Vec = Vec::new(); + let mut picks_weight_wu: u64 = 0; for item in tables.balances.rows_iter()? { let (key, value) = item?; let balance = decode_balance(value.value())?; @@ -1099,6 +1101,16 @@ impl Ledger { Some(guard) => guard.value().to_vec(), None => continue, }; + // Serialized-output weight budget with funding headroom: the + // count cap alone admits 2,500 P2TR/P2WSH outputs (~430,000 WU) + // before funding inputs and signatures, exceeding the 400,000-WU + // standard limit. Stop before exceeding the budget so any signed + // funding of the reserved outputs stays standard. + let weight = payout_output_weight_wu(script.len()); + if picks_weight_wu.saturating_add(weight) > MAX_PAYOUT_OUTPUT_WEIGHT_WU { + break; + } + picks_weight_wu = picks_weight_wu.saturating_add(weight); picks.push(PayoutOutput { account, script_pubkey: script, diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index 55f40c4325..e39d9e4a5f 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -3407,3 +3407,115 @@ fn confirmed_payout_with_shallow_depth_reverts_and_restores_reservation() { assert_eq!(reserved_total, total, "revert must restore the reservation"); fund.ledger.check_invariants().fixture("conservation"); } +#[test] +fn prepare_caps_outputs_by_serialized_weight_before_count() { + // The count cap alone admits 2,500 P2TR/P2WSH outputs (~430,000 WU) before + // funding inputs and signatures, exceeding the 400,000-WU standard limit. + // The serialized-output weight budget (with funding headroom) must truncate + // the batch first. + assert_eq!( + payout_output_weight_wu(34), + 172, + "a P2TR/P2WSH output weighs 43 bytes * 4" + ); + assert!( + 2_500u64.saturating_mul(172) > MAX_PAYOUT_OUTPUT_WEIGHT_WU, + "the count cap alone would exceed the output weight budget" + ); + let dir = tempfile::tempdir().fixture("tempdir"); + let path = ledger_path(&dir); + let mut cfg = config(1_000_000, 0); + cfg.payout_confirmations = 2; + cfg.abandon_confirmations = 2; + cfg.reorg_watch_depth = 10; + let ledger = create(&path, cfg).fixture("create"); + const ACCOUNTS: u64 = 2_400; + for account in 1..=ACCOUNTS { + let mut seed = Vec::new(); + seed.extend_from_slice(&1u64.to_be_bytes()); + seed.extend_from_slice(&account.to_be_bytes()); + seed.extend_from_slice(&1u32.to_be_bytes()); + let credit = ShareCredit { + header_hash: sha256(&seed), + job: 1, + account: MinerId(account), + extranonce: account.to_le_bytes().to_vec(), + nonce: u32::try_from(account).fixture("small nonce"), + version: 1, + ntime: 1_700_000_000, + work: 1, + }; + ledger.record_share(&credit).fixture("share"); + } + let solving = ShareCredit { + header_hash: sha256(b"solve"), + job: 1, + account: MinerId(ACCOUNTS + 1), + extranonce: b"solve".to_vec(), + nonce: 0, + version: 1, + ntime: 1_700_000_000, + work: 1, + }; + let block = BlockFound { + share: solving, + height: 100, + coinbase_txid: sha256(b"coinbase"), + claimed_coinbase_value_sat: 5_000_000, + }; + ledger.record_found_block(&block).fixture("block"); + let chain = FakeChain::default(); + chain.set_block(100, block.share.header_hash); + chain.set_coinbase( + block.share.header_hash, + ObservedCoinbase { + txid: block.coinbase_txid, + outputs: vec![TxOut { + value_sat: 5_000_000, + script_pubkey: pool_script(), + }], + }, + ); + chain.set_tip(99); + for account in 1..=ACCOUNTS { + let mut script = vec![0x51u8, 0x20]; + let mut key = [0u8; 32]; + key[..8].copy_from_slice(&account.to_le_bytes()); + script.extend_from_slice(&key); + assert_eq!(script.len(), 34); + ledger + .set_payout_script(MinerId(account), &script) + .fixture("script"); + } + chain.set_tip(102); + ledger.reconcile_rounds(&chain).fixture("mature"); + let batch = ledger + .prepare_payout_batch( + idem(1), + PayoutPolicy { + min_payout_sat: 546, + max_outputs: MAX_BATCH_OUTPUTS, + }, + ) + .fixture("prepare") + .fixture("a batch"); + let weight: u64 = batch + .outputs + .iter() + .map(|output| payout_output_weight_wu(output.script_pubkey.len())) + .sum(); + assert!( + batch.outputs.len() < MAX_BATCH_OUTPUTS, + "weight budget must bind before the 2,500-output count cap" + ); + assert!( + weight <= MAX_PAYOUT_OUTPUT_WEIGHT_WU, + "reserved outputs must leave funding headroom: {weight} > {}", + MAX_PAYOUT_OUTPUT_WEIGHT_WU + ); + assert!( + weight.saturating_add(payout_output_weight_wu(34)) > MAX_PAYOUT_OUTPUT_WEIGHT_WU, + "the next 34-byte output must not fit the budget" + ); + ledger.check_invariants().fixture("invariants"); +} From 9537779711d6cee11ed0daa097864f761493b997 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:37:01 +0900 Subject: [PATCH 55/61] fix(pool-ledger): confirm replacement payouts without spend index With txindex but no script index, a confirmed replacement payout aborted reconciliation: find_dead_spend errored on the original transaction after it left the mempool. Collect transaction confirmation evidence first and skip spend observations when one batch transaction conclusively confirms, so missing abandonment evidence never blocks authoritative confirmation. --- crates/mining/src/pool/ledger/store.rs | 18 +++- .../mining/tests/support/pool_ledger_cases.rs | 93 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index d60b3c2c8e..ea2cce8f33 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -2437,7 +2437,10 @@ fn observe_batch( batch: &PayoutBatch, tip: u32, ) -> Result { - let mut txs = Vec::with_capacity(batch.transactions.len()); + // Phase one: authoritative transaction confirmation evidence. Spend observations + // (abandonment evidence) require a script index and must not block confirmation + // when the transaction evidence alone is conclusive. + let mut confirmed = Vec::with_capacity(batch.transactions.len()); for tx in &batch.transactions { let status = chain.tx_status(&tx.txid)?; let (canonical, depth) = match status { @@ -2447,8 +2450,19 @@ fn observe_batch( ), _ => (false, 0), }; + confirmed.push((tx, status, canonical, depth)); + } + let conclusive = confirmed.iter().any(|(_, _, canonical, depth)| { + *canonical && *depth >= u64::from(config.payout_confirmations) + }); + let mut txs = Vec::with_capacity(batch.transactions.len()); + for (tx, status, canonical, depth) in confirmed { let confirmed_here = canonical && matches!(status, TxStatus::Confirmed { .. }); - let dead_by = if confirmed_here || status == TxStatus::InMempool { + // When one transaction conclusively confirms the payout, skip spend-index + // lookups entirely: missing abandonment evidence (e.g. no script index + // after the replaced transaction left the mempool) must not abort the + // confirmation. Only without conclusive confirmation do we need spends. + let dead_by = if conclusive || confirmed_here || status == TxStatus::InMempool { None } else { find_dead_spend(chain, config, batch, tx, tip)? diff --git a/crates/mining/tests/support/pool_ledger_cases.rs b/crates/mining/tests/support/pool_ledger_cases.rs index e39d9e4a5f..607b6b9ad0 100644 --- a/crates/mining/tests/support/pool_ledger_cases.rs +++ b/crates/mining/tests/support/pool_ledger_cases.rs @@ -3407,6 +3407,99 @@ fn confirmed_payout_with_shallow_depth_reverts_and_restores_reservation() { assert_eq!(reserved_total, total, "revert must restore the reservation"); fund.ledger.check_invariants().fixture("conservation"); } + +/// A chain observer with a transaction index but no script index: transaction +/// positions answer, spend lookups fail. Mirrors the node observer when +/// `txindex` is enabled without the spend index. +struct TxOnlyChain<'a> { + inner: &'a FakeChain, +} + +impl ChainView for TxOnlyChain<'_> { + fn tip_height(&self) -> Result { + self.inner.tip_height() + } + fn block_location( + &self, + block_hash: &Hash32, + claimed_height: u32, + ) -> Result { + self.inner.block_location(block_hash, claimed_height) + } + fn coinbase_of( + &self, + block_hash: &Hash32, + ) -> Result, ChainViewError> { + self.inner.coinbase_of(block_hash) + } + fn tx_status(&self, txid: &Hash32) -> Result { + self.inner.tx_status(txid) + } + fn confirmed_spender( + &self, + _outpoint: &OutPoint, + ) -> Result, ChainViewError> { + Err(ChainViewError( + "payout spend observation requires script index".into(), + )) + } +} + +#[test] +fn confirmed_replacement_confirms_without_spend_index() { + // With txindex but no script index, a confirmed replacement payout must not + // abort reconciliation: the original transaction has left the mempool + // (`Unknown`), so its spend lookup would fail, but the replacement's + // conclusive confirmation is authoritative on its own. Spend observations + // are only required when no batch transaction conclusively confirms. + let fund = funded(0, 1_000_001); + let batch = prepared(&fund); + let first = pay_tx( + &batch, + &[outpoint(0xA1, 0), outpoint(0xA2, 0)], + Some(40_000), + ); + fund.ledger + .record_signed(&receipt(&batch, &first)) + .fixture("first"); + let replacement = pay_tx( + &batch, + &[outpoint(0xA2, 0), outpoint(0xA4, 0)], + Some(30_000), + ); + fund.ledger + .record_signed(&receipt(&batch, &replacement)) + .fixture("replacement"); + fund.chain.set_tx(replacement.txid, TxStatus::InMempool); + fund.ledger + .record_broadcast(&batch.id, &replacement.txid, &fund.chain) + .fixture("broadcast replacement"); + // Depth 2 meets `payout_confirmations = 2`; the original is `Unknown`. + confirm_tx(&fund, replacement.txid, 0xB2, 102, 103); + let tx_only = TxOnlyChain { inner: &fund.chain }; + let transitions = fund.ledger.reconcile_payouts(&tx_only).fixture("confirm"); + assert_eq!( + transitions, + vec![BatchTransition { + batch_id: batch.id, + from: BatchState::Broadcast, + to: BatchState::Confirmed, + }], + "authoritative confirmation must win over missing abandonment evidence" + ); + let confirmed = fund + .ledger + .batch(&batch.id) + .fixture("batch") + .fixture("exists"); + assert_eq!(confirmed.state, BatchState::Confirmed); + assert_eq!( + confirmed.confirmation.fixture("confirmation").txid, + replacement.txid + ); + fund.ledger.check_invariants().fixture("invariants"); +} + #[test] fn prepare_caps_outputs_by_serialized_weight_before_count() { // The count cap alone admits 2,500 P2TR/P2WSH outputs (~430,000 WU) before From 5c357be699dea533fc73f7dc70f5f6ec2f6cc2ce Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:12 +0900 Subject: [PATCH 56/61] style: format workspace for CI Apply rustfmt to the branch so cargo fmt --all -- --check matches the CI formatter. No behavior changes. --- crates/chain/src/header_sync.rs | 2 +- crates/index/src/portable/chain.rs | 10 ++-- crates/index/src/runtime/tests/seed_paused.rs | 9 ++-- crates/mempool/src/submit_package.rs | 8 +-- crates/mining/src/pool/job.rs | 4 +- crates/mining/src/pool/ledger/kv.rs | 7 +-- crates/mining/src/pool/ledger/store.rs | 19 +++---- crates/mining/src/pool/stratum/sv1/session.rs | 2 +- crates/mining/src/pool/stratum/sv2/session.rs | 23 +++++---- crates/mining/tests/pool_jobs.rs | 31 ++++++++---- crates/mining/tests/pool_sv1.rs | 14 +++--- crates/mining/tests/pool_sv2.rs | 24 ++++----- crates/node/src/lib.rs | 4 +- crates/node/src/pool/ledger.rs | 28 +++++------ crates/node/src/pool/sv2.rs | 49 +++++++++---------- crates/node/src/pool/tests.rs | 23 +++------ crates/rpc/src/handlers/chain.rs | 2 +- crates/rpc/tests/policy_contract.rs | 4 +- 18 files changed, 126 insertions(+), 137 deletions(-) diff --git a/crates/chain/src/header_sync.rs b/crates/chain/src/header_sync.rs index a4cc8df716..e7f2dab014 100644 --- a/crates/chain/src/header_sync.rs +++ b/crates/chain/src/header_sync.rs @@ -1,8 +1,8 @@ use bitcoin_rs_consensus::MAX_TIMEWARP; use bitcoin_rs_primitives::{CompactTarget, Hash256, Network}; -pub use pow::{compact_is_met_by, compact_to_target}; use pow::target_to_compact; +pub use pow::{compact_is_met_by, compact_to_target}; use crate::{ ChainError, diff --git a/crates/index/src/portable/chain.rs b/crates/index/src/portable/chain.rs index 8b0c7ee63d..43f10e1a74 100644 --- a/crates/index/src/portable/chain.rs +++ b/crates/index/src/portable/chain.rs @@ -10,7 +10,6 @@ use super::artifact::CANCEL_POLL_RECORDS; use super::{IndexBundleError, Withheld}; -use std::collections::{HashMap, VecDeque}; use crate::types::TxPosition; use bitcoin_rs_chain::{ BlockBodySource, BlockTreeReader, ChainTxCount, ChainWork, NodeId, NodeStatus, TipSnapshot, @@ -18,6 +17,7 @@ use bitcoin_rs_chain::{ use bitcoin_rs_primitives::{ BlockHash, Hash256, Header, Tx, deserialize, layout::ParsedBlock, varint, }; +use std::collections::{HashMap, VecDeque}; /// The applied-tip ancestry up to a bundle base, with the base's identity. /// @@ -402,12 +402,11 @@ impl<'a> BodyReader<'a> { .source .block_body_metadata(height, BlockHash::from(hash)) { - let count = u64::try_from(metadata.tx_count).map_err(|_| { - IndexBundleError::BodyRead { + let count = + u64::try_from(metadata.tx_count).map_err(|_| IndexBundleError::BodyRead { height, reason: "the transaction count does not fit u64".to_owned(), - } - })?; + })?; self.counts.insert((height, hash), count); return Ok(Some(count)); } @@ -479,7 +478,6 @@ impl<'a> BodyReader<'a> { } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/index/src/runtime/tests/seed_paused.rs b/crates/index/src/runtime/tests/seed_paused.rs index 172b4134d5..d11a48eed7 100644 --- a/crates/index/src/runtime/tests/seed_paused.rs +++ b/crates/index/src/runtime/tests/seed_paused.rs @@ -1087,11 +1087,10 @@ fn seed_mutation_check_digest_removal_fails() { base: tip_hash.to_le_bytes(), }, ); - let result = - artifact.for_each_coin_raw( - &mut |_: &OutPoint, _: &[u8]| Ok::<(), StorageError>(()), - &|| false, - ); + let result = artifact.for_each_coin_raw( + &mut |_: &OutPoint, _: &[u8]| Ok::<(), StorageError>(()), + &|| false, + ); assert!( matches!(result, Err(bitcoin_rs_utxo::RawSeedError::Integrity)), "only the digest comparison refuses a length-preserving flip" diff --git a/crates/mempool/src/submit_package.rs b/crates/mempool/src/submit_package.rs index 73a968756e..9229825f32 100644 --- a/crates/mempool/src/submit_package.rs +++ b/crates/mempool/src/submit_package.rs @@ -321,13 +321,7 @@ impl MempoolGateway { chain: &dyn AdmissionChain, ) -> Result { crate::package::check_structure(txs)?; - self.submit_package_atomic( - txs, - AdmissionOrigin::from(origin), - max_rate, - time, - chain, - ) + self.submit_package_atomic(txs, AdmissionOrigin::from(origin), max_rate, time, chain) } /// Atomically submits a BIP331 ancestor package relayed by `source`: the diff --git a/crates/mining/src/pool/job.rs b/crates/mining/src/pool/job.rs index fa9d0185d9..e2e6e43cb6 100644 --- a/crates/mining/src/pool/job.rs +++ b/crates/mining/src/pool/job.rs @@ -19,13 +19,13 @@ use std::collections::{HashMap, VecDeque}; use std::sync::Arc; -use bitcoin_rs_chain::{ChainWork, compact_is_met_by}; -use bitcoin_rs_consensus::transaction_sigop_cost; use crate::{ BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CoinbaseBudget, MiningControl, MiningControlError, MiningError, STALE_PARENT_REASON, }; +use bitcoin_rs_chain::{ChainWork, compact_is_met_by}; +use bitcoin_rs_consensus::transaction_sigop_cost; use bitcoin_rs_primitives::{ Amount, Block, BlockHash, BlockPayload, CompactTarget, Hash256, Header, Script, Tx, TxOut, Txid, Witness, diff --git a/crates/mining/src/pool/ledger/kv.rs b/crates/mining/src/pool/ledger/kv.rs index a7e69c60cd..5d416bfa12 100644 --- a/crates/mining/src/pool/ledger/kv.rs +++ b/crates/mining/src/pool/ledger/kv.rs @@ -531,9 +531,10 @@ impl Table<'_, u64, V> { } } let logical = full_key[self.prefix.len()..].to_vec(); - let key_bytes: [u8; 8] = logical.as_slice().try_into().map_err(|_| { - LedgerError::Invariant("non-u64 key in u64 table page".into()) - })?; + let key_bytes: [u8; 8] = logical + .as_slice() + .try_into() + .map_err(|_| LedgerError::Invariant("non-u64 key in u64 table page".into()))?; let key = u64::from_be_bytes(key_bytes); if let Some(cursor) = after && key <= cursor diff --git a/crates/mining/src/pool/ledger/store.rs b/crates/mining/src/pool/ledger/store.rs index ea2cce8f33..a8b20f2f6a 100644 --- a/crates/mining/src/pool/ledger/store.rs +++ b/crates/mining/src/pool/ledger/store.rs @@ -521,9 +521,8 @@ impl Ledger { .ok_or(LedgerError::Overflow("count"))? .min(boundary); let mut doomed: Vec = Vec::new(); - doomed.reserve( - usize::try_from(batch_end - start).unwrap_or(Self::PRUNE_BATCH_SIZE), - ); + doomed + .reserve(usize::try_from(batch_end - start).unwrap_or(Self::PRUNE_BATCH_SIZE)); for seq in start..batch_end { if tables.shares.get(seq)?.is_some() { doomed.push(seq); @@ -549,8 +548,8 @@ impl Ledger { for seq in &doomed { tables.shares.remove(*seq)?; } - let batch = u64::try_from(doomed.len()) - .map_err(|_| LedgerError::Overflow("count"))?; + let batch = + u64::try_from(doomed.len()).map_err(|_| LedgerError::Overflow("count"))?; if floor > previous { tables.meta_u64.insert(M_PRUNED_BELOW, floor)?; } @@ -655,9 +654,8 @@ impl Ledger { let txn = self.db.begin_write()?; let tables = Tables::open(&txn)?; if let Some(id) = round_id_for_block(&tables.round_by_block, &block_hash)? { - let round = load_round(&tables.rounds, id)?.ok_or_else(|| { - LedgerError::Corrupt("round index points at no round".into()) - })?; + let round = load_round(&tables.rounds, id)? + .ok_or_else(|| LedgerError::Corrupt("round index points at no round".into()))?; Self::check_round_claim(&round, found)?; let seq = round.solving_seq; return Ok(BlockRecorded { @@ -698,9 +696,8 @@ impl Ledger { let recorded = { let tables = Tables::open(&txn)?; if let Some(id) = round_id_for_block(&tables.round_by_block, &block_hash)? { - let round = load_round(&tables.rounds, id)?.ok_or_else(|| { - LedgerError::Corrupt("round index points at no round".into()) - })?; + let round = load_round(&tables.rounds, id)? + .ok_or_else(|| LedgerError::Corrupt("round index points at no round".into()))?; Self::check_round_claim(&round, found)?; let seq = round.solving_seq; return Ok(BlockRecorded { diff --git a/crates/mining/src/pool/stratum/sv1/session.rs b/crates/mining/src/pool/stratum/sv1/session.rs index 25753be2df..9a031053bb 100644 --- a/crates/mining/src/pool/stratum/sv1/session.rs +++ b/crates/mining/src/pool/stratum/sv1/session.rs @@ -35,11 +35,11 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; +use crate::pool::core::{Completion, Credited, Lease, ShareRequest, SubmitFailure}; use crate::pool::{ BlockOutcome, JobId, PoolCore, SessionId, ShareClass, ShareError, ShareTarget, StaleReason, WorkerIdentity, }; -use crate::pool::core::{Completion, Credited, Lease, ShareRequest, SubmitFailure}; use bitcoin_rs_primitives::BlockHash; use super::{ diff --git a/crates/mining/src/pool/stratum/sv2/session.rs b/crates/mining/src/pool/stratum/sv2/session.rs index 7613d42b38..aa3bd71bdd 100644 --- a/crates/mining/src/pool/stratum/sv2/session.rs +++ b/crates/mining/src/pool/stratum/sv2/session.rs @@ -38,12 +38,12 @@ use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant}; -use binary_sv2::{B032, B064K, Decodable, Encodable, GetSize, Seq0255, Str0255, Sv2Option, U256}; -use crate::{MiningControl, MiningControlError}; use crate::pool::{ AcceptedShare, BlockReceipt, Extranonce, FoundBlock, Job, JobBook, JobError, JobId, Session, SessionId, ShareError, ShareOutcome, ShareSubmission, ShareTarget, }; +use crate::{MiningControl, MiningControlError}; +use binary_sv2::{B032, B064K, Decodable, Encodable, GetSize, Seq0255, Str0255, Sv2Option, U256}; use bitcoin_rs_primitives::{BlockHash, Hash256}; use common_messages_sv2::{ CHANNEL_BIT_RECONNECT, CHANNEL_BIT_SETUP_CONNECTION, CHANNEL_BIT_SETUP_CONNECTION_ERROR, @@ -1398,10 +1398,7 @@ impl ProtocolSession { /// previous hash. Connections that did not set /// `REQUIRES_STANDARD_JOBS` receive one group-addressed /// `NewExtendedMiningJob`; the others, one job per channel. - pub fn announce( - &mut self, - installed: &crate::pool::Installed, - ) -> Result<(), SessionError> { + pub fn announce(&mut self, installed: &crate::pool::Installed) -> Result<(), SessionError> { // Delivery is driven by per-connection channel state, not the // owner's `fresh` flag: a channel opened after the install (or one // whose `SetNewPrevHash` never landed) still needs the current job @@ -1921,8 +1918,11 @@ impl ProtocolSession { // Queue before removing the ticket: a full outbound queue must leave // the pending acknowledgement in place for a later retry instead of // dropping a share the ledger already recorded. - let (channel_id, sequence_number, difficulty) = - (pending.channel_id, pending.sequence_number, pending.difficulty); + let (channel_id, sequence_number, difficulty) = ( + pending.channel_id, + pending.sequence_number, + pending.difficulty, + ); if !self.channels.contains_key(&channel_id) { self.pending.remove(&ticket.0); return Ok(false); @@ -1960,8 +1960,11 @@ impl ProtocolSession { // Queue before removing the ticket: a full outbound queue must leave // the pending acknowledgement in place for a later retry instead of // dropping a block the ledger already recorded. - let (channel_id, sequence_number, difficulty) = - (pending.channel_id, pending.sequence_number, pending.difficulty); + let (channel_id, sequence_number, difficulty) = ( + pending.channel_id, + pending.sequence_number, + pending.difficulty, + ); if !self.channels.contains_key(&channel_id) { self.pending.remove(&submission.ticket.0); return Ok(false); diff --git a/crates/mining/tests/pool_jobs.rs b/crates/mining/tests/pool_jobs.rs index 2dbe9de3db..21ad8646d8 100644 --- a/crates/mining/tests/pool_jobs.rs +++ b/crates/mining/tests/pool_jobs.rs @@ -19,6 +19,11 @@ use bitcoin_rs_mempool::{ AdmissionChain, AdmissionOrigin, ChainAdmissionSnapshot, Mempool, MempoolGateway, MempoolLimits, SubmitOutcome, }; +use bitcoin_rs_mining::pool::{ + BIP320_VERSION_MASK, BlockStatus, Extranonce, ExtranonceLayout, Job, JobBook, JobBookConfig, + JobError, JobId, Session, SessionId, ShareClass, ShareError, ShareOutcome, ShareSubmission, + ShareTarget, ShareWork, StaleReason, fetch_template, +}; use bitcoin_rs_mining::{ BlockTemplate, BlockTemplateMode, BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CandidateContext, CoinbaseBudget, GenerateRequest, @@ -29,11 +34,6 @@ use bitcoin_rs_primitives::{ Script, Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::consensus_bytes, }; use bitcoin_rs_script::VerifyFlags; -use bitcoin_rs_mining::pool::{ - BIP320_VERSION_MASK, BlockStatus, Extranonce, ExtranonceLayout, Job, JobBook, JobBookConfig, - JobError, JobId, Session, SessionId, ShareClass, ShareError, ShareOutcome, ShareSubmission, - ShareTarget, ShareWork, StaleReason, fetch_template, -}; use compact_str::CompactString; use parking_lot::RwLock; use sha2::{Digest, Sha256}; @@ -435,20 +435,31 @@ fn unburn_releases_duplicate_key_for_retry() -> TestResult { let mut fx = fixture(config(), 100, 2)?; let target = easy()?; let job = Arc::clone(&fx.job); - let first = fx.book.validate(&submit_for(&fx.session, &job, 41), &target)?; + let first = fx + .book + .validate(&submit_for(&fx.session, &job, 41), &target)?; let hash = first.share().hash; - let replay = fx.book.validate(&submit_for(&fx.session, &job, 41), &target); + let replay = fx + .book + .validate(&submit_for(&fx.session, &job, 41), &target); assert!( matches!(replay, Err(ShareError::Duplicate { first_job, .. }) if first_job == job.id()) ); // Unknown keys release nothing. - assert!(!fx.book.unburn(&BlockHash::from(Hash256::from_le_bytes(&[0xcc; 32])))); + assert!( + !fx.book + .unburn(&BlockHash::from(Hash256::from_le_bytes(&[0xcc; 32]))) + ); // The burned key releases once; a retry then validates fresh. assert!(fx.book.unburn(&hash)); assert!(!fx.book.unburn(&hash)); - let retry = fx.book.validate(&submit_for(&fx.session, &job, 41), &target)?; + let retry = fx + .book + .validate(&submit_for(&fx.session, &job, 41), &target)?; assert_eq!(retry.share().hash, hash); - let replay = fx.book.validate(&submit_for(&fx.session, &job, 41), &target); + let replay = fx + .book + .validate(&submit_for(&fx.session, &job, 41), &target); assert!( matches!(replay, Err(ShareError::Duplicate { first_job, .. }) if first_job == job.id()) ); diff --git a/crates/mining/tests/pool_sv1.rs b/crates/mining/tests/pool_sv1.rs index f89747e452..39018f1df6 100644 --- a/crates/mining/tests/pool_sv1.rs +++ b/crates/mining/tests/pool_sv1.rs @@ -19,13 +19,6 @@ use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; use bitcoin_rs_chain::compact_is_met_by; -use bitcoin_rs_mining::{ - BlockValidationResult, Candidate, CoinbaseBudget, FakeMiningControl, MiningControl, - MiningControlError, STALE_PARENT_REASON, TemplateId, -}; -use bitcoin_rs_primitives::{ - Amount, CompactTarget, Hash256, LockTime, OutPoint, Script, Sequence, Tx, TxIn, TxOut, Witness, -}; use bitcoin_rs_mining::pool::stratum::sv1::{ ConnectionLimiter, DifficultyPolicy, FrameError, LineBuffer, Observer, RefusalReason, Server, ServerConfig, ServerDeps, ServerEvent, StaticAccountResolver, prevhash_from_wire, @@ -35,6 +28,13 @@ use bitcoin_rs_mining::pool::{ AcceptedShare, Extranonce, ExtranonceLayout, FoundBlock, Job, JobBookConfig, PoolCore, RecordOutcome, ShareLedger, }; +use bitcoin_rs_mining::{ + BlockValidationResult, Candidate, CoinbaseBudget, FakeMiningControl, MiningControl, + MiningControlError, STALE_PARENT_REASON, TemplateId, +}; +use bitcoin_rs_primitives::{ + Amount, CompactTarget, Hash256, LockTime, OutPoint, Script, Sequence, Tx, TxIn, TxOut, Witness, +}; type TestResult = Result<(), Box>; diff --git a/crates/mining/tests/pool_sv2.rs b/crates/mining/tests/pool_sv2.rs index a44b459989..7046604ae5 100644 --- a/crates/mining/tests/pool_sv2.rs +++ b/crates/mining/tests/pool_sv2.rs @@ -23,6 +23,16 @@ use bitcoin_rs_mempool::{ AdmissionChain, AdmissionOrigin, ChainAdmissionSnapshot, Mempool, MempoolGateway, MempoolLimits, SubmitOutcome, }; +use bitcoin_rs_mining::pool::stratum::sv2::{ + AuthorityKeypair, CloseReason, Connection, Error, Event, FLAG_REQUIRES_STANDARD_JOBS, + FLAG_REQUIRES_VERSION_ROLLING, FLAG_REQUIRES_WORK_SELECTION, Limits, ROLES, RoleStatus, + SUCCESS_FLAG_REQUIRES_FIXED_VERSION, ServerConfig, SessionError, TransportError, + target_difficulty, +}; +use bitcoin_rs_mining::pool::{ + BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Installed, Job, JobBook, JobBookConfig, + JobId, ShareClass, ShareError, ShareTarget, +}; use bitcoin_rs_mining::{ BlockTemplateRequest, BlockTemplateResult, BlockValidationResult, Candidate, CandidateContext, CoinbaseBudget, GenerateRequest, GeneratedBlock, MiningControl, MiningControlError, MiningInfo, @@ -33,16 +43,6 @@ use bitcoin_rs_primitives::{ Sequence, Tx, TxIn, TxOut, Txid, Witness, encode::double_sha256, }; use bitcoin_rs_script::VerifyFlags; -use bitcoin_rs_mining::pool::{ - BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Installed, Job, JobBook, JobBookConfig, - JobId, ShareClass, ShareError, ShareTarget, -}; -use bitcoin_rs_mining::pool::stratum::sv2::{ - AuthorityKeypair, CloseReason, Connection, Error, Event, FLAG_REQUIRES_STANDARD_JOBS, - FLAG_REQUIRES_VERSION_ROLLING, FLAG_REQUIRES_WORK_SELECTION, Limits, ROLES, RoleStatus, - SUCCESS_FLAG_REQUIRES_FIXED_VERSION, ServerConfig, SessionError, TransportError, - target_difficulty, -}; use codec_sv2::{ Decoded, Decrypted, Handshake, InitiatorSent, MessageFrame, NoiseDecoder, NoiseEncoder, TransportDecryptState, TransportEncryptState, @@ -2754,8 +2754,8 @@ fn active_refresh_upgrades_a_channel_that_missed_its_parent_to_future() { .unwrap_or_else(|error| panic!("book: {error:?}")); let mut cfg = server_config(); cfg.limits.max_outbound_frames = 3; - let mut session = ProtocolSession::new(cfg, &book) - .unwrap_or_else(|error| panic!("session: {error:?}")); + let mut session = + ProtocolSession::new(cfg, &book).unwrap_or_else(|error| panic!("session: {error:?}")); let setup = SetupConnection { protocol: Protocol::MiningProtocol, min_version: 2, diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index d1a0093ac6..28724dbd98 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -27,11 +27,11 @@ mod lifecycle; pub mod metrics; /// Node-owned mining candidate lifecycle coordinator. pub mod mining; +/// Foreground offline import for the strict full-validation comparator. +pub mod offline_import; /// Native Stratum pool service and external-signer accounting boundary. #[cfg(feature = "pool")] mod pool; -/// Foreground offline import for the strict full-validation comparator. -pub mod offline_import; /// Node-owned effects around authoritative chainstate reorgs. pub mod reorg; /// Top-level node runner. diff --git a/crates/node/src/pool/ledger.rs b/crates/node/src/pool/ledger.rs index e1e2965865..def999ee60 100644 --- a/crates/node/src/pool/ledger.rs +++ b/crates/node/src/pool/ledger.rs @@ -7,9 +7,6 @@ use std::sync::{ use bitcoin_rs_chainstate::Chainstate; use bitcoin_rs_index::{DerivedIndexQuery, ScriptIndexQuery}; use bitcoin_rs_mempool::MempoolReader; -use bitcoin_rs_primitives::encode::deserialize; -use bitcoin_rs_primitives::{Block, Hash256, OutPoint as NodeOutPoint, Txid}; -use bitcoin_rs_storage::block_body::BlockBodyStore; use bitcoin_rs_mining::pool::ledger as pool_ledger; use bitcoin_rs_mining::pool::ledger::{ BlockFound as LedgerBlockFound, BlockLocation, ChainView, ChainViewError, ConfirmedSpend, @@ -21,6 +18,9 @@ use bitcoin_rs_mining::pool::{ AcceptedShare, BlockReceipt, BlockStatus, FoundBlock, LedgerUnavailable, RecordOutcome, ShareLedger, WorkerIdentity, }; +use bitcoin_rs_primitives::encode::deserialize; +use bitcoin_rs_primitives::{Block, Hash256, OutPoint as NodeOutPoint, Txid}; +use bitcoin_rs_storage::block_body::BlockBodyStore; /// Node-backed observation of the fully applied chain. /// @@ -384,9 +384,7 @@ impl Drop for QueuedRecoveryHold { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); pending.reserved = pending.reserved.saturating_sub(1); - pending.bytes = pending - .bytes - .saturating_sub(MAX_BLOCK_RECORD_BYTES); + pending.bytes = pending.bytes.saturating_sub(MAX_BLOCK_RECORD_BYTES); } } } @@ -492,10 +490,7 @@ impl BlockRecordPermit { token: Arc::new(()), }); } - let hold = QueuedRecoveryHold { - host, - state, - }; + let hold = QueuedRecoveryHold { host, state }; return ( BlockRecordOutcome::Queued, Some(DeferredCharge::Queued(hold)), @@ -781,13 +776,12 @@ impl LedgerHost { let aborted = || !tip_unchanged(&before, &chain.tip_generation()); let result: Result<(bool, bool, Option, Option), pool_ledger::LedgerError> = (|| { - let (_, round_more, round_next) = - self.ledger.reconcile_rounds_checked_limit( - &observation, - &aborted, - pool_ledger::RECONCILE_ROUND_BATCH_LIMIT, - round_after, - )?; + let (_, round_more, round_next) = self.ledger.reconcile_rounds_checked_limit( + &observation, + &aborted, + pool_ledger::RECONCILE_ROUND_BATCH_LIMIT, + round_after, + )?; let (_, payout_more, payout_next) = self.ledger.reconcile_payouts_checked_limit( &observation, diff --git a/crates/node/src/pool/sv2.rs b/crates/node/src/pool/sv2.rs index 028fbcf6ff..4d4655a6d1 100644 --- a/crates/node/src/pool/sv2.rs +++ b/crates/node/src/pool/sv2.rs @@ -8,14 +8,14 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use bitcoin_rs_mining::MiningControl; -use bitcoin_rs_primitives::BlockHash; +use bitcoin_rs_mining::pool::ledger::MinerId; use bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter; use bitcoin_rs_mining::pool::stratum::sv2::{ AdmitError, AuthorityKeypair, BlockSubmission, Connection, ERROR_CODE_SHARE_NOT_RECORDED, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE, Event, Limits, MAX_FEED_LEN, PendingBlock, ServerConfig, }; use bitcoin_rs_mining::pool::{BlockStatus, PoolCore, RecordOutcome}; -use bitcoin_rs_mining::pool::ledger::MinerId; +use bitcoin_rs_primitives::BlockHash; use super::authority; use super::ledger::{BlockRecordOutcome, BlockRecordPermit, DeferredCharge, LedgerHost}; @@ -87,8 +87,7 @@ fn settle_one_deferred( } Ok(fresh) if fresh.receipt().status() == BlockStatus::Rejected => { if fresh.receipt().is_stale_parent() { - let _ = connection - .reject_block(&fresh, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE); + let _ = connection.reject_block(&fresh, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE); } return None; } @@ -559,9 +558,7 @@ fn handle_events( // `charge`; dropping the waiter releases it. Shed // past capacity instead of growing unbounded. let Some(charge) = charge else { - return Err(anyhow!( - "SV2 durable recovery has no reservation" - )); + return Err(anyhow!("SV2 durable recovery has no reservation")); }; deferred.push(DeferredBlock { hash, @@ -593,9 +590,7 @@ fn handle_events( // state; dropping the waiter releases exactly once. // Shed past capacity instead of retaining uncharged. let Some(charge) = charge else { - return Err(anyhow!( - "SV2 queued recovery has no reservation" - )); + return Err(anyhow!("SV2 queued recovery has no reservation")); }; deferred.push(DeferredBlock { hash, @@ -626,9 +621,7 @@ fn handle_events( // growing unbounded: never confirm an unapplied block. // Linearizable capacity; at-least-once delivery stays // idempotent on retry. - return Err(anyhow!( - "SV2 Undetermined block lost its reservation" - )); + return Err(anyhow!("SV2 Undetermined block lost its reservation")); } BlockStatus::Rejected if submission.receipt().is_stale_parent() => { connection @@ -775,13 +768,7 @@ mod tests { use bitcoin::secp256k1::{Keypair, Secp256k1}; use bitcoin_rs_chain::compact_is_met_by; use bitcoin_rs_mempool::{Mempool, MempoolLimits}; - use bitcoin_rs_mining::{ - Candidate, CandidateContext, CoinbaseBudget, FakeMiningControl, MiningControl, - assemble_candidate, - }; - use bitcoin_rs_primitives::{BlockHash, CompactTarget, Hash256, Network}; - use bitcoin_rs_rpc::context::PoolOperator as _; - use bitcoin_rs_storage::KvStore; + use bitcoin_rs_mining::pool::ledger::{Ledger, MinerId, PayoutPolicy, PoolConfig}; use bitcoin_rs_mining::pool::stratum::sv2::{ AdmitError, AuthorityKeypair, Connection, FLAG_REQUIRES_STANDARD_JOBS, Limits, ServerConfig, }; @@ -789,6 +776,13 @@ mod tests { BIP320_VERSION_MASK, Extranonce, ExtranonceLayout, Job, JobBookConfig, MAX_NTIME_FORWARD_SECS, PoolCore, ShareLedger, ShareTarget, }; + use bitcoin_rs_mining::{ + Candidate, CandidateContext, CoinbaseBudget, FakeMiningControl, MiningControl, + assemble_candidate, + }; + use bitcoin_rs_primitives::{BlockHash, CompactTarget, Hash256, Network}; + use bitcoin_rs_rpc::context::PoolOperator as _; + use bitcoin_rs_storage::KvStore; use codec_sv2::{ Decoded, Decrypted, Handshake, InitiatorSent, MessageFrame, NoiseDecoder, NoiseEncoder, TransportDecryptState, TransportEncryptState, @@ -805,7 +799,6 @@ mod tests { SubmitSharesStandard, }; use noise_sv2::{INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE, Initiator}; - use bitcoin_rs_mining::pool::ledger::{Ledger, MinerId, PayoutPolicy, PoolConfig}; use zeroize::Zeroizing; use super::{BlockPipeline, DeferredBlock, LedgerHost, announce_current_job, handle_events}; @@ -1341,8 +1334,7 @@ mod tests { // Journal backpressure leaves the chain outcome unknown: the host must // retain the submission instead of confirming an unapplied block. - *host.control.submit.lock() = - bitcoin_rs_mining::BlockValidationResult::Inconclusive; + *host.control.submit.lock() = bitcoin_rs_mining::BlockValidationResult::Inconclusive; let pending = host.submit(&channel, 1, &job, winner)?; assert!( !has(&pending, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS), @@ -1364,8 +1356,15 @@ mod tests { &mut host.connection, &host.core, ); - assert!(waiter.is_none(), "Applied retry confirms and drops the waiter"); - assert_eq!(host.node_submissions(), 2, "deferred retry re-queries node state"); + assert!( + waiter.is_none(), + "Applied retry confirms and drops the waiter" + ); + assert_eq!( + host.node_submissions(), + 2, + "deferred retry re-queries node state" + ); let confirmed = host.miner.open(&host.connection.take_transmit())?; assert!(has(&confirmed, MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS)); Ok(()) diff --git a/crates/node/src/pool/tests.rs b/crates/node/src/pool/tests.rs index 08d73ca574..0480316a6c 100644 --- a/crates/node/src/pool/tests.rs +++ b/crates/node/src/pool/tests.rs @@ -2,10 +2,6 @@ use std::{path::Path, sync::Arc}; -use bitcoin_rs_mining::{BlockValidationResult, MiningControlError}; -use bitcoin_rs_primitives::{Block, BlockHash, Hash256, Header, Txid}; -use bitcoin_rs_rpc::context::PoolOperator; -use bitcoin_rs_storage::KvStore; use bitcoin_rs_mining::pool::ledger as pool_ledger; use bitcoin_rs_mining::pool::ledger::{ Balance, Ledger, MinerId, PayoutPolicy, PoolConfig, RoundState, ShareCredit, ShareReceipt, @@ -15,6 +11,10 @@ use bitcoin_rs_mining::pool::{ AcceptedShare, BlockReceipt, FoundBlock, JobId, LedgerUnavailable, RecordOutcome, SessionId, ShareClass, ShareLedger, ShareTarget, WorkerIdentity, }; +use bitcoin_rs_mining::{BlockValidationResult, MiningControlError}; +use bitcoin_rs_primitives::{Block, BlockHash, Hash256, Header, Txid}; +use bitcoin_rs_rpc::context::PoolOperator; +use bitcoin_rs_storage::KvStore; use super::ledger::LedgerHost; @@ -340,9 +340,7 @@ fn block_record_reservations_are_bounded_and_released(open: OpenStore) -> TestRe Ok(()) } -fn durable_undetermined_keeps_reservation_until_waiter_removed( - open: OpenStore, -) -> TestResult { +fn durable_undetermined_keeps_reservation_until_waiter_removed(open: OpenStore) -> TestResult { // An `Undetermined` submission releases no capacity when its record is // already durable: the full waiter stays charged to the shared // `MAX_PENDING_BLOCK_RECORDS` budget until removed. Fill capacity with @@ -379,8 +377,7 @@ fn durable_undetermined_keeps_reservation_until_waiter_removed( let permit = host .try_reserve_block_slot() .ok_or("slot refused below capacity")?; - let (outcome, charge) = - permit.record_or_enqueue(MinerId(42), &found, &Ok(receipt)); + let (outcome, charge) = permit.record_or_enqueue(MinerId(42), &found, &Ok(receipt)); assert!( matches!(outcome, BlockRecordOutcome::Durable), "good block must record durably" @@ -414,9 +411,7 @@ fn durable_undetermined_keeps_reservation_until_waiter_removed( #[test] fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResult { - let shared = Arc::new( - bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(1, 1)?, - ); + let shared = Arc::new(bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(1, 1)?); let sv1 = Arc::clone(&shared); let sv2 = Arc::clone(&shared); let first = sv1 @@ -439,9 +434,7 @@ fn protocol_admissions_share_total_capacity_and_release_it_on_drop() -> TestResu #[test] fn protocol_admissions_share_per_ip_capacity_without_consuming_refused_slots() -> TestResult { - let shared = Arc::new( - bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(3, 1)?, - ); + let shared = Arc::new(bitcoin_rs_mining::pool::stratum::sv1::ConnectionLimiter::new(3, 1)?); let sv1 = Arc::clone(&shared); let sv2 = Arc::clone(&shared); let same_ip = "192.0.2.1".parse()?; diff --git a/crates/rpc/src/handlers/chain.rs b/crates/rpc/src/handlers/chain.rs index 1f110afee9..70a901d73d 100644 --- a/crates/rpc/src/handlers/chain.rs +++ b/crates/rpc/src/handlers/chain.rs @@ -14,9 +14,9 @@ use bitcoin_rs_primitives::{Amount, LockTime, Script, Sequence, Witness}; use corepc_types::v31::{self, ChainTips, ChainTipsStatus}; use sonic_rs::{JsonContainerTrait as _, JsonValueMutTrait as _, JsonValueTrait, Value, json}; -use super::util::{ScanScript, parse_derivation_range, scan_descriptor}; #[cfg(test)] use super::util::scan_descriptor_range; +use super::util::{ScanScript, parse_derivation_range, scan_descriptor}; use bitcoin::hex::DisplayHex as _; use crate::context::{AppliedView, ChainControlError, Context, TxQueryError, TxUnavailableCause}; diff --git a/crates/rpc/tests/policy_contract.rs b/crates/rpc/tests/policy_contract.rs index 9410c86829..9120bc21e4 100644 --- a/crates/rpc/tests/policy_contract.rs +++ b/crates/rpc/tests/policy_contract.rs @@ -673,8 +673,8 @@ struct RecordingGatewayObserver { changes: parking_lot::Mutex>, } - impl MempoolObserver for RecordingGatewayObserver { - fn on_mutation(&self, envelope: &MutationEnvelope) { +impl MempoolObserver for RecordingGatewayObserver { + fn on_mutation(&self, envelope: &MutationEnvelope) { let result = &envelope.result; let mut changes = self.changes.lock(); for (offset, change) in result.changes.iter().enumerate() { From 77f8cb13f7a84e01e62649c1296b5457692d3f3b Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:21 +0900 Subject: [PATCH 57/61] fix(index): clear strict clippy findings Remove the remaining clippy errors under the CI feature set: backticked LevelDB in the doc, safe length conversions, a non-lazy ok_or, map_or, a moved tests module, and the redundant clone in the bundle test. No behavior change. --- crates/index/src/portable/export.rs | 67 ++++++++++--------- crates/index/src/portable/restore.rs | 21 +++--- .../src/runtime/{tests/mod.rs => tests.rs} | 0 crates/index/tests/portable_bundle.rs | 2 +- crates/primitives/src/endpoint.rs | 1 + crates/utxo/src/compress.rs | 2 +- 6 files changed, 45 insertions(+), 48 deletions(-) rename crates/index/src/runtime/{tests/mod.rs => tests.rs} (100%) diff --git a/crates/index/src/portable/export.rs b/crates/index/src/portable/export.rs index ebf5a1323e..992ad2ab0e 100644 --- a/crates/index/src/portable/export.rs +++ b/crates/index/src/portable/export.rs @@ -22,15 +22,15 @@ use super::manifest::{ BundleFamily, BundleIdentity, IndexBundleManifestV1, IndexMode, MANIFEST_FILE, TxLookupArtifact, }; use super::{ - BundleHost, ExportReport, IndexBundleError, IndexBundleRequest, MAX_RESTORE_POSITIONS, io_error, - sync_dir, -}; -use crate::types::{HASH_PREFIX_ROW_SIZE, HashPrefixRow, TxPosition, TxPositionValue, positions_are_valid}; -use crate::{ - IndexCapability, IndexError, IndexWatermark, IndexWriteFence, TxIndexFamily, + BundleHost, ExportReport, IndexBundleError, IndexBundleRequest, MAX_RESTORE_POSITIONS, + io_error, sync_dir, }; #[cfg(test)] use crate::TxIndexSnapshot; +use crate::types::{ + HASH_PREFIX_ROW_SIZE, HashPrefixRow, TxPosition, TxPositionValue, positions_are_valid, +}; +use crate::{IndexCapability, IndexError, IndexWatermark, IndexWriteFence, TxIndexFamily}; use bitcoin_rs_chain::BlockBodySource; use bitcoin_rs_storage::{PrefixScanLimit, StorageError}; use std::cmp::Reverse; @@ -441,18 +441,17 @@ const SORT_MERGE_FAN_IN: usize = 32; /// implementation sorts both the staged file (keyed by height) and the /// resolved file (keyed by sequence). fn write_framed(out: &mut impl Write, key: &[u8], payload: &[u8]) -> io::Result<()> { + let payload_len = u64::try_from(payload.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "payload length exceeds u64"))?; out.write_all(key)?; - out.write_all(&(payload.len() as u64).to_le_bytes())?; + out.write_all(&payload_len.to_le_bytes())?; out.write_all(payload) } /// Reads one framed record, or `None` at a clean record boundary. A file /// that ends inside a key, length, or payload is corruption, never a clean /// end. -fn read_framed( - reader: &mut impl Read, - key_len: usize, -) -> io::Result, Vec)>> { +fn read_framed(reader: &mut impl Read, key_len: usize) -> io::Result, Vec)>> { let mut key = vec![0_u8; key_len]; match reader.read_exact(&mut key) { Ok(()) => {} @@ -570,7 +569,7 @@ fn copy_sort_run( if cancelled() { return Err(IndexBundleError::Cancelled); } - let mut buf = [0_u8; IO_BUFFER_BYTES]; + let mut buf = vec![0_u8; IO_BUFFER_BYTES]; loop { let n = run.read(&mut buf).map_err(failed)?; if n == 0 { @@ -603,7 +602,7 @@ fn merge_runs( } let mut readers: Vec> = runs.iter_mut().map(BufReader::new).collect(); let mut heads: Vec, Vec)>> = Vec::with_capacity(readers.len()); - for reader in readers.iter_mut() { + for reader in &mut readers { heads.push(read_framed(reader, key_len).map_err(failed)?); } let mut heap: BinaryHeap, usize)>> = BinaryHeap::new(); @@ -638,7 +637,8 @@ fn merge_runs( fn encode_staged(key: &[u8; STAGED_KEY_LEN], value: &[u8]) -> Vec { let mut payload = Vec::with_capacity(STAGED_KEY_LEN + 8 + value.len()); payload.extend_from_slice(key); - payload.extend_from_slice(&(value.len() as u64).to_le_bytes()); + let value_len = u64::try_from(value.len()).unwrap_or(u64::MAX); + payload.extend_from_slice(&value_len.to_le_bytes()); payload.extend_from_slice(value); payload } @@ -662,7 +662,8 @@ fn decode_staged(payload: &[u8]) -> Option<([u8; STAGED_KEY_LEN], &[u8])> { fn encode_resolved(height: u32, located: &[([u8; 32], TxPosition)]) -> Vec { let mut payload = Vec::with_capacity(8 + located.len() * (32 + 6)); payload.extend_from_slice(&height.to_le_bytes()); - payload.extend_from_slice(&(located.len() as u64).to_le_bytes()); + let located_len = u64::try_from(located.len()).unwrap_or(u64::MAX); + payload.extend_from_slice(&located_len.to_le_bytes()); for (txid, position) in located { payload.extend_from_slice(txid); payload.extend_from_slice(position.as_bytes()); @@ -670,8 +671,10 @@ fn encode_resolved(height: u32, located: &[([u8; 32], TxPosition)]) -> Vec { payload } +type ResolvedPairs = Vec<([u8; 32], TxPosition)>; + /// Splits one resolved payload back into its height and located pairs. -fn decode_resolved(payload: &[u8]) -> Option<(u32, Vec<([u8; 32], TxPosition)>)> { +fn decode_resolved(payload: &[u8]) -> Option<(u32, ResolvedPairs)> { if payload.len() < 12 { return None; } @@ -733,12 +736,7 @@ impl RowExporter<'_> { } external_sort(&mut staged, STAGED_SORT_KEY_LEN, self.path, cancelled)?; let mut resolved = self.resolve(&mut staged, rows, cancelled)?; - external_sort( - &mut resolved, - RESOLVED_SORT_KEY_LEN, - self.path, - cancelled, - )?; + external_sort(&mut resolved, RESOLVED_SORT_KEY_LEN, self.path, cancelled)?; self.emit(&mut resolved, rows, out, cancelled) } @@ -751,7 +749,7 @@ impl RowExporter<'_> { fn charge_row(value: &[u8], height: u32, total: u64) -> Result { let positions = TxPositionValue::decode(value) .filter(|found| positions_are_valid(found)) - .ok_or_else(|| IndexBundleError::SourceRow { + .ok_or(IndexBundleError::SourceRow { height, reason: "row carries no valid position list", })?; @@ -878,13 +876,14 @@ impl RowExporter<'_> { .into()); }; seen = seen.saturating_add(1); - let sequence_bytes: [u8; 8] = sort_key.get(4..12).and_then(|bytes| bytes.try_into().ok()).ok_or_else( - || { + let sequence_bytes: [u8; 8] = sort_key + .get(4..12) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { IndexError::from(StorageError::InvalidOperation( "export staging carries a malformed sort key", )) - }, - )?; + })?; let sequence = u64::from_be_bytes(sequence_bytes); let (key, value) = decode_staged(&payload).ok_or_else(|| { IndexError::from(StorageError::InvalidOperation( @@ -904,7 +903,8 @@ impl RowExporter<'_> { return Err(IndexBundleError::Cancelled); } self.locate(row, value)?; - positions = positions.saturating_add(self.located.len() as u64); + positions = + positions.saturating_add(u64::try_from(self.located.len()).unwrap_or(u64::MAX)); if positions > u64::try_from(MAX_RESTORE_POSITIONS).unwrap_or(u64::MAX) { return Err(IndexBundleError::ExportTooLarge { limit_positions: MAX_RESTORE_POSITIONS, @@ -968,9 +968,7 @@ impl RowExporter<'_> { })?; let sequence = u64::from_be_bytes(sequence_bytes); if sequence != expected { - let height = decode_resolved(&payload) - .map(|(height, _)| height) - .unwrap_or(u32::MAX); + let height = decode_resolved(&payload).map_or(u32::MAX, |(height, _)| height); return Err(IndexBundleError::SourceRow { height, reason: "resolved rows lost their stored order", @@ -1018,8 +1016,11 @@ impl RowExporter<'_> { .take_while(|(candidate, _)| *candidate == txid) .count(); self.positions.clear(); - self.positions - .extend(located[index..index + run].iter().map(|(_, position)| *position)); + self.positions.extend( + located[index..index + run] + .iter() + .map(|(_, position)| *position), + ); let added = u64::try_from(run).unwrap_or(u64::MAX); if self.positions_written.saturating_add(added) > u64::try_from(MAX_RESTORE_POSITIONS).unwrap_or(u64::MAX) diff --git a/crates/index/src/portable/restore.rs b/crates/index/src/portable/restore.rs index 2f306eaf4a..9b50ef1737 100644 --- a/crates/index/src/portable/restore.rs +++ b/crates/index/src/portable/restore.rs @@ -295,11 +295,7 @@ impl LocatorCheck<'_> { ) })?; let added = record.positions.len(); - if self - .pending_positions - .saturating_add(added) - > super::MAX_RESTORE_POSITIONS - { + if self.pending_positions.saturating_add(added) > super::MAX_RESTORE_POSITIONS { return Err(IndexBundleError::RestoreTooLarge { limit_positions: super::MAX_RESTORE_POSITIONS, }); @@ -344,12 +340,11 @@ impl LocatorCheck<'_> { } } } - match self.bodies.block_tx_count(height, hash)? { - Some(count) => Ok(Some(count)), - None => { - self.outcome = LocatorOutcome::Withheld(Withheld::BodyUnavailable { height }); - Ok(None) - } + if let Some(count) = self.bodies.block_tx_count(height, hash)? { + Ok(Some(count)) + } else { + self.outcome = LocatorOutcome::Withheld(Withheld::BodyUnavailable { height }); + Ok(None) } } /// @@ -770,8 +765,8 @@ mod tests { /// fully so the digest comparison can outrank framing faults, while a /// trip returns `Cancelled` at once with no digest comparison. #[test] - fn invalid_tail_drain_polls_cancellation_per_bounded_chunk( - ) -> Result<(), Box> { + fn invalid_tail_drain_polls_cancellation_per_bounded_chunk() + -> Result<(), Box> { let dir = tempfile::tempdir()?; let path = dir.path().join("tail.dat"); // Several poll intervals plus a short tail: the final chunk is diff --git a/crates/index/src/runtime/tests/mod.rs b/crates/index/src/runtime/tests.rs similarity index 100% rename from crates/index/src/runtime/tests/mod.rs rename to crates/index/src/runtime/tests.rs diff --git a/crates/index/tests/portable_bundle.rs b/crates/index/tests/portable_bundle.rs index f98338bc09..93a1e3386a 100644 --- a/crates/index/tests/portable_bundle.rs +++ b/crates/index/tests/portable_bundle.rs @@ -627,7 +627,7 @@ impl Node { }, trip: start.cancel_on_body_read.then(|| Arc::clone(&shutdown)), witness: start.witness, - loads: start.body_loads.clone(), + loads: start.body_loads, })), }; let chain_events: Arc = probe.clone(); diff --git a/crates/primitives/src/endpoint.rs b/crates/primitives/src/endpoint.rs index 7a3d7c13fa..c6fcf96fbe 100644 --- a/crates/primitives/src/endpoint.rs +++ b/crates/primitives/src/endpoint.rs @@ -781,4 +781,5 @@ mod tests { #[cfg(test)] #[path = "endpoint/tests.rs"] +#[expect(clippy::expect_used)] mod endpoint_main_tests; diff --git a/crates/utxo/src/compress.rs b/crates/utxo/src/compress.rs index c01e2a1b6a..0192a506fe 100644 --- a/crates/utxo/src/compress.rs +++ b/crates/utxo/src/compress.rs @@ -186,7 +186,7 @@ pub(crate) fn encode_core_varint_array(mut value: u64) -> ([u8; 10], usize) { } /// Compares two vouts by Core's coins-DB key order: bytewise order of the -/// subtract-one MSB VARINT encodings, which is the LevelDB cursor order that +/// subtract-one MSB VARINT encodings, which is the `LevelDB` cursor order that /// defines Core's `HASH_SERIALIZED` commitment and dumptxoutset group order. /// /// This is NOT numeric order across a width transition: 2-byte max 16,511 From 06789800b0d0b598444011777bc6e000871f24c4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:25 +0900 Subject: [PATCH 58/61] fix(rpc): clear strict clippy findings Add the codec result aliases, use map_or and is_multiple_of, merge the duplicate missing-body arm, scope the test-only BodyInterest import, and remove same-type conversions. No behavior change. --- bin/bitcoin-rs/src/config.rs | 6 ++---- crates/rpc/src/compressed_tx.rs | 29 ++++++++++++++------------- crates/rpc/src/compressed_tx/tests.rs | 22 +++++++++++--------- crates/rpc/src/context.rs | 6 +++--- crates/rpc/src/handlers/network.rs | 20 ++++++------------ 5 files changed, 39 insertions(+), 44 deletions(-) diff --git a/bin/bitcoin-rs/src/config.rs b/bin/bitcoin-rs/src/config.rs index 9b5c48b3b3..cdb0887887 100644 --- a/bin/bitcoin-rs/src/config.rs +++ b/bin/bitcoin-rs/src/config.rs @@ -562,10 +562,7 @@ onlynet=onion,i2p conf_with_overlays.p2p.overlay.tor_control, Some(SocketAddr::from(([127, 0, 0, 1], 9052))) ); - assert_eq!( - conf_with_overlays.p2p.overlay.tor_cookie.as_deref(), - None - ); + assert_eq!(conf_with_overlays.p2p.overlay.tor_cookie.as_deref(), None); assert_eq!( conf_with_overlays.p2p.overlay.i2p_sam, Some(SocketAddr::from(([127, 0, 0, 1], 7656))) @@ -674,6 +671,7 @@ assumevalid=0000000000000000000000000000000000000000000000000000000000000000 } #[cfg(test)] +#[expect(clippy::expect_used)] mod feature_option_tests { use std::ffi::OsString; use std::path::Path; diff --git a/crates/rpc/src/compressed_tx.rs b/crates/rpc/src/compressed_tx.rs index bc6316b0f2..d994e0169e 100644 --- a/crates/rpc/src/compressed_tx.rs +++ b/crates/rpc/src/compressed_tx.rs @@ -80,6 +80,9 @@ pub(crate) const MAX_CUMULATIVE_BODY_BYTES: usize = MAX_RETAINED_BODY_BYTES * 2; /// Cancellation is polled once per this many codec inputs. const CANCELLATION_POLL_INPUTS: usize = 256; +type FlatLookup = Option, LookupError>>; +type LocatedPrevoutLookup = Option, LookupError>>; + /// Caller-selected compression behaviour. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct CompressOptions { @@ -907,15 +910,15 @@ fn resolve_flats_grouped( resolver: &mut dyn PrevoutResolver, order: &[u32], members: &HashMap>, -) -> Result, LookupError>>>, CodecError> { - let mut results: Vec, LookupError>>> = vec![None; tx.inputs.len()]; +) -> Result, CodecError> { + let mut results: Vec = vec![None; tx.inputs.len()]; let mut polls = 0_usize; for &height in order { let Some(indices) = members.get(&height) else { continue; }; for &index in indices { - if polls % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + if polls.is_multiple_of(CANCELLATION_POLL_INPUTS) && resolver.cancelled() { return Err(CodecError::Cancelled); } polls += 1; @@ -1029,12 +1032,12 @@ pub(crate) fn compress_transaction( // order. Results are cached per input; the plan pass below still runs in // input order, so warnings keep their `UTXO(i)` order and content, and // proved-absent inputs still degrade rather than fail. - let mut flat_results: Vec, LookupError>>> = - vec![None; tx.inputs.len()]; - if options.compress_outpoints { + let flat_results = if options.compress_outpoints { let (order, members) = flat_lookup_groups(tx, &resolved, °raded); - flat_results = resolve_flats_grouped(tx, resolver, &order, &members)?; - } + resolve_flats_grouped(tx, resolver, &order, &members)? + } else { + vec![None; tx.inputs.len()] + }; // Per-input encoding plan. let mut plans: Vec = Vec::with_capacity(tx.inputs.len()); @@ -1347,7 +1350,7 @@ fn recover_pubkey(cache: &SighashCache<'_>, request: &Recovery<'_>) -> Result Result, LookupError>>>, CodecError> { +) -> Result, CodecError> { let mut order: Vec = Vec::new(); let mut members: HashMap> = HashMap::new(); for (index, part) in parsed.iter().enumerate() { @@ -1358,15 +1361,14 @@ fn resolve_outpoints_grouped( members.entry(height).or_default().push(index); } } - let mut located: Vec, LookupError>>> = - vec![None; parsed.len()]; + let mut located: Vec = vec![None; parsed.len()]; let mut polls = 0_usize; for &height in &order { let Some(indices) = members.get(&height) else { continue; }; for &index in indices { - if polls % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + if polls.is_multiple_of(CANCELLATION_POLL_INPUTS) && resolver.cancelled() { return Err(CodecError::Cancelled); } polls += 1; @@ -1579,11 +1581,10 @@ pub(crate) fn decompress_prepare( let (outpoint, resolved) = match height_flat { Some((height, _)) => match located[index].take() { Some(Ok(Some(found))) => (found.0, Some(found.1)), - Some(Ok(None)) => return Err(CodecError::BodyMissing(height)), + Some(Ok(None)) | None => return Err(CodecError::BodyMissing(height)), Some(Err(error)) => { return Err(lookup_failure(error, CodecError::BodyMissing(height))); } - None => return Err(CodecError::BodyMissing(height)), }, None => match raw_outpoint { Some(outpoint) => (outpoint, None), diff --git a/crates/rpc/src/compressed_tx/tests.rs b/crates/rpc/src/compressed_tx/tests.rs index 70248f64b6..d848dbeb20 100644 --- a/crates/rpc/src/compressed_tx/tests.rs +++ b/crates/rpc/src/compressed_tx/tests.rs @@ -9,6 +9,7 @@ //! re-derives every field from the bodies themselves. #![expect( + clippy::expect_used, clippy::unwrap_used, reason = "test assertions fail with the checked value in the message" )] @@ -1500,7 +1501,16 @@ fn mainnet_bodies_truth() { } } +fn compact_of(der_plus_hashtype: &[u8]) -> [u8; 64] { + let parsed = bitcoin::secp256k1::ecdsa::Signature::from_der( + &der_plus_hashtype[..der_plus_hashtype.len() - 1], + ) + .unwrap(); + parsed.serialize_compact() +} + #[test] +#[expect(clippy::too_many_lines)] fn shared_sighash_cache_recovers_every_input() { // One cache over the immutable skeleton must serve every compressed // input: the legacy input and the segwit input below share the same @@ -1568,24 +1578,17 @@ fn shared_sighash_cache_recovers_every_input() { // transaction carries DER signatures plus a sighash byte; recovery // needs the 64-byte compact form, so strip the trailing byte and // parse the DER prefix. - fn compact_of(der_plus_hashtype: &[u8]) -> [u8; 64] { - let parsed = bitcoin::secp256k1::ecdsa::Signature::from_der( - &der_plus_hashtype[..der_plus_hashtype.len() - 1], - ) - .unwrap(); - parsed.serialize_compact() - } let cache = SighashCache::new(&tx); let compact_a = compact_of(&sig_item_a); let compact_b = compact_of(&tx.inputs[1].witness[0]); let kind_a = super::prevout_kind(prevout_a.script_pubkey.as_bytes()); let kind_b = super::prevout_kind(prevout_b.script_pubkey.as_bytes()); let resolved_a = ResolvedPrevout { - txout: prevout_a.clone(), + txout: prevout_a, confirmed_height: Some(100), }; let resolved_b = ResolvedPrevout { - txout: prevout_b.clone(), + txout: prevout_b, confirmed_height: Some(101), }; let recovered_a = super::recover_pubkey( @@ -1640,6 +1643,7 @@ fn shared_sighash_cache_recovers_every_input() { } #[test] +#[expect(clippy::too_many_lines)] fn production_resolver_clones_only_the_requested_output() { use std::sync::Arc; diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index 10c939df58..9895fc7a78 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -5,9 +5,9 @@ use bitcoin_rs_chain::{ BlockBodyReadError, BlockBodySource, BlockTreeReader, LatchReader, TipReader, TipSnapshot, softfork_state, }; -use bitcoin_rs_mempool::{ - AdmissionChain, BodyInterest, ChainAdmissionSnapshot, MempoolGateway, PrevoutMeta, -}; +#[cfg(any(test, feature = "test-seam"))] +use bitcoin_rs_mempool::BodyInterest; +use bitcoin_rs_mempool::{AdmissionChain, ChainAdmissionSnapshot, MempoolGateway, PrevoutMeta}; #[cfg(any(test, feature = "test-seam"))] use bitcoin_rs_mempool::{Mempool, MempoolLimits, MempoolObserver}; diff --git a/crates/rpc/src/handlers/network.rs b/crates/rpc/src/handlers/network.rs index 5dd31a1286..4172a0c002 100644 --- a/crates/rpc/src/handlers/network.rs +++ b/crates/rpc/src/handlers/network.rs @@ -97,7 +97,6 @@ fn optional_u64(params: &Value, index: usize, default: u64) -> Result, params: &Value) -> Result { ensure_no_params(params)?; let peers = ctx.network.peer_table.infos(); @@ -802,8 +801,7 @@ mod tests { ), onlynet: vec![bitcoin_rs_p2p::PeerNetwork::Onion], ..bitcoin_rs_p2p::overlay::OverlayConfig::default() - } - .into(), + }, ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), @@ -871,8 +869,7 @@ mod tests { i2p_sam: Some("127.0.0.1:2".parse()?), randomize_credentials: true, ..bitcoin_rs_p2p::overlay::OverlayConfig::default() - } - .into(), + }, ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), @@ -948,8 +945,7 @@ mod tests { overlay: bitcoin_rs_p2p::overlay::OverlayConfig { tor_control: Some("127.0.0.1:9051".parse()?), ..bitcoin_rs_p2p::overlay::OverlayConfig::default() - } - .into(), + }, ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), @@ -1030,7 +1026,7 @@ mod tests { let mut ctx = Context::new(); let p2p = Arc::new(bitcoin_rs_p2p::P2pService::new( bitcoin_rs_p2p::P2pServiceConfig { - overlay: overlay.into(), + overlay, ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), @@ -1144,8 +1140,7 @@ mod tests { randomize_credentials: false, onlynet: vec![bitcoin_rs_p2p::PeerNetwork::Onion], ..bitcoin_rs_p2p::overlay::OverlayConfig::default() - } - .into(), + }, ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), @@ -1312,10 +1307,7 @@ mod addnode_validation_tests { let persisted = bitcoin_rs_p2p::PeerEndpoint::Ip(SocketAddr::from(([127, 0, 0, 1], 8333))); let result = addnode(&ctx, &json!(["127.0.0.1:8333", "add"])); assert!(result.is_ok()); - assert_eq!( - ctx.network.p2p.added_nodes().as_slice(), - &[persisted.into()] - ); + assert_eq!(ctx.network.p2p.added_nodes().as_slice(), &[persisted]); ctx.network.p2p.set_network_active(true); let result = addnode(&ctx, &json!(["127.0.0.2:8333", "onetry"])); From f61055ce3e354ff36e88ee23f979815de85869ce Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:29 +0900 Subject: [PATCH 59/61] fix(node): clear strict clippy findings Move the line-count expectation to materialize, use map_or for the staged timestamp, and format the block hash directly in the offline-import test manifest. No behavior change. --- crates/node/src/config.rs | 22 ++++++++------------- crates/node/src/offline_import.rs | 33 +++++++++++++++---------------- crates/node/src/snapshot.rs | 3 +-- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index c53348b58b..df74507ed0 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -5,12 +5,12 @@ use anyhow::Result; use bitcoin_rs_chainstate::{ChainstateJournalConfig, ValidationMode}; use bitcoin_rs_consensus::ValidationEngine; use bitcoin_rs_index::IndexCapabilities; +#[cfg(feature = "pool")] +use bitcoin_rs_mining::pool::ledger as pool_ledger; use bitcoin_rs_p2p::PeerNetwork; use bitcoin_rs_p2p::endpoint::{ClassifiedEndpoint, classify_endpoint}; use bitcoin_rs_primitives::Network; use bitcoin_rs_storage::StorageBackend; -#[cfg(feature = "pool")] -use bitcoin_rs_mining::pool::ledger as pool_ledger; use core::fmt; use crossbeam_channel::Receiver; use serde::Deserialize; @@ -518,6 +518,10 @@ impl NodeConfig { /// profile's value for a field no layer set. /// INVARIANT: a network selection never overwrites a field an operator /// set on any layer; it only fills the gaps (`ARCH-05`). + #[expect( + clippy::too_many_lines, + reason = "One fold of every resolved field keeps precedence auditable in one place" + )] fn materialize(settings: &UserConfig, selection: NetworkSelection) -> Self { let profile = NetworkProfile::for_selection(selection); let mut chainstate_journal = ChainstateJournalConfig::default(); @@ -634,10 +638,6 @@ impl NodeConfig { /// This is the fail-early gate for engine selection: an engine this build /// cannot execute is rejected here — before the data directory opens, /// chainstate exists, or any worker starts. - #[expect( - clippy::too_many_lines, - reason = "Keep resolved cross-field checks in one ordered fail-early gate before startup" - )] pub fn validate(&self) -> Result<()> { if let Some(pool) = &self.pool { pool.validate()?; @@ -804,17 +804,11 @@ impl NodeConfig { "proxy/overlay routing requires --dns-seeds-enabled=false; DNS seeds resolve via local DNS and would bypass the privacy route" ); anyhow::ensure!( - !overlay - .onlynet - .contains(&PeerNetwork::Onion) - || onion_route, + !overlay.onlynet.contains(&PeerNetwork::Onion) || onion_route, "--onlynet=onion requires an onion route (--onion-proxy or --proxy)" ); anyhow::ensure!( - !overlay - .onlynet - .contains(&PeerNetwork::I2p) - || overlay.i2p_sam.is_some(), + !overlay.onlynet.contains(&PeerNetwork::I2p) || overlay.i2p_sam.is_some(), "--onlynet=i2p requires an I2P SAM bridge (--i2p-sam)" ); anyhow::ensure!( diff --git a/crates/node/src/offline_import.rs b/crates/node/src/offline_import.rs index 9e098edd42..223f7b52ab 100644 --- a/crates/node/src/offline_import.rs +++ b/crates/node/src/offline_import.rs @@ -1190,30 +1190,29 @@ mod tests { fn two_block_archive() -> TwoBlockArchive { use sha2::Digest as _; let genesis = bitcoin_rs_primitives::Network::Regtest.genesis_block(); - let child = bitcoin_rs_chain::regtest_fixture::mined_regtest_child_at( - genesis.block_hash(), - 1, - ) - .expect("mine child"); + let child = + bitcoin_rs_chain::regtest_fixture::mined_regtest_child_at(genesis.block_hash(), 1) + .expect("mine child"); let blocks = vec![genesis, child]; let magic = bitcoin_rs_primitives::Network::Regtest.magic(); let mut corpus = Vec::new(); let mut entries = Vec::new(); let mut offset: u64 = 0; for (index, block) in blocks.iter().enumerate() { - let payload = - bitcoin_rs_primitives::BlockPayload::from_local(block.clone()); + let payload = bitcoin_rs_primitives::BlockPayload::from_local(block.clone()); let raw = payload.bytes(); let mut framed = Vec::with_capacity(8 + raw.len()); framed.extend_from_slice(&magic); framed.extend_from_slice( - &u32::try_from(raw.len()).expect("payload fits").to_le_bytes(), + &u32::try_from(raw.len()) + .expect("payload fits") + .to_le_bytes(), ); framed.extend_from_slice(raw); entries.push(format!( r#"{{"height":{index},"hash":"{hash}","offset":{offset},"payload_length":{len}}}"#, index = u32::try_from(index).expect("index fits"), - hash = block.block_hash().to_string(), + hash = block.block_hash(), len = raw.len() )); offset += u64::try_from(framed.len()).expect("offset fits"); @@ -1267,15 +1266,13 @@ mod tests { let state = dir.path().join("state.json"); run_offline_import(offline_import_config(&data_dir), &corpus, &manifest, &state) .expect("fresh import succeeds"); - let record = - read_full_validation_provenance(&data_dir).expect("provenance recorded"); + let record = read_full_validation_provenance(&data_dir).expect("provenance recorded"); assert_eq!(record.height, 1, "provenance binds the certified tip"); assert!( record.tip_hash.eq_ignore_ascii_case(&archive.stop_hash), "provenance binds the certified tip hash" ); - let text = - String::from_utf8(std::fs::read(&state).expect("read state")).expect("utf8"); + let text = String::from_utf8(std::fs::read(&state).expect("read state")).expect("utf8"); assert!(text.contains("\"height\": 1"), "state certifies height 1"); assert!( text.contains(&archive.stop_hash), @@ -1309,7 +1306,10 @@ mod tests { .expect("certified directory resumes"); let text = String::from_utf8(std::fs::read(&resume_state).expect("read state")).expect("utf8"); - assert!(text.contains("\"height\": 1"), "resume still certifies height 1"); + assert!( + text.contains("\"height\": 1"), + "resume still certifies height 1" + ); assert!( text.contains(&archive.stop_hash), "resume still certifies the archive tip" @@ -1328,9 +1328,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().join("node"); { - let live = - crate::state::NodeState::open(offline_import_config(&data_dir), None) - .expect("open node"); + let live = crate::state::NodeState::open(offline_import_config(&data_dir), None) + .expect("open node"); for block in &archive.blocks { live.apply_block(&bitcoin_rs_primitives::BlockPayload::from_local( block.clone(), diff --git a/crates/node/src/snapshot.rs b/crates/node/src/snapshot.rs index b283f8d4c1..5cf70f3b80 100644 --- a/crates/node/src/snapshot.rs +++ b/crates/node/src/snapshot.rs @@ -43,8 +43,7 @@ static EXPORT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); fn staged_temp_name(name: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.as_nanos()) - .unwrap_or(0); + .map_or(0, |elapsed| elapsed.as_nanos()); let counter = EXPORT_TEMP_COUNTER.fetch_add(1, Ordering::SeqCst); format!( "{name}.{pid}.{nanos:x}-{counter:x}.incomplete", From 338d0ad81fa880dc434e05dfcfbf906194b60c35 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:35 +0900 Subject: [PATCH 60/61] fix(p2p): clear clippy debt and wire addrv2 Remove unreachable helpers, gate test-only seam contracts, and route the negotiated AddrV2 reply through the overlay-filtered gossip projection. Route denial now gates feeler, restart, and candidate selection. The p2p library suite still fails six inherited tests at base 953777971: addrman persistence and v8 preservation expectations, SAM CANT_REACH_PEER and TIMEOUT failure accounting, service start cancellation, and failed-start teardown. This commit does not repair that separate contract debt. --- crates/p2p/src/addrman.rs | 17 +- crates/p2p/src/addrman/tests.rs | 90 +++++-- .../src/addrman/tests/eligibility_tests.rs | 30 ++- crates/p2p/src/addrman/tests/probe_tests.rs | 7 +- .../p2p/src/addrman/tests/services_tests.rs | 43 +++- crates/p2p/src/discouragement.rs | 18 +- crates/p2p/src/download_window.rs | 15 +- crates/p2p/src/endpoint.rs | 1 - crates/p2p/src/inv.rs | 20 +- crates/p2p/src/listener.rs | 240 ++++-------------- .../src/listener/aggregate_filter_tests.rs | 8 +- .../p2p/src/listener/getdata_pause_tests.rs | 8 +- crates/p2p/src/listener/v2_transport_tests.rs | 22 +- crates/p2p/src/overlay/sam.rs | 10 +- crates/p2p/src/overlay/tests.rs | 6 +- crates/p2p/src/peer_info.rs | 6 +- crates/p2p/src/peer_table.rs | 75 ++---- crates/p2p/src/sam.rs | 27 +- crates/p2p/src/service.rs | 75 ++---- crates/p2p/src/sync/requests.rs | 11 +- crates/p2p/src/sync/tests.rs | 15 +- crates/p2p/src/sync/tests/chain_sync.rs | 18 +- .../p2p/src/sync/tests/frontier_liveness.rs | 7 +- crates/p2p/src/sync/tests/frontier_model.rs | 37 +-- .../p2p/src/sync/tests/frontier_recovery.rs | 10 +- crates/p2p/src/sync/tests/header_rotation.rs | 25 +- crates/p2p/src/sync/tests/headers_presync.rs | 20 +- .../p2p/src/sync/tests/headroom_throttling.rs | 6 +- crates/p2p/src/sync/tests/historical.rs | 10 +- .../p2p/src/sync/tests/settlement_faults.rs | 15 +- crates/p2p/src/sync/tests/window_recovery.rs | 24 +- crates/p2p/src/tx_policy.rs | 7 +- crates/p2p/src/tx_policy/ingress.rs | 3 +- crates/p2p/tests/core_compat.rs | 2 +- 34 files changed, 348 insertions(+), 580 deletions(-) diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index 0f88e5331b..1be70a8d2c 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -53,17 +53,6 @@ fn deserialize_endpoint<'de, D: serde::Deserializer<'de>>( let text = String::deserialize(deserializer)?; crate::parse_endpoint(&text).ok_or_else(|| serde::de::Error::custom("invalid endpoint")) } -fn endpoint_group(groups: &NetGroups, endpoint: PeerEndpoint) -> Vec { - match endpoint { - PeerEndpoint::Ip(socket) => groups.group(socket.ip()), - PeerEndpoint::TorV3 { .. } | PeerEndpoint::I2p { .. } => { - crate::endpoint::overlay_netgroup(endpoint.host()).unwrap_or_else(|| vec![0]) - } - } -} -fn endpoint_ip(endpoint: PeerEndpoint) -> Option { - endpoint.as_ip().map(|socket| socket.ip()) -} #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] @@ -781,7 +770,10 @@ pub(crate) fn canonical(addr: PeerEndpoint) -> PeerEndpoint { } fn routable(addr: PeerEndpoint, allow_local: bool) -> bool { - if addr.port() == 0 && !matches!(addr.address(), crate::PeerAddress::I2p(_)) || addr.address().is_unspecified() || addr.address().is_multicast() { + if addr.port() == 0 && !matches!(addr.address(), crate::PeerAddress::I2p(_)) + || addr.address().is_unspecified() + || addr.address().is_multicast() + { return false; } match addr.address() { @@ -1705,7 +1697,6 @@ fn read_anchors<'de, D: serde::Deserializer<'de>>( deserializer.deserialize_seq(Anchors) } - fn validate_anchors(stored: &Stored) -> io::Result<()> { let mut anchors = HashSet::new(); if stored.anchors.len() > MAX_ANCHORS { diff --git a/crates/p2p/src/addrman/tests.rs b/crates/p2p/src/addrman/tests.rs index 4291ede1fa..ae5a8980f9 100644 --- a/crates/p2p/src/addrman/tests.rs +++ b/crates/p2p/src/addrman/tests.rs @@ -20,7 +20,11 @@ fn book() -> Arc { #[test] fn unproven_addresses_need_a_successful_outbound_handshake() { let book = book(); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 9, 10_000)], + 10_000, + ); assert!(!book.state.lock().stored.records[0].tried); book.queued(addr(1)); book.attempted(addr(1), true, 10_001); @@ -34,7 +38,11 @@ fn unproven_addresses_need_a_successful_outbound_handshake() { fn gossip_is_bounded_and_selection_respects_groups_pending_and_exclusion() { let book = book(); for n in 1..200 { - book.learn_peer(crate::PeerRemote::Endpoint(addr(200)), &[(addr(n), 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(200)), + &[(addr(n), 9, 10_000)], + 10_000, + ); } assert!(book.len() <= 199); let picked = book @@ -80,7 +88,11 @@ fn invalid_and_duplicate_addresses_do_not_create_records() { 10_000, ); assert_eq!(book.len(), 0); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 9, 10_000); 2], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 9, 10_000); 2], + 10_000, + ); assert_eq!(book.len(), 1); } @@ -89,7 +101,11 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("peers.dat"); let book = AddressBook::open(Some(path.clone()), [1; 4], false, None); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 9, 10_000)], + 10_000, + ); book.succeeded(addr(1), 9, 10_001); book.save(); let restored = AddressBook::open(Some(path.clone()), [1; 4], false, None); @@ -98,7 +114,11 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { for corrupt in [b"truncated".to_vec(), vec![0; 40]] { fs::write(network_path(&path, [1; 4]), &corrupt).expect("corrupt fixture"); let recovered = AddressBook::open(Some(path.clone()), [1; 4], false, None); - recovered.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(3), 9, 10_000)], 10_000); + recovered.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(3), 9, 10_000)], + 10_000, + ); recovered.save(); assert_eq!( fs::read(network_path(&path, [1; 4])).expect("read"), @@ -111,7 +131,11 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { fn promotion_collision_never_overwrites_a_proven_peer() { let book = book(); let first = addr(1); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(first, 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(first, 9, 10_000)], + 10_000, + ); book.succeeded(first, 9, 10_001); let incumbent = book.state.lock().stored.records[0].clone(); let challenger = { @@ -122,7 +146,11 @@ fn promotion_collision_never_overwrites_a_proven_peer() { .find(|candidate| *candidate != first && manager.tried_slot(*candidate) == target) .expect("slot collision") }; - book.learn_peer(crate::PeerRemote::Endpoint(addr(3)), &[(challenger, 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(3)), + &[(challenger, 9, 10_000)], + 10_000, + ); book.succeeded(challenger, 9, 10_002); let manager = book.state.lock(); assert!( @@ -277,16 +305,28 @@ fn publication_retries_collisions_with_a_bound_and_preserves_unowned_temps() { fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { let dir = tempfile::tempdir().expect("dir"); let book = AddressBook::open(Some(dir.path().join("peers.dat")), [1; 4], false, None); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 1, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 1, 10_000)], + 10_000, + ); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 1, 9_999); 32], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 1, 9_999); 32], + 10_000, + ); assert_eq!( book.state.lock().revision, clean, "unchanged duplicate reports cannot schedule disk writes" ); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 8, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 8, 10_000)], + 10_000, + ); assert_ne!( book.state.lock().revision, clean, @@ -295,7 +335,11 @@ fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { book.succeeded(addr(1), 9, 10_001); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 64, 10_001)], 10_001); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 64, 10_001)], + 10_001, + ); assert_ne!( book.state.lock().revision, clean, @@ -303,7 +347,11 @@ fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { ); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 9, 13_602)], 13_602); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 9, 13_602)], + 13_602, + ); assert_ne!( book.state.lock().revision, clean, @@ -375,7 +423,11 @@ fn gossip_cursor_cache_shared_across_projections() { book.set_addrv2_gossip(true, false); let tick = Instant::now(); let legacy = book.gossip_at(10_000, tick); - assert_eq!(legacy.len(), 6, "gossip projects the shared cache overlay-inclusive"); + assert_eq!( + legacy.len(), + 6, + "gossip projects the shared cache overlay-inclusive" + ); let cursor = book.state.lock().gossip_cursor; let v2 = book.gossip_addrv2_at(10_000, tick); assert_eq!( @@ -1280,8 +1332,7 @@ fn ring_manager(second_port: u16) -> (Manager, PeerEndpoint, PeerEndpoint) { .parse::() .expect("ip"), port, - ) - .into(); + ); assert!(manager.learn(peer, Some(9), source(1), EPOCH, EPOCH, 0)); let bucket = usize::try_from(row["new_bucket"].as_u64().expect("bucket")).expect("bucket"); assert_eq!( @@ -1524,8 +1575,7 @@ fn mapped_alias_exact_connection_filter_does_not_block_distinct_endpoints() { #[test] fn overlay_identity_anonymous_tor_gossip_learns_nothing() { let book = book(); - let anon = - crate::PeerRemote::AnonymousTor(crate::ConnectionId::for_test_raw(77)); + let anon = crate::PeerRemote::AnonymousTor(crate::ConnectionId::for_test_raw(77)); book.learn_peer(anon, &[(addr(1), 9, 10_000)], 10_000); assert_eq!( book.len(), @@ -1593,8 +1643,7 @@ fn overlay_identity_identified_i2p_learns_typed_source() { #[test] fn overlay_identity_no_loopback_proxy_ip_in_learn() { let book = book(); - let loopback: PeerEndpoint = - std::net::SocketAddr::new(Ipv4Addr::new(127, 0, 0, 1).into(), 8333).into(); + let loopback: PeerEndpoint = std::net::SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 8333).into(); book.learn_peer( crate::PeerRemote::Endpoint(addr(2)), &[(loopback, 9, 10_000)], @@ -1605,8 +1654,7 @@ fn overlay_identity_no_loopback_proxy_ip_in_learn() { 0, "loopback gossip addresses must never enter learn" ); - let anon = - crate::PeerRemote::AnonymousTor(crate::ConnectionId::for_test_raw(78)); + let anon = crate::PeerRemote::AnonymousTor(crate::ConnectionId::for_test_raw(78)); book.learn_peer(anon, &[(addr(1), 9, 10_000)], 10_000); assert_eq!( book.len(), diff --git a/crates/p2p/src/addrman/tests/eligibility_tests.rs b/crates/p2p/src/addrman/tests/eligibility_tests.rs index 9bdde3fe11..382b89b1d6 100644 --- a/crates/p2p/src/addrman/tests/eligibility_tests.rs +++ b/crates/p2p/src/addrman/tests/eligibility_tests.rs @@ -17,7 +17,11 @@ fn ordinary_selection_uses_existing_service_contract_for_new_and_tried() { (1033, u64::MAX, true), ] { let book = book(); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), services, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), services, EPOCH)], + EPOCH, + ); if tried { book.succeeded(target(), services, EPOCH + 1); } @@ -42,7 +46,11 @@ fn ordinary_selection_uses_existing_service_contract_for_new_and_tried() { #[test] fn current_services_are_rechecked_after_unlocked_policy_callback() { let book = book(); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 9, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 9, EPOCH)], + EPOCH, + ); assert_eq!( book.select(&[], &[], EPOCH, u64::MAX, |endpoint| { assert!(book.state.try_lock().is_some()); @@ -63,7 +71,11 @@ fn current_services_are_rechecked_after_unlocked_policy_callback() { #[test] fn accepted_version_service_update_is_metadata_only_canonical_and_idempotent() { let book = book(); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 9, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 9, EPOCH)], + EPOCH, + ); book.attempted(target(), true, EPOCH + 1); let (before, revision, last_good) = { let manager = book.state.lock(); @@ -165,10 +177,18 @@ fn only_unknown_dns_services_keep_native_bootstrap_exception() { #[test] fn later_gossip_service_upgrade_uses_the_single_current_field() { let book = book(); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 1, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 1, EPOCH)], + EPOCH, + ); book.succeeded(target(), 1, EPOCH + 1); assert!(!book.ordinary_services_eligible(target(), u64::MAX)); - book.learn_peer(crate::PeerRemote::Endpoint(addr(3)), &[(target(), 8, EPOCH + 2)], EPOCH + 2); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(3)), + &[(target(), 8, EPOCH + 2)], + EPOCH + 2, + ); assert!(book.ordinary_services_eligible(target(), u64::MAX)); assert_eq!( book.select(&[], &[], EPOCH + 2, u64::MAX, |_| true), diff --git a/crates/p2p/src/addrman/tests/probe_tests.rs b/crates/p2p/src/addrman/tests/probe_tests.rs index 93ccfc2af7..46fbd88e29 100644 --- a/crates/p2p/src/addrman/tests/probe_tests.rs +++ b/crates/p2p/src/addrman/tests/probe_tests.rs @@ -165,7 +165,6 @@ fn connected_collision_good_uses_ip_not_port_but_not_linked_ipv6_group() { assert_eq!(state(&book, target()).last_success, EPOCH - 20_000); } - #[test] fn anchors_consume_durably_and_return_original_metadata_only_before_dispatch() { let directory = tempfile::tempdir().expect("directory"); @@ -669,7 +668,11 @@ fn feelers_use_advertised_address_database_services_without_inventing_dns_bits() book.select(&[], &[], EPOCH, u64::MAX, |_| true), Some(target()) ); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 1, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 1, EPOCH)], + EPOCH, + ); assert_eq!(book.feeler(&[], &[], EPOCH, |_| true), Some(target())); book.state.lock().stored.records[0].services = Some(1024); assert_eq!(book.feeler(&[], &[], EPOCH, |_| true), Some(target())); diff --git a/crates/p2p/src/addrman/tests/services_tests.rs b/crates/p2p/src/addrman/tests/services_tests.rs index 18fd52b6b7..b943c963f7 100644 --- a/crates/p2p/src/addrman/tests/services_tests.rs +++ b/crates/p2p/src/addrman/tests/services_tests.rs @@ -16,7 +16,11 @@ fn v8_book_migrates_to_v9_with_backup_and_byte_identical_ip_records() { // IP record strings are identical in both schemas. let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); book.learn_dns("seed", &[target()], EPOCH); - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(addr(1), 9, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(addr(1), 9, EPOCH)], + EPOCH, + ); book.save(); let v9_payload = fs::read(&path).expect("saved book"); let mut value: serde_json::Value = @@ -49,7 +53,11 @@ fn v8_book_migrates_to_v9_with_backup_and_byte_identical_ip_records() { key: [7u8; 32], port: 8333, }; - migrated.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(tor, 9, EPOCH)], EPOCH); + migrated.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(tor, 9, EPOCH)], + EPOCH, + ); migrated.save(); let with_tor = stored_value(&path); assert!( @@ -156,15 +164,27 @@ fn incoming_known_gossip_and_unknown_dns_merge_without_losing_observation() { let book = book(); book.learn_dns("seed", &[target()], EPOCH); let revision = book.state.lock().revision; - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 0, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 0, EPOCH)], + EPOCH, + ); assert_eq!(book.state.lock().stored.records[0].services, None); assert_eq!( book.state.lock().revision, revision, "zero-bit gossip and same-time ref refusal add no new information" ); - book.learn_peer(crate::PeerRemote::Endpoint(addr(3)), &[(target(), 1, EPOCH)], EPOCH); - book.learn_peer(crate::PeerRemote::Endpoint(addr(4)), &[(target(), 8, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(3)), + &[(target(), 1, EPOCH)], + EPOCH, + ); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(4)), + &[(target(), 8, EPOCH)], + EPOCH, + ); assert_eq!(book.state.lock().stored.records[0].services, Some(9)); book.learn_dns("seed", &[target()], EPOCH + 7200); assert_eq!(book.state.lock().stored.records[0].services, Some(9)); @@ -177,7 +197,11 @@ fn incoming_known_gossip_and_unknown_dns_merge_without_losing_observation() { "unknown incoming advertisement still refreshes known peer time" ); let ip_origin = addr(5); - book.learn_peer(crate::PeerRemote::Endpoint(addr(6)), &[(ip_origin, 9, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(6)), + &[(ip_origin, 9, EPOCH)], + EPOCH, + ); book.succeeded(ip_origin, 9, EPOCH + 1); book.learn_dns("seed", &[ip_origin], EPOCH + 14_400); let manager = book.state.lock(); @@ -258,7 +282,6 @@ fn assert_preserved_invalid(base: &Path, path: &Path, bytes: &[u8]) { assert_eq!(fs::read(path).expect("untouched"), bytes); } - #[test] fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { let directory = tempfile::tempdir().expect("directory"); @@ -268,7 +291,11 @@ fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { book.state.lock().stored.secret = [1; 32]; book.learn_dns("seed", &[target()], EPOCH); let revision = book.state.lock().revision; - book.learn_peer(crate::PeerRemote::Endpoint(addr(2)), &[(target(), 0, EPOCH)], EPOCH); + book.learn_peer( + crate::PeerRemote::Endpoint(addr(2)), + &[(target(), 0, EPOCH)], + EPOCH, + ); assert_eq!(book.state.lock().stored.records[0].services, None); assert_eq!(book.state.lock().revision, revision); assert_eq!( diff --git a/crates/p2p/src/discouragement.rs b/crates/p2p/src/discouragement.rs index 0f0ddfc3f2..04ef51f02f 100644 --- a/crates/p2p/src/discouragement.rs +++ b/crates/p2p/src/discouragement.rs @@ -4,7 +4,7 @@ use bitcoin_rs_primitives::PeerAddress; use std::collections::VecDeque; use std::net::IpAddr; -use bitcoin_rs_primitives::{EndpointHost, PeerEndpoint}; +use bitcoin_rs_primitives::PeerEndpoint; use std::time::{Duration, Instant, SystemTime}; use hashbrown::{HashMap, HashSet}; @@ -104,23 +104,13 @@ impl AutomaticPeerPolicy { .contains(&PeerAddress::from(crate::EndpointHost::Ip(ip))) || self.protected.iter().any(|subnet| subnet.contains(ip))) } - PeerEndpoint::TorV3 { .. } | PeerEndpoint::I2p { .. } => { - !self - .discouraged - .contains(&PeerAddress::from(endpoint.host())) - } + PeerEndpoint::TorV3 { .. } | PeerEndpoint::I2p { .. } => !self + .discouraged + .contains(&PeerAddress::from(endpoint.host())), } } } -/// Whether an endpoint is local; overlay endpoints are never local. -pub(crate) fn is_local_endpoint(endpoint: PeerEndpoint) -> bool { - match endpoint { - PeerEndpoint::Ip(socket) => is_local(socket.ip()), - PeerEndpoint::TorV3 { .. } | PeerEndpoint::I2p { .. } => false, - } -} - /// Core `CNetAddr::IsLocal`: IPv4 0/8, 127/8 and IPv6 loopback. pub(crate) fn is_local(address: impl Into) -> bool { let Some(ip) = address.into().ip() else { diff --git a/crates/p2p/src/download_window.rs b/crates/p2p/src/download_window.rs index be9fb336bc..c532f61906 100644 --- a/crates/p2p/src/download_window.rs +++ b/crates/p2p/src/download_window.rs @@ -79,7 +79,6 @@ impl PeerRequest { pub(crate) fn len(&self) -> usize { self.entries.len() } - } /// Per-peer availability view for one request build. @@ -105,9 +104,7 @@ impl<'a> RefusalFilter<'a> { /// and its height contends for the cursor pin. pub(crate) fn keep(&mut self, hash: Hash256, height: u32) -> bool { if (self.is_refused)(hash) { - self.min_refused = Some(self.min_refused.map_or(height, |floor| { - floor.min(height) - })); + self.min_refused = Some(self.min_refused.map_or(height, |floor| floor.min(height))); false } else { true @@ -1786,14 +1783,8 @@ impl DownloadWindow { next_request_height, }; if entries.is_empty() - && let Some(request) = self.clean_contiguous_peer_request( - stager, - source, - chain_tip, - tree, - scan, - refusals, - ) + && let Some(request) = self + .clean_contiguous_peer_request(stager, source, chain_tip, tree, scan, refusals) { return Some(request); } diff --git a/crates/p2p/src/endpoint.rs b/crates/p2p/src/endpoint.rs index d15a908cc5..3d311ed356 100644 --- a/crates/p2p/src/endpoint.rs +++ b/crates/p2p/src/endpoint.rs @@ -917,5 +917,4 @@ mod tests { None ); } - } diff --git a/crates/p2p/src/inv.rs b/crates/p2p/src/inv.rs index e630d898b5..81ee384e96 100644 --- a/crates/p2p/src/inv.rs +++ b/crates/p2p/src/inv.rs @@ -160,8 +160,7 @@ mod tests { } #[test] - fn missing_parents_use_txids_and_deduplicate_repeated_inputs() - -> Result<(), Box> { + fn missing_parents_use_txids_and_deduplicate_repeated_inputs() { let table = PeerTable::new(); let (sender, receiver) = crate::test_support::message_channel_bounded(2); let lease = PeerLease::new(sender); @@ -184,12 +183,10 @@ mod tests { assert!(!request_missing_parents(&table, source, &[])); assert!(receiver.try_recv().is_err()); assert!(!lease.is_cancelled()); - Ok(()) } #[test] - fn missing_parents_request_witness_by_service_not_announcement_preference() - -> Result<(), Box> { + fn missing_parents_request_witness_by_service_not_announcement_preference() { use bitcoin::p2p::ServiceFlags; use std::sync::Arc; @@ -252,7 +249,6 @@ mod tests { assert!(!lease.is_cancelled()); } } - Ok(()) } #[test] @@ -281,8 +277,7 @@ mod tests { } #[test] - fn stale_missing_parent_source_cannot_send_to_or_cancel_replacement() - -> Result<(), Box> { + fn stale_missing_parent_source_cannot_send_to_or_cancel_replacement() { let table = PeerTable::new(); let (old_sender, old_receiver) = crate::test_support::message_channel_bounded(1); let old = PeerLease::new(old_sender); @@ -314,12 +309,10 @@ mod tests { &[parent(1)] )); assert!(matches!(new_receiver.try_recv(), Ok(Message::GetData(_)))); - Ok(()) } #[test] - fn missing_parent_request_keeps_outbound_saturation_policy() - -> Result<(), Box> { + fn missing_parent_request_keeps_outbound_saturation_policy() { let table = PeerTable::new(); let (sender, _receiver) = crate::test_support::message_channel_bounded(1); let lease = PeerLease::new(sender); @@ -333,12 +326,10 @@ mod tests { assert!(!request_missing_parents(&table, source, &[parent(1)])); assert!(lease.is_cancelled()); - Ok(()) } #[test] - fn cancelled_missing_parent_source_does_not_enqueue_a_request() - -> Result<(), Box> { + fn cancelled_missing_parent_source_does_not_enqueue_a_request() { let table = PeerTable::new(); let (sender, receiver) = crate::test_support::message_channel_bounded(1); let lease = PeerLease::new(sender); @@ -351,7 +342,6 @@ mod tests { assert!(!request_missing_parents(&table, source, &[parent(1)])); assert!(receiver.try_recv().is_err()); - Ok(()) } #[test] diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index 4b3356b3ea..54f2028ebc 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -367,14 +367,16 @@ impl ConnectionShared { let address = addr.endpoint().map(PeerEndpoint::address); let local = address.is_some_and(crate::discouragement::is_local); let mut discouraged = false; - let current = self.peer_table.with_current(lease.source(addr.endpoint()), || { - if !protected - && !local - && let Some(address) = address - { - discouraged = self.banned.discourage(address); - } - }); + let current = self + .peer_table + .with_current(lease.source(addr.endpoint()), || { + if !protected + && !local + && let Some(address) = address + { + discouraged = self.banned.discourage(address); + } + }); if current { if lease.ignores_protocol_error(error) { tracing::debug!(peer_addr = %addr, node_id = lease.node_id(), %error, protected, local, @@ -1219,15 +1221,7 @@ fn dial( let _ = peer.stream.shutdown(std::net::Shutdown::Both); return Ok(Dialed::Done); } - run_connected_session( - &mut peer, - addr, - shared, - lease, - outbound_rx, - info, - ) - .map(|()| Dialed::Done) + run_connected_session(&mut peer, addr, shared, lease, outbound_rx, info).map(|()| Dialed::Done) } /// Drives the outbound handshake until the peer is ready. @@ -1345,16 +1339,13 @@ fn spawn_handshake_thread( outbound_rx: crossbeam_channel::Receiver, ) { let peer_addr = peer_addr.into(); - let endpoint = peer_addr.endpoint(); let thread_name = format!("bitcoin-rs-p2p-handshake-{peer_addr}"); let reservation = lease.clone(); let spawn_shared = shared.clone(); let spawn_result = std::thread::Builder::new() .name(thread_name) .spawn(move || { - if let Err(error) = - run_handshake(stream, peer_addr, &shared, lease, outbound_rx) - { + if let Err(error) = run_handshake(stream, peer_addr, &shared, lease, outbound_rx) { tracing::warn!( peer_addr = %peer_addr, %error, @@ -1534,14 +1525,7 @@ fn run_handshake( info.network = bitcoin_rs_primitives::NetworkLabel::Onion; } - run_connected_session( - &mut peer, - peer_addr, - shared, - lease, - outbound_rx, - info, - ) + run_connected_session(&mut peer, peer_addr, shared, lease, outbound_rx, info) } fn publish_connected_peer( @@ -1639,13 +1623,7 @@ fn run_connected_session( "p2p handshake complete; entering message loop", ); - let loop_result = run_message_loop( - peer, - peer_addr, - &lease, - shared, - shared.ibd.as_ref(), - ); + let loop_result = run_message_loop(peer, peer_addr, &lease, shared, shared.ibd.as_ref()); let source = lease.source(peer_addr.endpoint()); if let Err(error) = &loop_result { @@ -2616,9 +2594,11 @@ fn run_message_loop( && let (Some(subject), Some(inventory)) = (info.subject(), shared.tx_inventory.as_deref()) { - shared - .peer_table - .release_package_info(lease.source(peer_addr.endpoint()), subject, inventory); + shared.peer_table.release_package_info( + lease.source(peer_addr.endpoint()), + subject, + inventory, + ); } // A first filter attempt that stopped records why, so the retry // charges its deadline only for publication or the local queue. @@ -2727,15 +2707,14 @@ fn run_message_loop( { answered_getaddr = true; if let Some(book) = &shared.address_book { - let rows = book.gossip(crate::addrman::now()); // BIP155: a peer that negotiated `sendaddrv2` // answers in `addrv2`, carrying overlay endpoints // only for transports this node has enabled; legacy // `addr` stays IP-only by construction. let reply = if peer.capabilities.addr_v2 { - crate::Message::AddrV2(rows) + crate::Message::AddrV2(book.gossip_addrv2(crate::addrman::now())) } else { - crate::Message::Addr(addr_v1_rows(&rows)) + crate::Message::Addr(addr_v1_rows(&book.gossip(crate::addrman::now()))) }; // Sent under the reservation taken before dispatch; a // closed lease ends the loop at its top. @@ -2804,7 +2783,8 @@ fn run_message_loop( )); } if tx_relay_open() - && let Err(error) = shared.send_package(lease.source(peer_addr.endpoint()), payload) + && let Err(error) = + shared.send_package(lease.source(peer_addr.endpoint()), payload) { if lease.ignores_protocol_error(&error) { shared.note_misbehavior(peer_addr, lease, &error); @@ -2827,9 +2807,10 @@ fn run_message_loop( // per-peer choice and must not gate compact-fetch // eligibility. shared.peer_table.note_compact_relay(lease.source(endpoint)); - shared - .peer_table - .note_compact_announcement(lease.source(peer_addr.endpoint()), &peer.compact_blocks); + shared.peer_table.note_compact_announcement( + lease.source(peer_addr.endpoint()), + &peer.compact_blocks, + ); if let Some(announcer) = &shared.block_announcer { announcer.reconcile_high_bandwidth_peers(); } @@ -3124,7 +3105,6 @@ fn process_compact_wire_message( shared: &ConnectionShared, ) { let peer_addr = peer_addr.into(); - let endpoint = peer_addr.endpoint(); let lease = sink.lease; // Reconstruction scans the whole mempool, so a compact block must first // earn it with its header; anything else is only a header announcement. @@ -3168,8 +3148,7 @@ fn process_compact_wire_message( // enqueue site exists; there is no direct follow-up send outside the // closure and no second enqueue. let source = lease.source(peer_addr.endpoint()); - if let Some((hash, follow_up)) = - handle_compact_outcome(outcome, sink, peer_addr, shared) + if let Some((hash, follow_up)) = handle_compact_outcome(outcome, sink, peer_addr, shared) && let Some(sync) = shared.block_sync.as_ref() { sync.enqueue_owned_body_fetch(source, hash, || sink.send(follow_up).is_ok()); @@ -4273,8 +4252,7 @@ mod writer_shutdown_tests { .peer_table .try_register_inbound(peer_addr, lease, &[], 1, 0, &shared.activity) .expect("the test lease reserves its inbound slot"); - let result = - crate::listener::run_handshake(server, peer_addr, &shared, lease, outbound_rx); + let result = crate::listener::run_handshake(server, peer_addr, &shared, lease, outbound_rx); let Err(error) = result else { panic!("handshake failure on a live lease must not be masked as revoked"); }; @@ -5008,9 +4986,8 @@ mod writer_shutdown_tests { if peer_requested_wtxid { peer.wtxid_relay.mark_peer_supported(); } - let result = run_connected_session( - &mut peer, peer_addr, &shared, lease, outbound_rx, info, - ); + let result = + run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); let _ = done_tx.send(result); }); @@ -5055,9 +5032,11 @@ mod writer_shutdown_tests { shared.peer_table.register(peer_addr, lease.clone()); if stale { let (replacement_tx, _replacement_rx) = crossbeam_channel::unbounded(); - assert!(shared - .peer_table - .register(peer_addr, crate::PeerLease::new(replacement_tx),)); + assert!( + shared + .peer_table + .register(peer_addr, crate::PeerLease::new(replacement_tx),) + ); } let info = crate::PeerInfo { @@ -5092,9 +5071,7 @@ mod writer_shutdown_tests { ), Magic::BITCOIN, ); - let _ = run_connected_session( - &mut peer, peer_addr, &shared, lease, outbound_rx, info, - ); + let _ = run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); let last_seen = book.gossip(10_000)[0].time; if block_relay || stale { @@ -5580,8 +5557,7 @@ mod ready_notify_tests { let lease = crate::PeerLease::new(tx); shared.peer_table.register(addr, lease.clone()); - let both = - crate::packages::PKG_RELAY_PKGTXNS | crate::packages::PKG_RELAY_ANC; + let both = crate::packages::PKG_RELAY_PKGTXNS | crate::packages::PKG_RELAY_ANC; assert!(shared.publish_info_and_notify_ready_with_package( addr, &lease, @@ -5592,7 +5568,10 @@ mod ready_notify_tests { let source = lease.source(Some(addr)); let token: bitcoin_rs_mempool::PeerToken = source.into(); let snapshot = shared.peer_table.package_relay_versions(token); - assert_eq!(snapshot, both, "peer-table must retain negotiated package bits"); + assert_eq!( + snapshot, both, + "peer-table must retain negotiated package bits" + ); assert_ne!( snapshot & crate::packages::PKG_RELAY_ANC, 0, @@ -6157,7 +6136,6 @@ mod address_tests { #[expect(clippy::expect_used)] mod feeler_tests { use super::*; - use std::net::SocketAddr; use std::sync::atomic::AtomicUsize; struct Scripted(std::io::Cursor>); @@ -6175,124 +6153,6 @@ mod feeler_tests { } } - fn spawn_remote_feeler_responder( - server: TcpListener, - magic: Magic, - remote_table: Arc, - address: SocketAddr, - services: u64, - revoked: u64, - ) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - let (stream, _) = server.accept().expect("accept"); - stream - .set_read_timeout(Some(Duration::from_secs(3))) - .expect("timeout"); - stream - .set_write_timeout(Some(Duration::from_secs(3))) - .expect("timeout"); - let mut peer = Peer::new(stream, magic); - let (initial, _) = peer - .read_message() - .expect("initial version") - .expect("a v1 frame"); - assert!(matches!(initial, crate::Message::Version(_))); - assert!( - remote_table - .sessions() - .iter() - .all(|session| session.info.is_none()) - ); - if revoked == 1 { - remote_table.cancel_all(); - } else if revoked == 2 { - let replacement = crate::PeerLease::new(crossbeam_channel::unbounded().0); - remote_table.register(crate::PeerEndpoint::Ip(address), replacement); - } - let sent = peer.send(&crate::Message::Version(crate::handshake::version_message( - 123, - 0, - crate::PeerRole::FullRelay, - ServiceFlags::from(services), - ))); - if revoked == 0 { - sent.expect("VERSION only"); - } - // No VERACK is sent; a feeler closes after native VERSION acceptance. - while let Ok(Some((message, _))) = peer.read_message() { - assert!( - !matches!(message, crate::Message::GetAddr | crate::Message::Verack), - "no ready/post-verack publication: {message:?}" - ); - } - }) - } - - fn run_version_only_feeler_case(services: u64, revoked: u64) { - let server = TcpListener::bind("127.0.0.1:0").expect("listener"); - let address = server.local_addr().expect("address"); - let table = Arc::new(crate::PeerTable::new()); - let mut shared = test_shared( - Arc::clone(&table), - crossbeam_channel::unbounded().0, - crossbeam_channel::unbounded().0, - ); - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = directory.path().join("peers-f9beb4d9.dat"); - let book = - crate::addrman::AddressBook::open(Some(base), shared.magic.to_bytes(), true, None); - book.learn_peer( - crate::PeerRemote::Endpoint(crate::PeerEndpoint::Ip(address)), - &[(crate::PeerEndpoint::Ip(address), 9, unix_time_secs())], - unix_time_secs(), - ); - book.set_services(crate::PeerEndpoint::Ip(address), 1); - shared.address_book = Some(Arc::clone(&book)); - shared.feeler = true; - assert!(book.queued_feeler(crate::PeerEndpoint::Ip(address))); - let notifications = Arc::new(AtomicUsize::new(0)); - let count = Arc::clone(¬ifications); - shared.peer_ready = Some(Arc::new(move |_| { - count.fetch_add(1, Ordering::Relaxed); - })); - let magic = shared.magic; - let remote_table = Arc::clone(&table); - let remote = - spawn_remote_feeler_responder(server, magic, remote_table, address, services, revoked); - spawn_outbound_connection( - crate::PeerEndpoint::Ip(address), - shared, - crate::PeerRole::BlockRelayOnly, - ) - .join() - .expect("thread") - .expect("probe"); - remote.join().expect("remote"); - assert!( - book.is_feeler(crate::PeerEndpoint::Ip(address)), - "the service reaper owns claim release" - ); - book.unqueue(crate::PeerEndpoint::Ip(address)); - assert_eq!(notifications.load(Ordering::Relaxed), 0); - assert_eq!(table.sessions().len(), usize::from(revoked == 2)); - assert!( - table - .sessions() - .iter() - .all(|session| session.info.is_none() && session.demonstrated_tips.is_empty()) - ); - book.save(); - let bytes = std::fs::read(path).expect("book"); - let stored: serde_json::Value = - serde_json::from_slice(&bytes[..bytes.len() - 32]).expect("JSON"); - assert_eq!( - stored["records"][0]["tried"], - revoked == 0, - "services={services} revoked={revoked}" - ); - } - #[test] fn version_only_feeler_uses_native_acceptance_without_publishing_work() { for (services, revoked) in [(0_u64, 0), (1, 0), (9, 0), (9, 1), (9, 2)] { @@ -6689,9 +6549,9 @@ mod discouragement_tests { let shared = shared(); let address: PeerEndpoint = "8.8.8.8:8333".parse()?; let stale = lease(false, false); - shared.peer_table.register(address, stale.clone()); - let current = lease(false, false); - shared.peer_table.register(address, current.clone()); + shared.peer_table.register(address, stale.clone()); + let current = lease(false, false); + shared.peer_table.register(address, current.clone()); let fault = PeerError::Incoming(Box::new(PeerError::BadChecksum)); shared.note_misbehavior(address, &stale, &fault); assert!(!shared.banned.is_discouraged(address.address())); @@ -6778,14 +6638,8 @@ mod discouragement_tests { malformed[20] ^= 1; // A fully delivered frame with a bad checksum. io::Write::write_all(&mut sender, &malformed)?; io::Write::write_all(&mut sender, &malformed)?; - let Err(error) = run_connected_session( - &mut peer, - address, - &shared, - lease, - rx, - info, - ) else { + let Err(error) = run_connected_session(&mut peer, address, &shared, lease, rx, info) + else { return Err("malformed frames must close the session with an error".into()); }; assert!(matches!(error, PeerError::Incoming(_))); diff --git a/crates/p2p/src/listener/aggregate_filter_tests.rs b/crates/p2p/src/listener/aggregate_filter_tests.rs index db2c864038..947982e7d8 100644 --- a/crates/p2p/src/listener/aggregate_filter_tests.rs +++ b/crates/p2p/src/listener/aggregate_filter_tests.rs @@ -235,13 +235,7 @@ fn the_first_filter_pass_records_an_aggregate_refusal() -> TestResult { peer.state = PeerState::Ready; let addr = PeerEndpoint::from(([127, 0, 0, 1], 18_444)); - run_message_loop( - &mut peer, - addr, - &lease, - &shared, - None, - )?; + run_message_loop(&mut peer, addr, &lease, &shared, None)?; assert!(lease.is_cancelled(), "the chain closed the lease"); let parked = peer diff --git a/crates/p2p/src/listener/getdata_pause_tests.rs b/crates/p2p/src/listener/getdata_pause_tests.rs index 4c64f8ca64..25a95d23e9 100644 --- a/crates/p2p/src/listener/getdata_pause_tests.rs +++ b/crates/p2p/src/listener/getdata_pause_tests.rs @@ -349,13 +349,7 @@ impl Served { let session_lease = lease.clone(); let (outcome_tx, outcome) = crossbeam_channel::bounded(1); let session = std::thread::spawn(move || { - let result = run_message_loop( - &mut peer, - addr, - &session_lease, - &shared, - None, - ); + let result = run_message_loop(&mut peer, addr, &session_lease, &shared, None); let _ = outcome_tx.send(result); }); Ok(Self { diff --git a/crates/p2p/src/listener/v2_transport_tests.rs b/crates/p2p/src/listener/v2_transport_tests.rs index 82d0901125..854b721530 100644 --- a/crates/p2p/src/listener/v2_transport_tests.rs +++ b/crates/p2p/src/listener/v2_transport_tests.rs @@ -112,15 +112,7 @@ fn handshake_and_ping(addr: PeerEndpoint, stream: TcpStream, negotiated: Negotia let (client, _) = shared(false); let lease = lease(); let mut peer = Peer::with_transport(stream, MAGIC, negotiated); - run_outbound_handshake( - &mut peer, - 7, - 0, - &lease, - deadline(), - addr, - &client, - )?; + run_outbound_handshake(&mut peer, 7, 0, &lease, deadline(), addr, &client)?; peer.send(&Message::Ping(41))?; read_until(&mut peer, &Message::Pong(41)) } @@ -203,11 +195,7 @@ fn both_ends_publish_the_negotiated_v2_session() -> TestResult { let (addr, shutdown, handle) = listen(listener_shared)?; let (dialer, dialer_table) = shared(true); - let dial = spawn_pinned_outbound_connection( - addr, - dialer, - PeerRole::FullRelay, - ); + let dial = spawn_pinned_outbound_connection(addr, dialer, PeerRole::FullRelay); let deadline = deadline(); let published = loop { if let (Some(outbound), Some(inbound)) = ( @@ -247,11 +235,7 @@ fn an_unanswered_v2_dial_retries_once_over_v1() -> TestResult { let (addr, shutdown, handle) = listen(listener_shared)?; let (dialer, dialer_table) = shared(true); - let dial = spawn_pinned_outbound_connection( - addr, - dialer, - PeerRole::FullRelay, - ); + let dial = spawn_pinned_outbound_connection(addr, dialer, PeerRole::FullRelay); let deadline = deadline(); let dialed = loop { let dialed = dialer_table.infos(); diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index ae0f7a642e..22ac8affdb 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -545,10 +545,12 @@ mod tests { } } }); - let transport = Arc::new(OverlayTransport::new(Arc::new(crate::overlay::OverlayConfig { - i2p_sam: Some(address), - ..crate::overlay::OverlayConfig::default() - }))); + let transport = Arc::new(OverlayTransport::new(Arc::new( + crate::overlay::OverlayConfig { + i2p_sam: Some(address), + ..crate::overlay::OverlayConfig::default() + }, + ))); let old = Arc::new(AtomicBool::new(true)); *transport.sam.write() = Some(Session { id: "old".into(), diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index 1bf37d2698..3f718b4c9e 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -457,7 +457,11 @@ fn sam_session_recovery(invalid_id: bool) { !replacement.is_cancelled(), "late old-epoch revocation cannot reach replacement" ); - assert!(shared.peer_table.is_current(replacement.source(Some(endpoint)))); + assert!( + shared + .peer_table + .is_current(replacement.source(Some(endpoint))) + ); assert!( !shared.banned.is_discouraged(endpoint.address()), "local router loss is not remote misbehavior" diff --git a/crates/p2p/src/peer_info.rs b/crates/p2p/src/peer_info.rs index 7a268ddd34..fe71d8cca0 100644 --- a/crates/p2p/src/peer_info.rs +++ b/crates/p2p/src/peer_info.rs @@ -144,6 +144,7 @@ impl PeerInfo { } /// Constructs a `PeerInfo` for an outbound peer from the captured remote `VersionMessage`. + #[cfg(test)] #[must_use] pub(crate) fn outbound_from_version( addr: impl Into, @@ -362,10 +363,7 @@ mod tests { let infos = table.infos(); assert_eq!(infos.len(), 1); assert_eq!(infos[0].endpoint, peer.endpoint()); - assert_eq!( - infos[0].addr_bind, - crate::TransportRemote::new(bind_socket) - ); + assert_eq!(infos[0].addr_bind, crate::TransportRemote::new(bind_socket)); assert_ne!( infos[0].endpoint, Some(crate::PeerEndpoint::Ip(infos[0].addr_bind.socket_addr())) diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index af750f993c..96261768ba 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -89,6 +89,7 @@ struct Entry { /// accepted; 0 when there is none. useful_tx_sequence: u64, /// Service epoch for anonymous Tor inbound sessions; `None` for IP/I2P. + #[cfg(test)] onion_epoch: Option, } @@ -102,6 +103,7 @@ impl Entry { announcement: PeerAnnouncementState::default(), keyed_netgroup: 0, useful_tx_sequence: 0, + #[cfg(test)] onion_epoch: None, } } @@ -248,11 +250,7 @@ impl PeerTable { if !activity.is_active() || lease.is_cancelled() { return None; } - Self::register_locked( - &mut entries, - addr, - Entry::fresh(lease.clone()), - ); + Self::register_locked(&mut entries, addr, Entry::fresh(lease.clone())); Some(lease) } @@ -345,6 +343,7 @@ impl PeerTable { /// Reserve an unidentified inbound session: capacity-only, coexists /// with known peers, never replaces by endpoint. + #[cfg(test)] #[must_use] pub(crate) fn try_register_inbound_unidentified( &self, @@ -469,11 +468,7 @@ impl PeerTable { /// the anonymous key derives from the lease, so only that connection /// can publish or be resolved through it. pub fn publish_info_unidentified(&self, lease: &PeerLease, info: PeerInfo) -> bool { - self.publish_info( - PeerRemote::AnonymousTor(lease.connection_id()), - lease, - info, - ) + self.publish_info(PeerRemote::AnonymousTor(lease.connection_id()), lease, info) } /// Publishes handshake metadata for the connection `lease` refers to. @@ -800,12 +795,9 @@ impl PeerTable { /// Returns announcement state for the current published connection. pub(crate) fn announcement_state(&self, source: PeerSource) -> Option { let entries = self.entries.read(); - entries - .get(&PeerRemote::from(source)) - .and_then(|entry| { - (entry.lease.is_current(source) && entry.info.is_some()) - .then_some(entry.announcement) - }) + entries.get(&PeerRemote::from(source)).and_then(|entry| { + (entry.lease.is_current(source) && entry.info.is_some()).then_some(entry.announcement) + }) } /// Reports whether the live published connection at `addr` requested @@ -814,14 +806,12 @@ impl PeerTable { pub(crate) fn compact_relay_of(&self, addr: impl Into) -> bool { let addr = addr.into(); let entries = self.entries.read(); - entries - .get(&addr) - .is_some_and(|entry| { - entry - .info - .as_ref() - .is_some_and(|info| info.compact_block_relay) - }) + entries.get(&addr).is_some_and(|entry| { + entry + .info + .as_ref() + .is_some_and(|info| info.compact_block_relay) + }) } /// Reports whether the live connection identified by `source` requested @@ -864,10 +854,9 @@ impl PeerTable { /// Remove an unidentified session by its lease. pub fn remove_unidentified(&self, lease: &PeerLease) -> bool { - self.remove_if( - PeerRemote::AnonymousTor(lease.connection_id()), - |current| current.same_connection(lease), - ) + self.remove_if(PeerRemote::AnonymousTor(lease.connection_id()), |current| { + current.same_connection(lease) + }) } /// Removes and cancels the connection that stamped `source`. Returns @@ -893,9 +882,7 @@ impl PeerTable { /// Removes and cancels the live connection at `addr` only when its identity is `id`. pub(crate) fn disconnect_connection(&self, addr: PeerEndpoint, id: ConnectionId) -> bool { - self.remove_if(addr.into(), |current| { - current.connection_id() == id - }) + self.remove_if(addr.into(), |current| current.connection_id() == id) } /// Removes and cancels exactly the handshake-complete connection whose @@ -998,6 +985,7 @@ impl PeerTable { /// /// Leaves IP connections, I2P connections, outbound Tor connections, and /// anonymous inbound connections from other epochs completely unaffected. + #[cfg(test)] pub(crate) fn disconnect_onion_epoch(&self, epoch: u64) -> Vec { let mut entries = self.entries.write(); let targets: Vec<(PeerRemote, ConnectionId)> = entries @@ -1113,26 +1101,6 @@ impl PeerTable { .map(|entry| entry.lease.clone()) } - /// Clones the lease for the exact connection named by `token`, whether - /// endpoint-identified or anonymous. - /// - /// Read-only: never registers and never affects capacity. Returns `None` - /// when the named connection is not live, is cancelled, or is not the - /// exact connection the token names. - #[must_use] - pub(crate) fn lease_for_token( - &self, - token: bitcoin_rs_mempool::PeerToken, - ) -> Option { - self.entries.read().iter().find_map(|(key, entry)| { - if entry.lease.is_cancelled() { - return None; - } - let current = entry.lease.source(key.endpoint()); - (bitcoin_rs_mempool::PeerToken::from(current) == token).then(|| entry.lease.clone()) - }) - } - /// Number of live connections, including those still handshaking. #[must_use] pub fn len(&self) -> usize { @@ -2632,10 +2600,9 @@ mod tests { #[test] fn overlay_identity_remote_never_uses_proxy_ip() { - use crate::connection::PeerRemote; use crate::PeerEndpoint; - let loopback = - PeerEndpoint::Ip(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + use crate::connection::PeerRemote; + let loopback = PeerEndpoint::Ip(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); let anon_id = crate::ConnectionId::for_test_raw(123); let anon = PeerRemote::AnonymousTor(anon_id); assert_eq!( diff --git a/crates/p2p/src/sam.rs b/crates/p2p/src/sam.rs index efe03b89c1..5ad8a0bb4e 100644 --- a/crates/p2p/src/sam.rs +++ b/crates/p2p/src/sam.rs @@ -45,16 +45,20 @@ use parking_lot::{Mutex, MutexGuard}; use std::cell::Cell; use std::fmt::Write as _; +#[cfg(test)] use std::fs::OpenOptions; #[cfg(test)] use std::io::BufReader; use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpStream}; +#[cfg(test)] use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use bitcoin::secp256k1::rand::{Rng, RngCore, thread_rng}; +#[cfg(test)] +use bitcoin::secp256k1::rand::Rng; +use bitcoin::secp256k1::rand::{RngCore, thread_rng}; use bitcoin_rs_primitives::endpoint::base32_encode; use sha2::{Digest, Sha256}; @@ -80,6 +84,7 @@ pub(crate) const ROOT_READINESS_BUDGET: Duration = /// waits past it while the bridge has never accepted. Only a bridge that /// connects is given [`ROOT_READINESS_BUDGET`], and once one has connected /// the window no longer applies. +#[cfg(test)] const UNREACHABLE_WINDOW: Duration = Duration::from_secs(20); /// Longest wait before a blocked root read or write notices cancellation. It @@ -89,15 +94,13 @@ const UNREACHABLE_WINDOW: Duration = Duration::from_secs(20); const CANCEL_POLL: Duration = Duration::from_millis(100); /// Retry pause growth per failed startup step, in milliseconds. +#[cfg(test)] const PAUSE_STEP_MS: u64 = 250; /// Failed startup steps after which the retry pause stops growing. +#[cfg(test)] const PAUSE_STEPS_MAX: u64 = 10; -/// Longest single pause between startup steps: the grown base plus full jitter. -pub(crate) const MAX_RETRY_PAUSE: Duration = - Duration::from_millis(PAUSE_STEP_MS * PAUSE_STEPS_MAX + PAUSE_STEP_MS); - /// Remaining budget before `deadline`. /// /// An exhausted deadline is reported as `TimedOut` rather than handed to @@ -1026,6 +1029,7 @@ impl SamSession { /// at most [`EXCHANGE_BUDGET`]. A bridge that has never accepted a /// connection is given up on after [`UNREACHABLE_WINDOW`], which also /// clips its connect waits, instead of the whole owner deadline. + #[cfg(test)] pub(crate) fn establish_startup( sam: SocketAddr, key_path: Option<&Path>, @@ -1069,6 +1073,7 @@ impl SamSession { /// itself. The first accepted connection sets `reached` for good; nothing /// here clears it, so later attempts connect, and fail, under their root /// deadline alone. + #[cfg(test)] fn retry_establish( sam: SocketAddr, private: &str, @@ -1540,6 +1545,7 @@ impl SamSession { /// that failure instead of spending a doomed attempt. Any other failure /// pauses and retries. When the deadline ends the loop, the last failure is /// reported. +#[cfg(test)] fn retry_until( deadline: Instant, cancelled: &dyn Fn() -> bool, @@ -1591,6 +1597,7 @@ fn retry_until( /// `cancelled` every [`CANCEL_POLL`], so shutdown never waits out a pause. /// Reaching the deadline is not an error here: the caller's next deadline /// check reports it with the last failure. +#[cfg(test)] fn startup_pause(failures: u64, deadline: Instant, cancelled: &dyn Fn() -> bool) -> io::Result<()> { let base = Duration::from_millis(failures.min(PAUSE_STEPS_MAX) * PAUSE_STEP_MS); let jitter = Duration::from_millis(thread_rng().gen_range(0..=PAUSE_STEP_MS)); @@ -1621,6 +1628,7 @@ fn startup_pause(failures: u64, deadline: Instant, cancelled: &dyn Fn() -> bool) /// the key pair is authoritative and still passes full validation. Any other /// `RESULT` is a failure status, refused before a key field is read even /// beside keys; only the sanitized result name reaches the error. +#[cfg(test)] pub(crate) fn generate_destination( sam: SocketAddr, timeout: Duration, @@ -1672,7 +1680,7 @@ pub(crate) fn generate_destination( /// Symbolic links in parent directories are still followed. /// /// Elsewhere: a plain read-only open with no permission or link policy. -#[cfg(unix)] +#[cfg(all(test, unix))] fn open_key_file(path: &Path) -> io::Result { use rustix::fs::{Mode, OFlags}; use std::os::unix::fs::PermissionsExt; @@ -1696,7 +1704,7 @@ fn open_key_file(path: &Path) -> io::Result { } /// Open a persisted key file (non-unix): no link or mode policy applies. -#[cfg(not(unix))] +#[cfg(all(test, not(unix)))] fn open_key_file(path: &Path) -> io::Result { std::fs::File::open(path) } @@ -1709,6 +1717,7 @@ fn open_key_file(path: &Path) -> io::Result { /// repaired to `0600` before its content is read. A symbolic link at /// `key_path` (unix), dangling or not, or any non-regular file is an /// error and never selects generation: only an absent path does. +#[cfg(test)] pub(crate) fn load_or_create_private_destination( sam: SocketAddr, key_path: &Path, @@ -1821,8 +1830,10 @@ fn validate_private_destination(text: &str) -> io::Result { Ok(text.to_owned()) } +#[cfg(test)] struct TempCleanup<'a>(&'a Path); +#[cfg(test)] impl Drop for TempCleanup<'_> { fn drop(&mut self) { let _ = std::fs::remove_file(self.0); @@ -1830,6 +1841,7 @@ impl Drop for TempCleanup<'_> { } /// Write `contents` as a new mode-`0600` file; refuse to replace. +#[cfg(test)] pub(crate) fn write_key_file(path: &Path, contents: &str) -> io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let mut nonce = [0u8; 8]; @@ -1876,6 +1888,7 @@ pub(crate) fn write_key_file(path: &Path, contents: &str) -> io::Result<()> { /// be a regular file, has its mode repaired to `0600` through that handle, /// and is read through the same handle (see `open_key_file`). Callers treat /// only `NotFound` as an absent key; every other error is refused. +#[cfg(test)] pub(crate) fn read_key_file(path: &Path) -> io::Result { let mut file = open_key_file(path)?; let mut text = String::new(); diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index 9459df438a..7c688c0c34 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -6,11 +6,12 @@ use bitcoin_rs_primitives::PeerEndpoint; use std::collections::VecDeque; -use std::net::SocketAddr; use std::io; +#[cfg(test)] +use std::net::SocketAddr; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime}; @@ -518,11 +519,6 @@ pub struct P2pService { /// Overlay service endpoints (onion service, I2P destination) the /// service currently publishes, for `getnetworkinfo` reporting. overlay_addresses: Arc>>, - /// `shutdown` calls so far. It survives the per-start epoch tokens that - /// `start` replaces, so a `start` that read it before taking the worker - /// slot sees every later `shutdown`, even one that is still waiting for - /// that slot. - stop_requests: AtomicU64, } impl std::fmt::Debug for P2pService { @@ -580,7 +576,6 @@ impl P2pService { workers: Mutex::new(None), shared_outbound: Arc::new(crate::connection::SharedOutboundBudget::new()), overlay_addresses: Arc::new(Mutex::new(Vec::new())), - stop_requests: AtomicU64::new(0), } } @@ -633,10 +628,6 @@ impl P2pService { peer_ready: &Arc, extras: crate::listener::ListenerExtras, ) -> Result<(), P2pServiceError> { - // Read before the slot is taken: a `shutdown` that increments the - // counter after this point cancels this start, whether or not it - // has reached the slot yet. - let stop_baseline = self.stop_requests.load(Ordering::Acquire); let mut slot = self.workers.lock(); if slot.is_some() { return Err(P2pServiceError::AlreadyStarted); @@ -649,14 +640,7 @@ impl P2pService { let mut workers = Workers::new(Arc::clone(&epoch_shutdown), Arc::clone(&session_cancel)); - match self.start_workers( - &mut workers, - stop_baseline, - chain_query, - sync_wake_tx, - peer_ready, - extras, - ) { + match self.start_workers(&mut workers, chain_query, sync_wake_tx, peer_ready, extras) { Ok(()) => { *slot = Some(workers); Ok(()) @@ -674,7 +658,6 @@ impl P2pService { fn start_workers( &self, workers: &mut Workers, - stop_baseline: u64, chain_query: Option<&Arc>, sync_wake_tx: Option<&Sender<()>>, peer_ready: &Arc, @@ -958,10 +941,6 @@ impl P2pService { /// Stops P2P workers and asks all current connection owners to tear down. pub fn shutdown(&self) { - // Counted first: a `start` in progress holds the worker slot below, - // and its overlay startup polls this counter, so this is what lets - // it abort instead of making this call wait out its budget. - self.stop_requests.fetch_add(1, Ordering::AcqRel); // Capture before cancelling: `anchor_peers` excludes cancelled leases, // so cancelling first would leave nothing to persist for restart. let demonstrated = anchor_peers(&self.peer_table, &self.address_book); @@ -1357,7 +1336,9 @@ fn spawn_feeler( let policy = shared.banned.automatic_snapshot(SystemTime::now()); let routes = crate::RouteTable::from_routes(&shared.dial_routes); let Some(addr) = book.feeler(&addresses, &connected, crate::addrman::now(), |addr| { - !is_manual_endpoint(manual, addr) && policy.allows(addr.address()) + !is_manual_endpoint(manual, addr) + && policy.allows(addr.address()) + && routes.denial(&addr).is_none() }) else { return; }; @@ -1401,6 +1382,7 @@ fn wait_for_shutdown(shutdown: &AtomicBool, delay: Duration) -> bool { true } +#[cfg(test)] fn bootstrap_fixed_peer_endpoints( network_active: &AtomicBool, peer_table: &crate::PeerTable, @@ -2010,6 +1992,7 @@ fn queue_restart_anchors( .address_book .ordinary_services_eligible(addr, depth) || !policy.allows(addr.address()) + || maintenance.routes.denial(&addr).is_some() { maintenance.address_book.return_restart_anchor(addr, now); continue; @@ -2041,10 +2024,6 @@ fn queue_restart_anchors( ) } -#[expect( - clippy::too_many_lines, - reason = "One maintenance loop owns anchor, collision, DNS, and candidate passes in lock order" -)] fn run_address_maintenance(maintenance: &AddressMaintenance) { let resolver = crate::peer::SystemDnsResolver::new(maintenance.port); // Give a populated book one cooldown to establish a connection before @@ -2110,11 +2089,7 @@ fn run_address_maintenance(maintenance: &AddressMaintenance) { ) { for seed in &maintenance.seeds { match crate::peer::DnsResolver::resolve(&resolver, seed) { - Ok(addresses) => maintenance.address_book.learn_dns( - seed, - &addresses, - now, - ), + Ok(addresses) => maintenance.address_book.learn_dns(seed, &addresses, now), Err(error) => tracing::warn!(seed, %error, "DNS bootstrap unavailable"), } } @@ -2244,7 +2219,9 @@ fn queue_address_candidates( let Some(addr) = maintenance .address_book .select(&active, &grouped, now, depth, |addr| { - !is_manual_endpoint(&manual, addr) && policy.allows(addr.address()) + !is_manual_endpoint(&manual, addr) + && policy.allows(addr.address()) + && maintenance.routes.denial(&addr).is_none() }) else { break; @@ -2912,7 +2889,11 @@ mod tests { for n in 1..100 { let addr = PeerEndpoint::from(([8, n, 1, 1], 8333)); book.learn_dns("seed", &[addr], 10_000); - book.learn_peer(crate::PeerRemote::Endpoint(addr), &[(addr, 9, 10_000)], 10_000); + book.learn_peer( + crate::PeerRemote::Endpoint(addr), + &[(addr, 9, 10_000)], + 10_000, + ); } ( AddressMaintenance { @@ -3215,9 +3196,11 @@ mod tests { for n in 1..100 { let addr = PeerEndpoint::from(([8, n, 1, 1], 8333)); maintenance.address_book.learn_dns("seed", &[addr], now); - maintenance - .address_book - .learn_peer(crate::PeerRemote::Endpoint(addr), &[(addr, 9, now)], now); + maintenance.address_book.learn_peer( + crate::PeerRemote::Endpoint(addr), + &[(addr, 9, now)], + now, + ); } assert!( maintenance @@ -4019,10 +4002,7 @@ mod tests { *service.workers.lock() = Some(Workers::default()); assert!(service.workers.lock().is_some()); service.shutdown(); - assert!( - lease.is_cancelled(), - "shutdown must cancel live sessions" - ); + assert!(lease.is_cancelled(), "shutdown must cancel live sessions"); let restored = crate::addrman::AddressBook::open(Some(path), Magic::REGTEST.to_bytes(), true, None); assert_eq!(restored.take_restart_anchors(now), vec![addr]); @@ -5192,10 +5172,9 @@ mod tests { let service = i2p_service(bridge.addr); let (frames, _outbound) = crossbeam_channel::unbounded(); let peer = crate::PeerLease::new(frames); - service.peer_table.register( - SocketAddr::from((Ipv4Addr::LOCALHOST, 8334)), - peer.clone(), - ); + service + .peer_table + .register(SocketAddr::from((Ipv4Addr::LOCALHOST, 8334)), peer.clone()); let observed = thread::scope(|scope| { let parked = service.overlay_addresses.lock(); diff --git a/crates/p2p/src/sync/requests.rs b/crates/p2p/src/sync/requests.rs index 05d6c8d019..8497b4e22e 100644 --- a/crates/p2p/src/sync/requests.rs +++ b/crates/p2p/src/sync/requests.rs @@ -666,7 +666,16 @@ mod tests { let mut refusals = RefusalFilter::new(&never_refused); let request = window .next_peer_request( - &mut stager, owner, false, &chain_tip, 1, 10, 0, &tree, now, &mut refusals, + &mut stager, + owner, + false, + &chain_tip, + 1, + 10, + 0, + &tree, + now, + &mut refusals, ) .unwrap_or_else(|| panic!("window must plan the test prefix")); let (_, hash) = request diff --git a/crates/p2p/src/sync/tests.rs b/crates/p2p/src/sync/tests.rs index c318c3e671..6c0c96d9da 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -3095,9 +3095,7 @@ fn notfound_from_owner_replans_the_body_to_the_alternate_once_with_one_request_p ); assert!(fixture.sync.owns_body_fetch(alternate_source, x1)); assert!( - fixture - .peers - .is_connected(fixture.owner), + fixture.peers.is_connected(fixture.owner), "a peer that lacks a body is not punished" ); @@ -3236,7 +3234,12 @@ fn two_refusing_peers_do_not_starve_a_lower_ranked_holder() -> TestResult { // holder instead of alternating between the refusers. let now = Instant::now(); assert!( - fixture.sync.scheduler.lock().body_unavailable.refuses(owner_source, x0, now), + fixture + .sync + .scheduler + .lock() + .body_unavailable + .refuses(owner_source, x0, now), "the first refusal survives the second under retention bounding" ); assert!( @@ -3384,9 +3387,7 @@ fn notfound_only_peer_is_retried_after_the_interval_and_not_starved_or_polled() "no wake-up-paced getdata/notfound loop against the only peer" ); assert!( - fixture - .peers - .is_connected(fixture.owner), + fixture.peers.is_connected(fixture.owner), "a peer that lacks a body is not disconnected" ); diff --git a/crates/p2p/src/sync/tests/chain_sync.rs b/crates/p2p/src/sync/tests/chain_sync.rs index db257a885a..ade7d38379 100644 --- a/crates/p2p/src/sync/tests/chain_sync.rs +++ b/crates/p2p/src/sync/tests/chain_sync.rs @@ -871,11 +871,7 @@ fn failed_sweep_probe_requests_headers_from_live_peer_in_same_tick() -> TestResu let connected_at = now.checked_sub(MINIMUM_CONNECT_TIME).ok_or("connect age")?; let lease = PeerLease::new_connected_at(tx, connected_at); peers.register(dead, lease.clone()); - peers.publish_info( - dead, - &lease, - synthetic_peer(dead, 5), - ); + peers.publish_info(dead, &lease, synthetic_peer(dead, 5)); let source = current_source(&peers, dead); let live_rx = connect_peer(&peers, synthetic_peer(live, 3)); @@ -942,11 +938,7 @@ fn failed_sweep_probe_reassigns_fresh_inflight_body_in_same_tick() -> TestResult let connected_at = now.checked_sub(MINIMUM_CONNECT_TIME).ok_or("connect age")?; let lease = PeerLease::new_connected_at(tx, connected_at); peers.register(dead, lease.clone()); - peers.publish_info( - dead, - &lease, - synthetic_peer(dead, 5), - ); + peers.publish_info(dead, &lease, synthetic_peer(dead, 5)); let source = current_source(&peers, dead); let chain = sync.observe_chain_frontier(); assert!( @@ -1015,11 +1007,7 @@ fn a_failed_sweep_probe_restores_the_armed_record() { .expect("the test clock predates the connect age"); let lease = PeerLease::new_connected_at(tx, connected_at); peers.register(addr, lease.clone()); - peers.publish_info( - addr, - &lease, - synthetic_peer(addr, 0), - ); + peers.publish_info(addr, &lease, synthetic_peer(addr, 0)); let source = current_source(&peers, addr); let frontier = sync.observe_frontier(sync.observe_chain_frontier(), t0); diff --git a/crates/p2p/src/sync/tests/frontier_liveness.rs b/crates/p2p/src/sync/tests/frontier_liveness.rs index cfd6b36999..54f2637c55 100644 --- a/crates/p2p/src/sync/tests/frontier_liveness.rs +++ b/crates/p2p/src/sync/tests/frontier_liveness.rs @@ -104,15 +104,12 @@ fn replacements_carry_frontier_after_serving_peer_timeouts() -> TestResult { for _ in 0..24 { std::thread::sleep(Duration::from_millis(250)); sync.tick(); - if !peers.is_connected(s1) - && !peers.is_connected(s2) - { + if !peers.is_connected(s1) && !peers.is_connected(s2) { break; } } assert!( - !peers.is_connected(s1) - && !peers.is_connected(s2), + !peers.is_connected(s1) && !peers.is_connected(s2), "both serving peers must be convicted of the block-request timeout" ); assert_eq!( diff --git a/crates/p2p/src/sync/tests/frontier_model.rs b/crates/p2p/src/sync/tests/frontier_model.rs index ab06f6c0ba..2bfe52d84a 100644 --- a/crates/p2p/src/sync/tests/frontier_model.rs +++ b/crates/p2p/src/sync/tests/frontier_model.rs @@ -128,10 +128,7 @@ fn unowned_frontier_schedules_recovery_when_a_capable_peer_exists() { assert!(plan.schedule_bodies); assert_eq!(plan.no_progress, None); match plan.header_action { - HeaderAction::Probe(source) => assert_eq!( - source.endpoint, - addr_of(9001).endpoint() - ), + HeaderAction::Probe(source) => assert_eq!(source.endpoint, addr_of(9001).endpoint()), action => panic!("expected Probe, got {action:?}"), } } @@ -311,10 +308,7 @@ fn probe_rotates_past_the_dead_pending_owner() { assert!(!frontier.header_request_live); match frontier.plan().header_action { HeaderAction::Probe(source) => { - assert_eq!( - source.endpoint, - addr_of(9001).endpoint() - ); + assert_eq!(source.endpoint, addr_of(9001).endpoint()); assert_ne!(source, dead_owner); } action => panic!("expected Probe, got {action:?}"), @@ -331,10 +325,7 @@ fn non_serving_peers_are_never_probe_picks() { vec![non_serving, usable(9002, 100, None)], ); match frontier.plan().header_action { - HeaderAction::Probe(source) => assert_eq!( - source.endpoint, - addr_of(9002).endpoint() - ), + HeaderAction::Probe(source) => assert_eq!(source.endpoint, addr_of(9002).endpoint()), action => panic!("expected Probe, got {action:?}"), } } @@ -476,11 +467,7 @@ fn convicted_connection_cannot_pass_its_stall_to_a_replacement() -> TestResult { let (tx, rx) = crate::test_support::message_channel(); let conn1 = PeerLease::new(tx); peers.register(staller, conn1.clone()); - peers.publish_info( - staller, - &conn1, - synthetic_peer(staller, 200), - ); + peers.publish_info(staller, &conn1, synthetic_peer(staller, 200)); sync.tick(); assert_applied_genesis(&applied_tip, &block_tree)?; @@ -501,9 +488,13 @@ fn convicted_connection_cannot_pass_its_stall_to_a_replacement() -> TestResult { .or_fail("regtest fixture block"); let payload = BlockPayload::from_local(block); scheduler.window.seed_front_cadence_for_test(50, now); - scheduler - .stager - .insert(tail, None, payload, Some(conn1.source(staller.endpoint())), now); + scheduler.stager.insert( + tail, + None, + payload, + Some(conn1.source(staller.endpoint())), + now, + ); scheduler .window .mark_received_from(tail, 80, Some(conn1.source(staller.endpoint())), now); @@ -535,11 +526,7 @@ fn convicted_connection_cannot_pass_its_stall_to_a_replacement() -> TestResult { let (tx2, rx2) = crate::test_support::message_channel(); let conn2 = PeerLease::new(tx2); peers.register(staller, conn2.clone()); - peers.publish_info( - staller, - &conn2, - synthetic_peer(staller, 200), - ); + peers.publish_info(staller, &conn2, synthetic_peer(staller, 200)); let owner = owner.ok_or_else(|| std::io::Error::other("missing stall owner"))?; assert!( diff --git a/crates/p2p/src/sync/tests/frontier_recovery.rs b/crates/p2p/src/sync/tests/frontier_recovery.rs index 9b6a90c258..bd5677f419 100644 --- a/crates/p2p/src/sync/tests/frontier_recovery.rs +++ b/crates/p2p/src/sync/tests/frontier_recovery.rs @@ -146,10 +146,7 @@ fn superseded_session_gets_no_probe_and_cannot_send_getheaders() -> TestResult { let old_source = current_source(&peers, peer); let (replacement_tx, replacement_rx) = crate::test_support::message_channel(); - peers.register( - peer, - PeerLease::new(replacement_tx), - ); + peers.register(peer, PeerLease::new(replacement_tx)); sync.tick(); @@ -411,10 +408,7 @@ fn reconciler_sweeps_cancelled_lease_sessions_within_one_tick() -> TestResult { let (sync, peers, _, _, _) = sync_with_header_chain(1)?; let addr = test_addr(9767, 0)?; let _rx = connect_peer(&peers, synthetic_peer(addr, 5)); - peers - .lease(addr) - .ok_or("peer missing from table")? - .cancel(); + peers.lease(addr).ok_or("peer missing from table")?.cancel(); assert!( peers .sessions() diff --git a/crates/p2p/src/sync/tests/header_rotation.rs b/crates/p2p/src/sync/tests/header_rotation.rs index 0a02459f07..428024160d 100644 --- a/crates/p2p/src/sync/tests/header_rotation.rs +++ b/crates/p2p/src/sync/tests/header_rotation.rs @@ -33,9 +33,10 @@ fn invalid_nbits_headers_disconnect_source_and_rotate_getheaders() -> TestResult "the first getheaders must target the invalid peer" ); assert!( - sync.scheduler.lock().header_request().is_some_and( - |request| request.source.endpoint == invalid_peer.endpoint() - ), + sync.scheduler + .lock() + .header_request() + .is_some_and(|request| request.source.endpoint == invalid_peer.endpoint()), "a pending getheaders must name the invalid peer before its batch arrives" ); @@ -69,11 +70,7 @@ fn invalid_nbits_headers_disconnect_source_and_rotate_getheaders() -> TestResult // Re-introduce a healthy peer; the next getheaders must rotate to it. let other_lease = crate::PeerLease::new(other_tx); peers.register(other_peer, other_lease.clone()); - peers.publish_info( - other_peer, - &other_lease, - synthetic_peer(other_peer, 8), - ); + peers.publish_info(other_peer, &other_lease, synthetic_peer(other_peer, 8)); sync.tick(); assert!( peers.is_connected(other_peer), @@ -326,11 +323,7 @@ fn same_address_reconnect_does_not_inherit_stalled_inflight() -> TestResult { let (replacement_tx, replacement_rx) = crate::test_support::message_channel(); let lease = PeerLease::new(replacement_tx); peers.register(winner, lease.clone()); - peers.publish_info( - winner, - &lease, - synthetic_peer(winner, 100), - ); + peers.publish_info(winner, &lease, synthetic_peer(winner, 100)); let replacement = lease.source(winner.endpoint()); sync.on_peer_ready(replacement); assert!( @@ -407,11 +400,7 @@ fn stall_eviction_does_not_disconnect_replacement_connection() -> TestResult { let (replacement_tx, _replacement_rx) = crate::test_support::message_channel(); let replacement = PeerLease::new(replacement_tx); peers.register(staller, replacement.clone()); - peers.publish_info( - staller, - &replacement, - synthetic_peer(staller, 200), - ); + peers.publish_info(staller, &replacement, synthetic_peer(staller, 200)); let evicted = selected.is_some_and(|owner| sync.peer_table.disconnect_source(owner)); assert!(selected.is_some()); diff --git a/crates/p2p/src/sync/tests/headers_presync.rs b/crates/p2p/src/sync/tests/headers_presync.rs index 7298aa6635..3fd95eef8b 100644 --- a/crates/p2p/src/sync/tests/headers_presync.rs +++ b/crates/p2p/src/sync/tests/headers_presync.rs @@ -113,11 +113,7 @@ fn connect( let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(addr, lease.clone()); - peers.publish_info( - addr, - &lease, - synthetic_peer(addr, start_height), - ); + peers.publish_info(addr, &lease, synthetic_peer(addr, start_height)); (addr, lease, rx) } @@ -800,11 +796,7 @@ fn replaced_connection_cannot_admit_queued_predecessor_headers() -> TestResult { let (replacement_tx, replacement_rx) = crate::test_support::message_channel(); let replacement_lease = PeerLease::new(replacement_tx); peers.register(addr, replacement_lease.clone()); - peers.publish_info( - addr, - &replacement_lease, - synthetic_peer(addr, 100_000), - ); + peers.publish_info(addr, &replacement_lease, synthetic_peer(addr, 100_000)); let replacement = current_source(&peers, addr); sync.tick(); @@ -1028,9 +1020,7 @@ fn a_forwarded_body_header_leaves_the_live_sync_state_alone() -> TestResult { "a header below the floor must not reach the tree through a body either" ); assert!( - rx.try_recv().is_err() - && !lease.is_cancelled() - && peers.is_connected(addr), + rx.try_recv().is_err() && !lease.is_cancelled() && peers.is_connected(addr), "a forwarded header sends nothing and blames no one" ); Ok(()) @@ -1196,9 +1186,7 @@ fn a_terminal_low_work_page_demotes_the_source() -> TestResult { let (addr, _lease, _rx) = connect(&peers, 9706, 100_000); let source = current_source(&peers, addr); assert_eq!( - peers - .info_of(addr) - .map(|info| info.best_known_height), + peers.info_of(addr).map(|info| info.best_known_height), Some(100_000) ); diff --git a/crates/p2p/src/sync/tests/headroom_throttling.rs b/crates/p2p/src/sync/tests/headroom_throttling.rs index 9850161156..a0f8ba0a4d 100644 --- a/crates/p2p/src/sync/tests/headroom_throttling.rs +++ b/crates/p2p/src/sync/tests/headroom_throttling.rs @@ -125,11 +125,7 @@ fn cold_start_stall_hedges_front_without_reassigning_owner() -> TestResult { .ok_or_else(|| std::io::Error::other("alternate peer info missing"))?; alternate_info.start_height = 0; alternate_info.best_known_height = 0; - assert!(peers.publish_info( - alternate, - &alternate_lease, - alternate_info - )); + assert!(peers.publish_info(alternate, &alternate_lease, alternate_info)); assert!(peers.note_announced_tip( current_source(&peers, alternate), Hash256::from_le_bytes(expected[0].as_bytes()), diff --git a/crates/p2p/src/sync/tests/historical.rs b/crates/p2p/src/sync/tests/historical.rs index 8b5f183497..a2191c5a3a 100644 --- a/crates/p2p/src/sync/tests/historical.rs +++ b/crates/p2p/src/sync/tests/historical.rs @@ -402,9 +402,7 @@ fn historical_notfound_releases_the_exact_owner_and_the_alternate_serves_it_once Some(alternate_source) ); assert!( - harness - .peers - .is_connected(addrs[owner]), + harness.peers.is_connected(addrs[owner]), "a peer that lacks a body is not punished" ); @@ -452,11 +450,7 @@ fn historical_notfound_only_peer_is_not_polled_and_is_retried_after_the_interval drain_getdata_items(&rx).is_empty(), "no wake-up-paced getdata/notfound loop against the only peer" ); - assert!( - harness - .peers - .is_connected(addr) - ); + assert!(harness.peers.is_connected(addr)); // Once the interval has passed the peer is asked exactly once more. let aged = Instant::now() diff --git a/crates/p2p/src/sync/tests/settlement_faults.rs b/crates/p2p/src/sync/tests/settlement_faults.rs index cde9b47c6e..943bd0399b 100644 --- a/crates/p2p/src/sync/tests/settlement_faults.rs +++ b/crates/p2p/src/sync/tests/settlement_faults.rs @@ -226,8 +226,7 @@ fn on_peer_ready_sweep_releases_dead_owned_requests_only() -> TestResult { )); let (stale_tx, _stale_rx) = crate::test_support::message_channel(); let stale = PeerLease::new(stale_tx); - sync.peer_table - .register(peer_addr, stale.clone()); + sync.peer_table.register(peer_addr, stale.clone()); register_info(&sync.peer_table, synthetic_peer(peer_addr, 2)); let replacement = current_source(&sync.peer_table, peer_addr); { @@ -273,11 +272,7 @@ fn far_future_matching_peer_retries_without_peer_blame() -> TestResult { let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(peer_addr, lease.clone()); - peers.publish_info( - peer_addr, - &lease, - synthetic_peer(peer_addr, 8), - ); + peers.publish_info(peer_addr, &lease, synthetic_peer(peer_addr, 8)); let tip_before = sync .chain .chain_tip() @@ -351,11 +346,7 @@ fn same_address_registration_clears_getheaders_gate_and_routes_replacement() -> let (new_tx, new_rx) = crate::test_support::message_channel(); let new_lease = PeerLease::new(new_tx); peers.register(addr, new_lease.clone()); - peers.publish_info( - addr, - &new_lease, - synthetic_peer(addr, 8), - ); + peers.publish_info(addr, &new_lease, synthetic_peer(addr, 8)); sync.tick(); assert!(old_rx.try_recv().is_err()); diff --git a/crates/p2p/src/sync/tests/window_recovery.rs b/crates/p2p/src/sync/tests/window_recovery.rs index b026283206..f3b17183d3 100644 --- a/crates/p2p/src/sync/tests/window_recovery.rs +++ b/crates/p2p/src/sync/tests/window_recovery.rs @@ -184,22 +184,14 @@ fn same_address_registration_after_window_eviction_keeps_replacement() -> TestRe let (old_tx, old_rx) = crate::test_support::message_channel(); let old = PeerLease::new(old_tx); peers.register(addr, old.clone()); - peers.publish_info( - addr, - &old, - synthetic_peer(addr, 100), - ); + peers.publish_info(addr, &old, synthetic_peer(addr, 100)); sync.tick(); let _ = old_rx.try_recv()?; let _ = old_rx.try_recv()?; let (new_tx, new_rx) = crate::test_support::message_channel(); let new = PeerLease::new(new_tx); peers.register(addr, new.clone()); - peers.publish_info( - addr, - &new, - synthetic_peer(addr, 100), - ); + peers.publish_info(addr, &new, synthetic_peer(addr, 100)); sync.tick(); assert!(peers.is_current(new.source(addr.endpoint()))); assert!(old.is_cancelled()); @@ -222,11 +214,7 @@ fn reconcile_forgets_window_state_only_when_connection_identity_changes() -> Tes let (tx, _rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(addr, lease.clone()); - peers.publish_info( - addr, - &lease, - synthetic_peer(addr, 8), - ); + peers.publish_info(addr, &lease, synthetic_peer(addr, 8)); sync.tick(); assert!(sync.scheduler.lock().header_request().is_some()); @@ -237,11 +225,7 @@ fn reconcile_forgets_window_state_only_when_connection_identity_changes() -> Tes let (new_tx, _new_rx) = crate::test_support::message_channel(); let replacement = PeerLease::new(new_tx); peers.register(addr, replacement.clone()); - peers.publish_info( - addr, - &replacement, - synthetic_peer(addr, 8), - ); + peers.publish_info(addr, &replacement, synthetic_peer(addr, 8)); sync.reconcile_peer_sessions(); assert!(sync.scheduler.lock().header_request().is_none()); assert!(sync.scheduler.lock().headers_sync.is_empty()); diff --git a/crates/p2p/src/tx_policy.rs b/crates/p2p/src/tx_policy.rs index 5c0d8e666e..2fad0ddaac 100644 --- a/crates/p2p/src/tx_policy.rs +++ b/crates/p2p/src/tx_policy.rs @@ -1719,6 +1719,10 @@ mod tests { ); } #[test] + #[expect( + clippy::too_many_lines, + reason = "Single end-to-end retirement scenario drives request, arrival, and resend phases" + )] fn known_state_and_external_arrival_retire_requests_before_another_send() { use std::sync::{ Arc, @@ -2778,8 +2782,7 @@ mod tests { // The connection ends with its body queued: nothing is re-requested. table.transaction_peer_disconnected(a, &inventory); assert!(b_rx.try_recv().is_err()); - let replacement = - PeerSource::for_test(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let replacement = PeerSource::for_test(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); assert!(!table.begin_package_resolution(replacement, subject, combined, &sequence)); assert!(!table.release_package_claim(replacement, subject, &inventory)); assert!(!table.begin_package_resolution(a, subject, combined, &sequence)); diff --git a/crates/p2p/src/tx_policy/ingress.rs b/crates/p2p/src/tx_policy/ingress.rs index cfab6fd161..3938f2a4fe 100644 --- a/crates/p2p/src/tx_policy/ingress.rs +++ b/crates/p2p/src/tx_policy/ingress.rs @@ -1282,8 +1282,7 @@ mod tests { assert_eq!(capacity.held(), 1); // A replacement connection cannot adopt the claim. - let replacement = - PeerSource::for_test(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let replacement = PeerSource::for_test(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); let rep_payload = make_payload(&[tx]); let rep_err = table.admit_package(&capacity, &inventory, replacement, rep_payload); assert_eq!(rep_err.err(), Some(PackageRefusal::Unsolicited)); diff --git a/crates/p2p/tests/core_compat.rs b/crates/p2p/tests/core_compat.rs index b2ea25df9c..0890f08e0b 100644 --- a/crates/p2p/tests/core_compat.rs +++ b/crates/p2p/tests/core_compat.rs @@ -1,7 +1,7 @@ //! Deterministic Bitcoin Core 31.1 P2P compatibility contract tests. -use std::collections::BTreeSet; use bitcoin_rs_primitives::PeerEndpoint; +use std::collections::BTreeSet; use std::error::Error; use std::io::Cursor; use std::net::{Ipv4Addr, TcpListener, TcpStream}; From 46b584f58689b7338b382d4090ddf4507235cae5 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:40 +0900 Subject: [PATCH 61/61] chore: refresh fuzz and template lockfiles Regenerate the two standalone lockfiles for the branch dependency changes. Both pass cargo metadata --locked; the template-provider tests and clippy pass. --- fuzz/Cargo.lock | 231 +++++++++++++++++- .../stratum-sv2/template-provider/Cargo.lock | 5 + 2 files changed, 235 insertions(+), 1 deletion(-) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 23667fb879..62dbb84402 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -12,6 +12,19 @@ dependencies = [ "inout", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -197,16 +210,20 @@ dependencies = [ "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-primitives", + "bitcoin-rs-script", "bitcoin-rs-storage", "bitcoin-rs-utxo", "bitcoin_hashes", "compact_str", "crossbeam-channel", "hashbrown", + "metrics", "parking_lot", "rayon", "serde", "sha2", + "sonic-rs", + "tempfile", "thiserror", "tracing", "zerocopy", @@ -266,6 +283,7 @@ name = "bitcoin-rs-primitives" version = "0.14.0" dependencies = [ "bytemuck", + "bytes", "ruint", "serde", "sha2", @@ -596,6 +614,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -612,6 +636,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "faststr" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca7d44d22004409a61c393afb3369c8f7bb74abcae49fe249ee01dcc3002113" +dependencies = [ + "bytes", + "rkyv", + "serde", + "simdutf8", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -670,6 +706,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -678,7 +726,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -742,6 +790,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.2.5" @@ -875,6 +933,26 @@ dependencies = [ "rapidhash", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -974,6 +1052,26 @@ dependencies = [ "unarray", ] +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "quanta" version = "0.13.0" @@ -998,12 +1096,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.8.8" @@ -1115,6 +1228,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytes", + "hashbrown", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "ruint" version = "1.20.1" @@ -1262,6 +1430,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1277,6 +1451,45 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "sonic-number" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c350a05f295f922fc153a3af8b443c10db70904a9291a624ddb6058c6110d0" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "sonic-rs" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756586caeeb21126a57c17d1ef2b9386e2613ba784aae0f27104f5e9982335ec" +dependencies = [ + "ahash", + "bumpalo", + "bytes", + "cfg-if", + "faststr", + "itoa", + "ref-cast", + "serde", + "simdutf8", + "sonic-number", + "sonic-simd", + "thiserror", + "zmij", +] + +[[package]] +name = "sonic-simd" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99e664ecd2d85a68c87e3c7a3cfe691f647ea9e835de984aba4d54a41f817d4" +dependencies = [ + "cfg-if", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1403,12 +1616,28 @@ dependencies = [ "ctutils", ] +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/tools/stratum-sv2/template-provider/Cargo.lock b/tools/stratum-sv2/template-provider/Cargo.lock index 770037666c..0e0314ed1a 100644 --- a/tools/stratum-sv2/template-provider/Cargo.lock +++ b/tools/stratum-sv2/template-provider/Cargo.lock @@ -278,6 +278,7 @@ dependencies = [ "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-primitives", + "bitcoin-rs-script", "bitcoin-rs-storage", "bitcoin-rs-utxo", "bitcoin_hashes 0.14.101", @@ -290,6 +291,7 @@ dependencies = [ "serde", "sha2 0.11.1", "sonic-rs", + "tempfile", "thiserror", "tracing", "zerocopy", @@ -362,6 +364,7 @@ dependencies = [ "serde", "sha2 0.11.1", "signal-hook", + "sonic-rs", "thiserror", "tracing", "tracing-subscriber", @@ -403,6 +406,7 @@ name = "bitcoin-rs-primitives" version = "0.14.0" dependencies = [ "bytemuck", + "bytes", "ruint", "serde", "sha2 0.11.1", @@ -428,6 +432,7 @@ dependencies = [ "bitcoin-rs-primitives", "bitcoin-rs-script", "bitcoin-rs-utxo", + "bytes", "compact_str", "corepc-types", "crossbeam-channel",