From e9c3e99491d88fe448b53fb8f1a2f71c92c1816e Mon Sep 17 00:00:00 2001 From: fi3 Date: Wed, 22 Jul 2026 13:56:11 +0200 Subject: [PATCH] ADD production-safe RSK merge mining without risking Bitcoin relay Inject locally validated RSK commitments into the canonical declared job while keeping every optional bridge and proof path bounded and isolated from Bitcoin processing. Correlate templates, jobs, chain context, and shares by generation, preserve pristine Bitcoin fallback before publication, and document the bridge contract and operational requirements. --- MERGE_MINING.md | 697 +++++ README.md | 67 + src/api/mod.rs | 33 +- src/jd_client/error.rs | 8 + .../job_declarator/message_handler.rs | 21 +- src/jd_client/job_declarator/mod.rs | 99 +- .../job_declarator/setup_connection.rs | 55 +- src/jd_client/mining_downstream/mod.rs | 404 ++- src/jd_client/mining_upstream/upstream.rs | 163 +- src/jd_client/template_receiver/mod.rs | 284 +- src/lib.rs | 7 +- src/merge_mining.rs | 2665 +++++++++++++++++ src/minin_pool_connection/mod.rs | 2 +- .../downstream/accept_connection.rs | 4 +- src/translator/downstream/downstream.rs | 174 +- src/translator/downstream/notify.rs | 19 +- src/translator/mod.rs | 64 +- src/translator/proxy/bridge.rs | 290 +- src/translator/proxy/task_manager.rs | 17 +- src/translator/upstream/upstream.rs | 216 +- src/translator/utils.rs | 29 +- 21 files changed, 4838 insertions(+), 480 deletions(-) create mode 100644 MERGE_MINING.md create mode 100644 src/merge_mining.rs diff --git a/MERGE_MINING.md b/MERGE_MINING.md new file mode 100644 index 00000000..d81669f7 --- /dev/null +++ b/MERGE_MINING.md @@ -0,0 +1,697 @@ +# Merge mining in `dmnd-client` + +This document explains how RSK merge mining is implemented in this proxy and defines the wire +contract for a bridge that connects the proxy to RskJ. + +The primary safety invariant is: + +> Merge mining is optional. No merge-mining failure may invalidate Bitcoin work, suppress an +> otherwise valid Bitcoin share or block solution, disconnect miners, or bring down the proxy. + +The words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** describe requirements for a compatible +bridge. Details explicitly described as current limits are implementation details of this proxy. + +## 1. Architecture + +The integration has two independent directions: + +```text +RskJ -- mnr_getWork --> bridge -- POST desired payload + target --> dmnd-client + | + validate and inject once + | + ordinary DeclareMiningJob + | + ordinary SetCustomMiningJob + | + v + miners + +miner share --> normal Bitcoin validation, relay, and solution paths + \ + +--> bounded nonblocking RSK observer --> found-job FIFO + | + bridge polls with GET + | + v + RskJ merge-mining submission RPC +``` + +The proxy and bridge must run as separately supervised processes. The bridge may fail or restart +without restarting `dmnd-client`; `dmnd-client` may lose RSK opportunities without interrupting +Bitcoin mining. + +## 2. Enabling merge mining + +All of the following are required: + +1. `dmnd-client` runs in Job Declaration mode with `--tp-address` and a reachable Template + Provider. +2. `API_SECRET` is non-empty and shared with the bridge. +3. The Template Provider honors the additional coinbase-output capacity advertised by the proxy. +4. The pool accepts every Bitcoin-consensus-valid template that also satisfies its ordinary + token/tip policy. +5. RskJ enables its merge-mining and miner RPC modules. + +Merge mining does not negotiate a private SV2 capability and does not require any pool or Job +Declaration protocol change. Setup requests and responses use the ordinary upstream protocol; +`flags = 0` is valid. + +An HTTP `202 Accepted` response does **not** prove that an RSK job was sent to miners. It means only +that the pair is stored and available for a subsequent compatible `NewTemplate`. + +The proxy advertises 100 additional serialized coinbase-output bytes to the Template Provider. +The standard RSK commitment consumes 52 bytes. + +## 3. How the proxy processes merge-mining work + +### 3.1 Desired pair + +The bridge obtains work from RskJ and sends one atomic pair to the proxy: + +- the `RSKBLOCK:` OP_RETURN payload; and +- the RSK target belonging to that exact payload. + +The most recently accepted pair remains active for later templates until another pair replaces it. +Replacement never rewrites an older template: every template generation keeps the payload and +target that were current when that generation was prepared. + +POSTing a pair does not request or synthesize a fresh Template Distribution template. The pair +becomes eligible when a subsequent compatible `NewTemplate` is processed. Jobs already published +for an older pair can remain valid and can produce found-job responses after a newer pair is +installed. + +The desired pair survives Mining, Job Declaration, and Template Provider reconnects within the +same `dmnd-client` process. It is not persisted across a complete process restart. + +### 3.2 Atomic template injection and pristine fallback + +For each compatible Template Distribution `NewTemplate`, the proxy first keeps a pristine copy. It +validates the complete MM change and then applies it to the one canonical template used by both the +miner-facing job factory and Job Declaration. The original Template Distribution template ID is +never replaced with a synthetic ID. + +The canonical template receives a zero-value output whose script is exactly: + +```text +OP_RETURN +``` + +For RSK, the payload is exactly 41 bytes: + +```text +ASCII "RSKBLOCK:" 52534b424c4f434b3a +blockHashForMergedMining 32 bytes / 64 hexadecimal characters +complete payload 41 bytes / 82 hexadecimal characters +``` + +The RskJ work hash is appended in the orientation returned by `mnr_getWork`; it is not reversed. +The output leaves all existing outputs and `coinbase_tx_value_remaining` unchanged and increments +the output count once. + +RskJ locates work by scanning the complete witness-stripped coinbase bytes without respecting +transaction-field, output, or script boundaries. The last raw occurrence of `RSKBLOCK:` must begin +the exact desired 41-byte payload, and no more than 128 bytes may follow the 32-byte work hash. The +128-byte limit is inclusive and includes later output bytes and locktime. A newly appended final +canonical output normally has only the four-byte locktime after its hash. + +The proxy still requires its own commitment to be a canonical OP_RETURN output: + +- if an existing exact canonical desired output also satisfies the raw last-tag and 128-byte rules, + it is not duplicated; +- if a different or hidden raw `RSKBLOCK:` follows it, or more than 128 bytes follow its hash, the + desired canonical commitment is appended again; +- unrelated outputs remain byte-for-byte unchanged; and +- an RSK work hash containing another raw `RSKBLOCK:` marker is rejected before it becomes active, + because RskJ could not select the leading intended commitment unambiguously. + +Before publication, the proxy applies the raw scan again to the prospective serialized outputs +plus locktime. This also rejects a later marker assembled across serialized field boundaries, such +as a work-hash suffix combined with the locktime bytes. + +Injection is rejected if the output does not fit the reserved bytes, the existing outputs cannot +be decoded, an SV2 field would overflow, MM state is unavailable, or the current coinbase converter +could cross its safe one-byte output-count range. The proxy decodes and counts every output in the +pool token and permits injection only when `template outputs + pool outputs <= 252`. With the +current one-output pool token, the canonical template may contain at most 251 outputs. A token with +multiple outputs lowers that limit accordingly, and the same full output set is used by the miner +job and Job Declaration. The exact canonical desired RSK output is accepted idempotently when it +satisfies the raw-selection rules and the final combined count is safe. If another output cannot be +appended safely, the byte-for-byte pristine template is used for the one normal Bitcoin job, as it +is on every other pre-publication MM failure. + +The modified candidate is also passed through a throwaway instance of the pinned coinbase job +builder with every pool output and the live channel's extranonce length. This verifies the complete +miner-facing coinbase prefix and suffix, not only the Template Distribution output field. The +throwaway builder cannot mutate the live channel factory. If either complete field cannot fit its +`B064K` representation, the unpublished MM generation is discarded and the byte-for-byte pristine +template is published once through the ordinary flow. + +### 3.3 One ordinary Job Declaration flow + +Every processed template produces only the existing normal sequence: + +```text +one NewTemplate -> one miner-facing extended job -> one DeclareMiningJob + -> one SetCustomMiningJob -> one pool job mapping +``` + +There is no optional token, second declaration, second custom job, capability gate, delayed upgrade, +or replacement notify. The declaration uses the coinbase prefix and suffix from that exact +miner-facing job. Custom-job responses are correlated by request ID, then map the exact local miner +job ID to its accepted pool job ID; template IDs are not used as a latest-job shortcut. + +On `SetNewPrevHash`, the proxy records immutable MM chain context first and then preserves the +existing orchestration order: start the Job Declarator transition before publishing the matching +prevhash to miners. It does not wait for the pool response before publication. + +The existing proxy publishes miner work before the ordinary declaration/custom-job exchange has +completed. This design therefore relies on the deployment requirement above: the pool accepts any +Bitcoin-consensus-valid template under its normal token/tip rules. The injected zero-value +canonical OP_RETURN is locally validated before publication and fits the pool's advertised 100-byte +allowance. A later token, tip, transport, or generic JD rejection is an ordinary Job Declaration +failure that can affect a clean job in the same way; it is not handled by a second MM attempt. + +### 3.4 Immutable job context + +Every published RSK job is bound to one immutable template generation containing: + +| Value | Source | +| --- | --- | +| Template ID | `NewTemplate.template_id` | +| Payload and target | Atomic pair applied to that generation | +| Merkle siblings | `NewTemplate.merkle_path` | +| Transaction count | `RequestTransactionDataSuccess.transaction_list.length + 1` | +| Previous block hash and `nBits` | Matching `SetNewPrevHash` | +| Coinbase prefix and suffix | Accepted live miner job | +| Miner job binding | Exact miner-facing extended job | + +The transaction count includes the coinbase and is never inferred from merkle-path length. +Non-future templates inherit the active chain state, which covers the normal +`SetNewPrevHash(A) -> NewTemplate(B, future=false)` refresh. Future templates remain incomplete +until their matching `SetNewPrevHash` arrives. A job binding immediately retains its immutable +template context; no pending-upgrade pin or second publication phase exists. + +Reused template or job IDs are separated by local generations. The transaction-data request keeps +the generation selected when the request was made; its response writes the transaction count once +to that generation rather than looking up a reusable template ID later. A share never falls back +to the newest template or to context belonging to another job. Miner-job announcements are matched +by job ID and stale earlier announcements are discarded deliberately, so one missing job cannot +shift every later merge-mining binding. + +The context behind the last miner-facing notify, the selected future job awaiting its prevhash, +and bindings already queued for ordered notify delivery are protected from bounded-history +eviction. Claiming a binding and applying that protection is atomic; when future-job coalescing +replaces a future, the discarded binding is released. Once a newer notify becomes active, older +inactive contexts are eligible for normal retirement. If every bounded slot is temporarily +protected, the incoming template remains pristine and Bitcoin-only instead of evicting context +that a miner can use. + +### 3.5 Share observation and proof construction + +After authentication and structural validation, the proxy offers each submitted share to a bounded +RSK observer before normal Bitcoin-difficulty filtering. The offer uses a nonblocking queue. A full +or unavailable observer loses only that RSK observation. + +For an observed share, the worker: + +1. resolves its exact job binding and immutable template snapshot; +2. reconstructs the full extranonce as channel extranonce1 plus submitted extranonce2; +3. reconstructs and deserializes `coinbase_prefix || full_extranonce || coinbase_suffix`; +4. verifies that the expected payload is the last canonical `RSKBLOCK:` commitment; +5. clears all coinbase input witness stacks, serializes the coinbase once, and verifies that the + expected payload starts at the last raw `RSKBLOCK:` occurrence with at most 128 trailing bytes; +6. computes the witness-stripped coinbase txid; +7. reconstructs the merkle root from the template's bottom-up sibling path; +8. builds the 80-byte Bitcoin header from the share version, timestamp and nonce plus the exact + prevhash, merkle root and `nBits`; +9. compares the header hash numerically with the template-scoped RSK target; and +10. enqueues the proof only when `bitcoin_block_hash <= rsk_target`. + +This side path never changes the result of normal Bitcoin share validation. A share or solution +continues through its configured Bitcoin relay and block-submission paths even if every RSK step +fails. + +### 3.6 Current bounds and failure policy + +| State | Current bound | Overflow/failure behavior | +| --- | ---: | --- | +| Template generations | 128 | Retire inactive old context; never evict active/queued work; otherwise use the pristine incoming template | +| Job bindings | 256 | Retire inactive old RSK reconstruction context | +| Job announcements | 256 | Retire the oldest announcement | +| Observer queue | 128 | Drop the RSK observation without delaying the share | +| Early shares awaiting context | 64 | Drop the oldest RSK observation | +| Found-job FIFO | 32 | Drop the oldest proof candidate | +| Recent proof identities | 128 | Retire the oldest deduplication identity | + +An unavailable observer makes the merge-mining API unavailable. If it is unavailable while a new +template is being prepared, the proxy uses the pristine Bitcoin template. If it fails after a job +was bound, later observations may be lost, but the job, Bitcoin shares, and Bitcoin block solution +path are unchanged. A thread-spawn failure is retried after a five-second backoff; an unexpected +worker exit is retried on the next operation that needs it. API bind failures likewise leave mining +active and retry every five seconds; an API serve failure retries after one second. + +## 4. Bridge-facing HTTP contract + +Both endpoints use JSON and the envelope: + +```json +{ + "success": true, + "message": null, + "data": {} +} +``` + +An application error uses: + +```json +{ + "success": false, + "message": "human-readable error", + "data": null +} +``` + +A bridge **MUST** treat a non-2xx status, malformed JSON, `success: false`, or missing required +success data as a failed call. + +### 4.1 Set the desired payload and target + +```http +POST /api/coinbase/op-return +Content-Type: application/json +``` + +```json +{ + "secret": "shared-api-secret", + "data_hex": "52534b424c4f434b3a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "rsk_target_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +} +``` + +Request fields: + +| Field | Requirements | +| --- | --- | +| `secret` | Exact value of the proxy's non-empty `API_SECRET` | +| `data_hex` | Non-empty, even-length hex without `0x`, maximum 80 decoded bytes | +| `rsk_target_hex` | Exactly 32 bytes of big-endian display hex; `0x` or `0X` is accepted | + +The generic endpoint accepts payloads up to 80 bytes, but only exactly `RSKBLOCK:` followed by one +32-byte work hash can produce RSK proof jobs. That 32-byte hash must not itself contain the +nine-byte `RSKBLOCK:` marker, because RskJ selects the last raw marker in the coinbase. + +Success is `202 Accepted` after the pair has been stored atomically: + +```json +{ + "success": true, + "message": null, + "data": { + "payload_len_bytes": 41, + "tx_out_len_bytes": 52, + "replaced_pending": false + } +} +``` + +`replaced_pending` is true whenever any desired pair was already stored, including an identical +pair. Reposting is valid and does not duplicate a commitment in one template. + +Actual error statuses are: + +| Status | Meaning | State change | +| --- | --- | --- | +| `400 Bad Request` | Missing/invalid target, malformed or ambiguous RSK payload, or output cannot be represented | None | +| `401 Unauthorized` | Wrong secret | None | +| `503 Service Unavailable` | `API_SECRET` is absent/empty or the RSK observer/state is unavailable | None | + +Malformed JSON may be rejected by the HTTP framework with another non-success response. + +### 4.2 Poll one found job + +```http +GET /api/merge-mining/found-job?secret=shared-api-secret +``` + +An empty queue is successful: + +```json +{ + "success": true, + "message": null, + "data": null +} +``` + +A non-empty response contains one job: + +```json +{ + "success": true, + "message": null, + "data": { + "id": 42, + "observed_at_unix_ts": 1784116800, + "template_id": 9001, + "version": 536870912, + "header_timestamp": 1784116798, + "header_nonce": 123456, + "bitcoin_block_hash_hex": "<64 lowercase hex characters>", + "block_header_hex": "<160 lowercase hex characters>", + "coinbase_tx_hex": "", + "merkle_hashes_hex": [ + "<64 lowercase hex characters per sibling>" + ], + "block_tx_count": 2048, + "op_return_payload_hex": "<82 lowercase hex characters>", + "rsk_target_hex": "<64 lowercase hex characters>" + } +} +``` + +Field requirements: + +| Field | Contract | +| --- | --- | +| `id` | Positive identifier unique during this proxy process lifetime | +| `observed_at_unix_ts` | UTC Unix seconds when the proxy observed the share | +| `template_id` | Exact Template Distribution template used for reconstruction | +| `version` | Submitted Bitcoin header version; diagnostic | +| `header_timestamp` | Submitted header timestamp; diagnostic | +| `header_nonce` | Submitted header nonce; diagnostic | +| `bitcoin_block_hash_hex` | Exactly 32 bytes in standard Bitcoin display order | +| `block_header_hex` | Exactly 80 consensus-serialized Bitcoin header bytes | +| `coinbase_tx_hex` | One valid witness-stripped Bitcoin transaction | +| `merkle_hashes_hex` | Coinbase sibling hashes only, in the format below | +| `block_tx_count` | Total block transactions including coinbase, `1..=2147483647` | +| `op_return_payload_hex` | Exact applied 41-byte RSK payload | +| `rsk_target_hex` | Exact applied 32-byte big-endian display target | + +The GET is a destructive FIFO operation: `200` with an object atomically removes that object. +`200` with `data: null` means empty. Authentication or internal failures do not intentionally pop +an item. + +Delivery is at-most-once. If the HTTP response is lost after the proxy removes the item, the proxy +does not deliver it again. A bridge therefore owns a job as soon as it receives a successful object +and **MUST** keep that job in its own bounded retry state until RskJ accepts it, the job expires, or +a terminal error makes it unusable. + +An ambiguous GET failure must not be treated as a retry of the same queue item: a later GET may pop +the next item because the first may already have been removed. The bridge should continue normal +polling and accept that the response-lost candidate is unrecoverable. It should also ignore unknown +response fields so additive proxy changes remain compatible. + +## 5. Byte order and proof validation + +A production bridge **MUST** validate a found job before submitting it to RskJ. At minimum: + +1. normalize all fixed-width hashes to lowercase 64-character hex; +2. require an 80-byte `block_header_hex` and recompute its double-SHA256 display hash; +3. require the recomputed hash to equal `bitcoin_block_hash_hex`; +4. require the numeric block hash to be less than or equal to `rsk_target_hex`; +5. deserialize exactly one coinbase transaction and reject witness-bearing serialization; +6. require the expected payload to be the last canonical `RSKBLOCK:` output and to start at the + last raw tag in the witness-stripped serialization; +7. compute the witness-stripped coinbase txid; +8. reconstruct the merkle root and compare it with the header; and +9. validate the transaction count and exact sibling count; and +10. require at most 128 bytes after the selected 32-byte RSK work hash. + +The companion `demand-rsk-op-return-bridge` is interoperable with the current proxy, but it does +not yet perform every independent check above. In particular, it trusts the proxy and RskJ for the +header-hash, last-commitment, and reconstructed-merkle-root checks. A new production bridge should +not copy that trust shortcut unless the proxy connection is inside the same trusted failure domain; +RskJ rejection still affects only merge-mining submission and never Bitcoin processing. + +The companion bridge's pending proof retry `VecDeque` also has no hard item cap. Job expiry limits +retention time but not the maximum number of retained jobs. It is therefore not production +conformant with this document's bounded-state requirement until that queue has a hard cap and a +documented eviction policy. This does not consume proxy memory or affect Bitcoin mining. + +### 5.1 Header layout + +`block_header_hex` is the normal Bitcoin consensus header: + +| Bytes | Value | Encoding | +| --- | --- | --- | +| `0..4` | version | little-endian `u32` | +| `4..36` | previous block hash | raw Bitcoin header byte order | +| `36..68` | merkle root | raw Bitcoin header byte order | +| `68..72` | timestamp | little-endian `u32` | +| `72..76` | `nBits` | little-endian `u32` | +| `76..80` | nonce | little-endian `u32` | + +The raw `SetNewPrevHash.prev_hash` bytes are already in header order and must not be reversed again. +`bitcoin_block_hash_hex` and `rsk_target_hex` are fixed-width, big-endian display values. + +### 5.2 Merkle siblings + +`merkle_hashes_hex` contains: + +- siblings only, never the coinbase txid; +- bottom-up order from the coinbase leaf to the root; +- one 32-byte lowercase string per sibling; +- standard Bitcoin display order, reversed from the raw SV2 merkle-path bytes; and +- exactly the tree height obtained by repeatedly applying `width = ceil(width / 2)` until one + node remains. + +For `block_tx_count == 1`, the array must be empty and the header merkle root must equal the +witness-stripped coinbase txid. + +### 5.3 RskJ raw commitment selection + +Let `C` be the complete witness-stripped consensus serialization of the coinbase and `H` the +32-byte work hash from this found job. A compatible producer or validating bridge must apply: + +```text +p = last byte position of ASCII "RSKBLOCK:" in C +p must exist +C[p .. p + 41] must equal "RSKBLOCK:" || H +C.length - (p + 41) must be <= 128 +``` + +The scan is byte-oriented across all fields and scripts; a marker can therefore occur in a +non-OP_RETURN script or span a serialization boundary. Witness bytes are excluded. The bound is +inclusive: 128 trailing bytes pass and 129 fail. The proxy separately requires its intended output +to use the canonical OP_RETURN form before it publishes RSK-bound work. + +## 6. RskJ-facing bridge contract + +### 6.1 Fetch work + +Call JSON-RPC 2.0: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "mnr_getWork", + "params": [] +} +``` + +Use HTTP POST with JSON. A bridge must correlate the response ID, reject a JSON-RPC `error`, and +accept work only from a successfully decoded `result`. + +The result must provide: + +| Field | Contract | +| --- | --- | +| `blockHashForMergedMining` | Exactly 32 bytes of hex | +| `target` | Exactly 32 bytes of big-endian target hex | +| `notify` | Informational boolean; it is not part of the atomic pair | + +Build `data_hex` as lowercase hex of ASCII `RSKBLOCK:` followed immediately by the normalized work +hash. Do not byte-reverse the work hash. A missing or malformed target makes this work unusable; +do not POST a partial pair. + +Only remember a pair as installed after the proxy returns a valid `202` success envelope with all +three metadata fields. Retry failed delivery. Reposting the same pair is safe. + +### 6.2 Submit a multi-transaction proof + +When `block_tx_count > 1`, call: + +```text +mnr_submitBitcoinBlockPartialMerkle( + work_hash_without_the_RSKBLOCK_tag, + block_header_hex, + witness_stripped_coinbase_tx_hex, + " ...", + lowercase_hex_block_tx_count_without_0x +) +``` + +Derive `work_hash_without_the_RSKBLOCK_tag` from this found job's +`op_return_payload_hex`, not from the bridge's newest cached work. A proof can legitimately belong +to an older pair. Keep payload and `rsk_target_hex` scoped to the found job, and never substitute +either value from current work. The bridge may submit an older proof while RskJ still recognizes +that work hash; a terminal “work not found” response retires it. + +The proxy-to-bridge values remain in standard Bitcoin display order. At the RskJ RPC boundary, the +bridge derives the witness-stripped coinbase txid and byte-reverses it and every proxy-provided +sibling into raw hash order. The sibling order remains bottom-up and unchanged. This compensates +for VETIVER's RSKIP92 proof builder reversing each submitted value internally. The sibling list +from the proxy itself never contains the coinbase txid. + +The equivalent JSON-RPC `params` value is: + +```json +[ + "<64-char work hash>", + "<160-char header>", + "", + " ", + "800" +] +``` + +The final example value is hexadecimal transaction count `0x800` without the prefix. + +### 6.3 Submit a coinbase-only block + +When `block_tx_count == 1`, construct: + +```text +raw_block_hex = block_header_hex || "01" || coinbase_tx_hex +``` + +`01` is the CompactSize transaction count. Submit it with: + +```text +mnr_submitBitcoinBlock(raw_block_hex) +``` + +### 6.4 Retry and queue policy + +After destructive GET, RskJ submission errors belong entirely to the bridge. A production bridge +**MUST** hard-bound its locally owned proof queue and define which job is evicted on overflow. It +**SHOULD** also: + +- use bounded connection and request timeouts; +- retry transport errors and transient JSON-RPC failures with bounded backoff; +- apply a longer cooldown for RskJ rate limiting; +- stop retrying malformed proofs, invalid blocks, expired work, or work RskJ no longer recognizes; +- bound the number of destructive GETs and RskJ submissions attempted per polling tick; +- deduplicate jobs for the same RSK work payload; +- continue fetching newer `mnr_getWork` while older proof submission is retrying; and +- process locally owned proofs even when a later proxy poll fails. + +Use `observed_at_unix_ts` to expire jobs. Synchronize the bridge and proxy hosts with NTP or chrony. + +## 7. Restart and resynchronization + +The proxy keeps the desired pair only in process memory. A bridge that suppresses an unchanged pair +after one successful POST can leave a restarted proxy without RSK work indefinitely. + +A compatible deployment **MUST** provide one resynchronization mechanism: + +- restart the bridge after every full `dmnd-client` restart; or +- make the bridge periodically repost the current pair; or +- detect a new proxy process/session and clear the bridge's last-installed cache. + +A simple deployment uses separate supervisors and restarts the bridge after the proxy is healthy. +Internal upstream reconnects do not require a repost because the proxy retains the desired pair and +clears only session-scoped bindings. + +## 8. Security and deployment + +The merge-mining endpoints use a shared secret but provide no TLS. The GET contract places that +secret in the query string. A production deployment must: + +- set `API_BIND_ADDRESS=127.0.0.1` when the bridge is on the same host; +- keep the API on a trusted private network or behind an authenticated TLS reverse proxy; +- firewall it from the public internet; +- avoid logging full GET URLs or query strings; +- use the same strong secret for `API_SECRET` and the bridge credential; and +- supervise the bridge independently from the proxy. + +RskJ must be started with: + +```text +-Drpc.modules.mnr.enabled=true -Dminer.server.enabled=true +``` + +Example proxy invocation: + +```sh +API_BIND_ADDRESS='127.0.0.1' \ +API_SECRET='' \ +TOKEN='' \ +cargo run -- -l info -d 'T' --tp-address='127.0.0.1:8336' +``` + +A bridge may use these configuration names, matching the companion implementation: + +| Variable | Required | Typical/default value | +| --- | --- | --- | +| `RSK_RPC_URL` | Yes | `http://127.0.0.1:4444` | +| `DMND_CLIENT_API_SECRET` | Yes | Same value as `API_SECRET` | +| `DMND_CLIENT_OP_RETURN_URL` | No | `http://127.0.0.1:3001/api/coinbase/op-return` | +| `DMND_CLIENT_FOUND_JOB_URL` | No | `http://127.0.0.1:3001/api/merge-mining/found-job` | +| `RSK_POLL_INTERVAL_SECS` | No | `1` | +| `FOUND_JOB_POLL_INTERVAL_SECS` | No | `1` | +| `JOB_RETRY_INTERVAL_SECS` | No | `5` | +| `FOUND_JOB_MAX_AGE_SECS` | No | `600` | + +These environment-variable names are not part of the wire protocol; another bridge may expose +equivalent configuration differently. + +## 9. Known miner-target limitation + +This proxy deliberately never lowers a miner's normal Bitcoin share difficulty. It evaluates every +authenticated, structurally valid share it receives before the normal Bitcoin-difficulty filter, +so an RSK-valid submitted share is not hidden by a harder upstream filter. + +An ASIC, however, reports only hashes that satisfy the target assigned to it. If the RSK target is +easier than the miner's assigned target, some hashes can satisfy RSK while never being submitted by +the ASIC. The proxy and bridge cannot observe or recover those hashes. + +This is an intentional stability-first policy: merge mining does not change miner traffic or normal +Bitcoin difficulty. It is a known deviation from a design that guarantees observation of every +RSK-valid hash. A bridge implementation cannot remove this limitation. + +## 10. Bridge conformance checklist + +A bridge is compatible when it verifies all of the following: + +1. It constructs exactly `RSKBLOCK:` plus the 32-byte RskJ work hash without reversal. +2. It sends the matching 32-byte target in the same POST and never installs half a pair. +3. It treats only `202` plus a valid success envelope as successful installation. +4. It retries failed pair delivery and provides restart resynchronization. +5. It treats `data: null` from GET as an empty queue, not an error. +6. It understands that GET is destructive and retains fetched jobs in bounded local retry state. +7. It validates header length/hash, target, the canonical output, the last raw RSK tag, the + inclusive 128-byte trailing limit, transaction count, merkle path length, and reconstructed + merkle root. +8. It derives the RskJ work hash from each found job and does not mix old proof context with the + newest cached pair. +9. It submits single-transaction blocks with `mnr_submitBitcoinBlock`. +10. It submits multi-transaction proofs with the exact RSKIP92 hash order and + `mnr_submitBitcoinBlockPartialMerkle` parameters above. +11. It retries only transient RskJ failures, expires old work, and bounds rate-limit pressure. +12. It never sends bridge failures back into the proxy's Bitcoin lifecycle. +13. Operators have verified one declaration, one custom-job request, and one miner job per affected + template, with no private capability flag or delayed second flow. +14. Operators understand and accept the miner-target limitation above. + +## 11. Current implementation map + +| Area | Source | +| --- | --- | +| HTTP route registration | `src/api/mod.rs` | +| Pair validation, template state, reconstruction, queues and API handlers | `src/merge_mining.rs` | +| Atomic template injection and pristine fallback | `src/jd_client/template_receiver/mod.rs` | +| Single ordinary declaration flow | `src/jd_client/job_declarator/mod.rs` | +| Exact custom-job response correlation | `src/jd_client/mining_upstream/upstream.rs` | +| Miner job binding, chain context and Bitcoin solution isolation | `src/jd_client/mining_downstream/mod.rs` | +| Pre-difficulty, nonblocking share observation | `src/translator/downstream/downstream.rs` | + +The companion implementation and its deeper behavioral test specification live at: + +```text +../demand/rust-backend/services/demand-rsk-op-return-bridge/ +``` diff --git a/README.md b/README.md index a0156e55..4c30dec4 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,73 @@ POOL_ADDRESSES=pool-a.example.com:20000,pool-b.example.com:20000 \ ./dmnd-client -l info -d 250T --tp-address="127.0.0.1:8336" ``` +### 4.3 RSK merge mining + +See [MERGE_MINING.md](MERGE_MINING.md) for the proxy design, safety model, complete bridge HTTP +contract, RskJ proof format, byte-order rules, and a bridge conformance checklist. + +RSK merge mining requires Job Declaration mode (`--tp-address`), a Template Provider that honors +the advertised coinbase-output allowance, RskJ, and the `demand-rsk-op-return-bridge` service from +the adjacent DEMAND repository. The client reserves 100 additional serialized coinbase-output +bytes when it advertises capacity to the Template Provider. If that reservation or an RSK +operation fails, the original Bitcoin template and Bitcoin share paths remain authoritative. + +Before an RSK proof can be queued, the client also enforces RskJ's raw-coinbase rule: the desired +payload must start at the last raw `RSKBLOCK:` marker and no more than 128 bytes may follow its hash +in the witness-stripped coinbase. It validates the prospective outputs plus locktime before +publishing the job, including markers that could be assembled across serialized field boundaries. + +Configure a non-empty secret on the DMND client and run it with the Template Provider: + +```sh +API_BIND_ADDRESS=127.0.0.1 \ +API_SECRET= \ +TOKEN= \ +./dmnd-client -l info -d 250T --tp-address="127.0.0.1:8336" +``` + +RskJ's HTTP RPC configuration must enable both merge-mining work and the miner server: + +```text +-Drpc.modules.mnr.enabled=true -Dminer.server.enabled=true +``` + +From `../demand/rust-backend`, run the bridge with the same secret: + +```sh +DMND_CLIENT_API_SECRET= \ +DMND_CLIENT_OP_RETURN_URL=http://127.0.0.1:3001/api/coinbase/op-return \ +DMND_CLIENT_FOUND_JOB_URL=http://127.0.0.1:3001/api/merge-mining/found-job \ +RSK_RPC_URL=http://127.0.0.1:4444 \ +cargo run -p demand-rsk-op-return-bridge +``` + +The companion bridge interoperates with this proxy but currently relies on the proxy and RskJ for +some proof-coherence validation, and its pending proof retry queue has no hard item cap. See the +production validation and bounded-state requirements in +[MERGE_MINING.md](MERGE_MINING.md#5-byte-order-and-proof-validation) when building or hardening a +bridge across a separate trust boundary. + +The bridge posts each atomic RSK payload/target pair to the first endpoint and destructively polls +proof candidates from the second. The client keeps at most 32 proof candidates in memory and drops +the oldest candidate on overflow; no merge-mining queue operation blocks Bitcoin share handling. +Merge mining never lowers a miner's normal Bitcoin difficulty. The bounded MM observer evaluates +every authenticated, structurally valid share the miner submits before normal Bitcoin-difficulty +filtering. If the RSK target is easier than the miner's assigned target, however, the ASIC does not +report every RSK-only hash and those unreported hashes cannot be observed. This intentional +stability-first policy avoids increasing miner traffic or changing Bitcoin difficulty. + +Run the bridge and DMND client as separate supervised processes. A bridge crash or restart must not +restart the DMND client. Whenever the DMND client process restarts, restart the bridge after the +client API is healthy so the bridge reposts the current RSK work. Pool or Job Declaration +reconnects inside the same DMND client process retain the desired pair. Synchronize the client and +bridge hosts to UTC with NTP or chrony because proof expiration uses the client's +`observed_at_unix_ts` value. + +The merge-mining endpoints use a shared secret in request data and do not provide TLS. Keep the API +on a trusted network or place it behind an authenticated TLS reverse proxy and firewall; do not +expose port 3001 directly to the Internet. + ## 5. Connect Your Miners With Bitcoin Core, sv2-tp, and the DMND Client all running, point your ASICs at the machine running the DMND Client: diff --git a/src/api/mod.rs b/src/api/mod.rs index 4faae7ab..0225548a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,7 @@ use axum::{ }; use routes::Api; use stats::StatsSender; +use tracing::{error, info}; // Holds shared state (like the router) that so that it can be accessed in all routes. #[derive(Clone)] @@ -53,6 +54,14 @@ pub(crate) async fn start( }; let app = AxumRouter::new() .route("/api/health", get(Api::health_check)) + .route( + "/api/coinbase/op-return", + post(crate::merge_mining::set_pair_api), + ) + .route( + "/api/merge-mining/found-job", + get(crate::merge_mining::poll_found_job_api), + ) .route("/api/tx/submit/{tx}", post(Api::send_tx_to_bitcoind)) .route( "/api/tx/prioritized", @@ -66,10 +75,22 @@ pub(crate) async fn start( .with_state(state); let api_server_port = crate::config::Configuration::api_server_port(); - let api_server_addr = format!("0.0.0.0:{api_server_port}"); - let listener = tokio::net::TcpListener::bind(api_server_addr) - .await - .expect("Invalid server address"); - println!("API Server listening on port {api_server_port}"); - axum::serve(listener, app).await.unwrap(); + let api_bind_address = + std::env::var("API_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string()); + let api_server_addr = format!("{api_bind_address}:{api_server_port}"); + loop { + let listener = match tokio::net::TcpListener::bind(&api_server_addr).await { + Ok(listener) => listener, + Err(error) => { + error!(%error, %api_server_addr, "API server could not bind; mining remains active"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + info!(%api_server_addr, "API server listening"); + if let Err(error) = axum::serve(listener, app.clone()).await { + error!(%error, "API server stopped; mining remains active"); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } } diff --git a/src/jd_client/error.rs b/src/jd_client/error.rs index d5c8ab01..10b3bc89 100644 --- a/src/jd_client/error.rs +++ b/src/jd_client/error.rs @@ -30,6 +30,10 @@ pub enum Error { Uint256Conversion(ParseIntError), Infallible(std::convert::Infallible), Unrecoverable, + UnsupportedCoinbaseOutputCount { + count: usize, + max: usize, + }, TaskManagerFailed, JdClientMutexCorrupted, @@ -68,6 +72,10 @@ impl fmt::Display for Error { VecToSlice32(ref e) => write!(f, "Standard Error: `{e:?}`"), Infallible(ref e) => write!(f, "Infallible Error:`{e:?}`"), Unrecoverable => write!(f, "Unrecoverable Error"), + UnsupportedCoinbaseOutputCount { count, max } => write!( + f, + "Coinbase transaction has {count} outputs; at most {max} outputs are supported" + ), JdClientMutexCorrupted => write!(f, "JdClient mutex Corrupted"), TaskManagerFailed => write!(f, "Failed to add Task in JdClient TaskManager"), diff --git a/src/jd_client/job_declarator/message_handler.rs b/src/jd_client/job_declarator/message_handler.rs index 794bb237..0f8fad74 100644 --- a/src/jd_client/job_declarator/message_handler.rs +++ b/src/jd_client/job_declarator/message_handler.rs @@ -9,6 +9,7 @@ use roles_logic_sv2::{ }; pub type SendTo = SendTo_, ()>; use roles_logic_sv2::errors::Error; +use tracing::warn; impl ParseServerJobDeclarationMessages for JobDeclarator { fn handle_allocate_mining_job_token_success( @@ -30,21 +31,25 @@ impl ParseServerJobDeclarationMessages for JobDeclarator { fn handle_declare_mining_job_error( &mut self, - _message: DeclareMiningJobError, + message: DeclareMiningJobError, ) -> Result { - // TODO consider using declarative names instead of setting states - super::super::IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); - Ok(SendTo::None(None)) + Ok(SendTo::None(Some(JobDeclaration::DeclareMiningJobError( + message.into_static(), + )))) } fn handle_provide_missing_transactions( &mut self, message: ProvideMissingTransactions, ) -> Result { - let tx_list = self - .last_declare_mining_jobs_sent - .get(&message.request_id) - .ok_or(Error::UnknownRequestId(message.request_id))? + let Some(last_declare) = self.last_declare_mining_jobs_sent.get(&message.request_id) else { + warn!( + request_id = message.request_id, + "ignoring missing-transactions request for an unknown declaration" + ); + return Ok(SendTo::None(None)); + }; + let tx_list = last_declare .clone() .ok_or(Error::JDSMissingTransactions)? .tx_list diff --git a/src/jd_client/job_declarator/mod.rs b/src/jd_client/job_declarator/mod.rs index b74c2afc..f53e76ac 100644 --- a/src/jd_client/job_declarator/mod.rs +++ b/src/jd_client/job_declarator/mod.rs @@ -42,7 +42,7 @@ use crate::{ shared::utils::AbortOnDrop, }; -use super::{error::Error, mining_upstream::Upstream}; +use super::{error::Error, mining_downstream::DownstreamJob, mining_upstream::Upstream}; #[derive(Debug, Clone)] pub struct LastDeclareJob { @@ -50,6 +50,7 @@ pub struct LastDeclareJob { template: NewTemplate<'static>, coinbase_pool_output: Vec, tx_list: Seq064K<'static, B016M<'static>>, + downstream_job: DownstreamJob, } #[derive(Debug)] @@ -71,12 +72,11 @@ pub struct JobDeclarator { NewTemplate<'static>, // pool's outputs Vec, + DownstreamJob, ), BuildNoHashHasher, >, up: Arc>, - pub coinbase_tx_prefix: B064K<'static>, - pub coinbase_tx_suffix: B064K<'static>, pub task_manager: Arc>, } @@ -116,8 +116,6 @@ impl JobDeclarator { last_set_new_prev_hash: None, future_jobs: HashMap::with_hasher(BuildNoHashHasher::default()), up, - coinbase_tx_prefix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), - coinbase_tx_suffix: vec![].try_into().expect("Internal error: this operation can not fail because Vec can always be converted into Inner"), set_new_prev_hash_counter: 0, task_manager, })); @@ -127,17 +125,14 @@ impl JobDeclarator { Ok((self_, abortable)) } - fn get_last_declare_job_sent( + fn take_last_declare_job_sent( self_mutex: &Arc>, request_id: u32, - ) -> Result { + ) -> Result, Error> { let id = self_mutex - .safe_lock(|s| s.last_declare_mining_jobs_sent.remove(&request_id).clone()) + .safe_lock(|s| s.last_declare_mining_jobs_sent.remove(&request_id)) .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; - Ok(id - .expect("Impossible to get last declare job sent") - .clone() - .expect("This is ok")) + Ok(id.flatten()) } fn update_last_declare_job_sent( @@ -219,13 +214,14 @@ impl JobDeclarator { } } - pub async fn on_new_template( + pub(crate) async fn on_new_template( self_mutex: &Arc>, template: NewTemplate<'static>, token: Vec, tx_list_: Seq064K<'static, B016M<'static>>, excess_data: B064K<'static>, coinbase_pool_output: Vec, + downstream_job: DownstreamJob, ) -> Result<(), Error> { let now = std::time::Instant::now(); while !super::IS_CUSTOM_JOB_SET.load(std::sync::atomic::Ordering::Acquire) { @@ -275,13 +271,8 @@ impl JobDeclarator { } let tx_ids: Seq064K<'static, U256> = Seq064K::from(tx_ids); - let coinbase_prefix = self_mutex - .safe_lock(|s| s.coinbase_tx_prefix.clone()) - .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; - - let coinbase_suffix = self_mutex - .safe_lock(|s| s.coinbase_tx_suffix.clone()) - .map_err(|_| Error::JobDeclaratorMutexCorrupted)?; + let coinbase_prefix = downstream_job.coinbase_tx_prefix.clone(); + let coinbase_suffix = downstream_job.coinbase_tx_suffix.clone(); let declare_job = DeclareMiningJob { request_id: id, @@ -297,6 +288,7 @@ impl JobDeclarator { template, coinbase_pool_output, tx_list: tx_list_.clone(), + downstream_job, }; Self::update_last_declare_job_sent(self_mutex, id, last_declare)?; let frame: StdFrame = @@ -350,8 +342,15 @@ impl JobDeclarator { Ok(SendTo::None(Some(JobDeclaration::DeclareMiningJobSuccess(m)))) => { let new_token = m.new_mining_job_token; let last_declare = - match Self::get_last_declare_job_sent(&self_mutex, m.request_id) { - Ok(last_declare) => last_declare, + match Self::take_last_declare_job_sent(&self_mutex, m.request_id) { + Ok(Some(last_declare)) => last_declare, + Ok(None) => { + warn!( + request_id = m.request_id, + "ignoring unknown or late mining-job declaration success" + ); + continue; + } Err(e) => { error!("{e}"); ProxyState::update_jd_state(JdState::Down); @@ -363,6 +362,7 @@ impl JobDeclarator { let id = last_declare.template.template_id; let merkle_path = last_declare.template.merkle_path.clone(); let template = last_declare.template; + let downstream_job = last_declare.downstream_job; // TODO where we should have a sort of signaling that is green after // that the token has been updated so that on_set_new_prev_hash know it @@ -377,6 +377,7 @@ impl JobDeclarator { merkle_path, template, last_declare.coinbase_pool_output, + downstream_job, ), ); }) { @@ -410,13 +411,23 @@ impl JobDeclarator { template.coinbase_tx_value_remaining, pool_outs, template.coinbase_tx_locktime, - template.template_id + template.template_id, + downstream_job, ).await {error!("Failed to set custom jobd: {e}"); ProxyState::update_jd_state(JdState::Down);break;}, None => panic!("Invalid state we received a NewTemplate not future, without having received a set new prev hash") } } } Ok(SendTo::None(Some(JobDeclaration::DeclareMiningJobError(m)))) => { + let removed = self_mutex + .safe_lock(|state| { + state.last_declare_mining_jobs_sent.remove(&m.request_id) + }) + .unwrap_or(None); + if removed.is_some() { + super::IS_CUSTOM_JOB_SET + .store(true, std::sync::atomic::Ordering::Release); + } error!("Job is not verified: {:?}", m); } Ok(SendTo::None(None)) => (), @@ -471,7 +482,7 @@ impl JobDeclarator { error!("{}", Error::JobDeclaratorMutexCorrupted); return; }; - let (job, up, merkle_path, template, mut pool_outs) = loop { + let (job, up, merkle_path, template, mut pool_outs, downstream_job) = loop { match self_mutex.safe_lock(|s| { if s.set_new_prev_hash_counter > 1 && s.last_set_new_prev_hash != Some(set_new_prev_hash.clone()) @@ -480,13 +491,20 @@ impl JobDeclarator { s.set_new_prev_hash_counter -= 1; Some(None) } else { - s.future_jobs - .remove(&id) - .map(|(job, merkle_path, template, pool_outs)| { + s.future_jobs.remove(&id).map( + |(job, merkle_path, template, pool_outs, downstream_job)| { s.future_jobs = HashMap::with_hasher(BuildNoHashHasher::default()); s.set_new_prev_hash_counter -= 1; - Some((job, s.up.clone(), merkle_path, template, pool_outs)) - }) + Some(( + job, + s.up.clone(), + merkle_path, + template, + pool_outs, + downstream_job, + )) + }, + ) } }) { Ok(Some(Some(future_job_tuple))) => break future_job_tuple, @@ -522,6 +540,7 @@ impl JobDeclarator { pool_outs, template.coinbase_tx_locktime, template.template_id, + downstream_job, ) .await { @@ -534,20 +553,20 @@ impl JobDeclarator { } async fn allocate_tokens(self_mutex: &Arc>, token_to_allocate: u32) { - for i in 0..token_to_allocate { + for _ in 0..token_to_allocate { + let (request_id, sender) = + match self_mutex.safe_lock(|state| (state.req_ids.next(), state.sender.clone())) { + Ok(request) => request, + Err(error) => { + error!(%error, "Job declarator state is unavailable"); + ProxyState::update_jd_state(JdState::Down); + return; + } + }; let message = JobDeclaration::AllocateMiningJobToken(AllocateMiningJobToken { user_identifier: "todo".to_string().try_into().expect("Infallible operation"), - request_id: i, + request_id, }); - let sender = match self_mutex.safe_lock(|s| s.sender.clone()) { - Ok(sender) => sender, - Err(e) => { - error!("{e}"); - //Poison lock - ProxyState::update_jd_state(JdState::Down); - return; - } - }; // Safe unwrap message is build above and is valid, below can never panic let frame: StdFrame = PoolMessages::JobDeclaration(message) diff --git a/src/jd_client/job_declarator/setup_connection.rs b/src/jd_client/job_declarator/setup_connection.rs index f36c9710..74f7c854 100644 --- a/src/jd_client/job_declarator/setup_connection.rs +++ b/src/jd_client/job_declarator/setup_connection.rs @@ -2,7 +2,7 @@ use crate::config::Configuration; use codec_sv2::{StandardEitherFrame, StandardSv2Frame}; use rand::distributions::{Alphanumeric, DistString}; use roles_logic_sv2::{ - common_messages_sv2::{Protocol, SetupConnection}, + common_messages_sv2::{Protocol, SetupConnection, SetupConnectionSuccess}, handlers::common::{ParseUpstreamCommonMessages, SendTo}, parsers::PoolMessages, routing_logic::{CommonRoutingLogic, NoRouting}, @@ -10,12 +10,15 @@ use roles_logic_sv2::{ }; use std::{convert::TryInto, net::SocketAddr, sync::Arc}; use tokio::sync::mpsc::{Receiver as TReceiver, Sender as TSender}; -use tracing::error; +use tracing::{error, warn}; pub type Message = PoolMessages<'static>; pub type StdFrame = StandardSv2Frame; pub type EitherFrame = StandardEitherFrame; -pub struct SetupConnectionHandler {} +#[derive(Default)] +pub struct SetupConnectionHandler { + setup_succeeded: bool, +} impl SetupConnectionHandler { fn get_setup_connection_message(proxy_address: SocketAddr) -> SetupConnection<'static> { @@ -78,35 +81,67 @@ impl SetupConnectionHandler { .ok_or(crate::jd_client::error::Error::Unrecoverable)? .msg_type(); let payload = incoming.payload(); + let handler = Arc::new(Mutex::new(SetupConnectionHandler::default())); ParseUpstreamCommonMessages::handle_message_common( - Arc::new(Mutex::new(SetupConnectionHandler {})), + handler.clone(), message_type, payload, CommonRoutingLogic::None, )?; - Ok(()) + let setup_succeeded = handler + .safe_lock(|state| state.setup_succeeded) + .map_err(|_| crate::jd_client::error::Error::JobDeclaratorMutexCorrupted)?; + if setup_succeeded { + Ok(()) + } else { + Err(crate::jd_client::error::Error::Unrecoverable) + } } } impl ParseUpstreamCommonMessages for SetupConnectionHandler { fn handle_setup_connection_success( &mut self, - _: roles_logic_sv2::common_messages_sv2::SetupConnectionSuccess, + _: SetupConnectionSuccess, ) -> Result { + self.setup_succeeded = true; Ok(SendTo::None(None)) } fn handle_setup_connection_error( &mut self, - _: roles_logic_sv2::common_messages_sv2::SetupConnectionError, + message: roles_logic_sv2::common_messages_sv2::SetupConnectionError, ) -> Result { - todo!() + warn!(?message, "Job Declaration setup was rejected"); + Ok(SendTo::None(None)) } fn handle_channel_endpoint_changed( &mut self, - _: roles_logic_sv2::common_messages_sv2::ChannelEndpointChanged, + message: roles_logic_sv2::common_messages_sv2::ChannelEndpointChanged, ) -> Result { - todo!() + warn!( + ?message, + "Unexpected channel endpoint change during Job Declaration setup" + ); + Ok(SendTo::None(None)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_success_with_zero_flags_is_accepted() { + let mut handler = SetupConnectionHandler::default(); + handler + .handle_setup_connection_success(SetupConnectionSuccess { + used_version: 2, + flags: 0, + }) + .expect("setup success"); + + assert!(handler.setup_succeeded); } } diff --git a/src/jd_client/mining_downstream/mod.rs b/src/jd_client/mining_downstream/mod.rs index f8ff8d59..8691c148 100644 --- a/src/jd_client/mining_downstream/mod.rs +++ b/src/jd_client/mining_downstream/mod.rs @@ -1,21 +1,24 @@ mod task_manager; use crate::{ - proxy_state::{DownstreamType, JdState, ProxyState}, + proxy_state::{DownstreamType, JdState, ProxyState, UpstreamType}, shared::utils::AbortOnDrop, }; use tokio::time::{timeout, Duration}; use super::{job_declarator::JobDeclarator, mining_upstream::Upstream as UpstreamMiningNode}; use crate::jd_client::error::Error as JdClientError; +use binary_sv2::B064K; use roles_logic_sv2::{ channel_logic::channel_factory::{OnNewShare, PoolChannelFactory, Share}, common_properties::{CommonDownstreamData, IsDownstream, IsMiningDownstream}, errors::Error, handlers::mining::{ParseDownstreamMiningMessages, SendTo, SupportedChannelTypes}, - job_creator::JobsCreators, + job_creator::{tx_outputs_to_costum_scripts, JobsCreators}, mining_sv2::*, parsers::{Mining, MiningDeviceMessages}, - template_distribution_sv2::{NewTemplate, SubmitSolution}, + template_distribution_sv2::{ + NewTemplate, SetNewPrevHash as TemplateSetNewPrevHash, SubmitSolution, + }, utils::Mutex, }; use task_manager::TaskManager; @@ -33,6 +36,15 @@ pub type Message = MiningDeviceMessages<'static>; pub type StdFrame = StandardSv2Frame; pub type EitherFrame = StandardEitherFrame; +const MAX_SUPPORTED_COINBASE_OUTPUTS: usize = 252; + +#[derive(Clone, Debug)] +pub(crate) struct DownstreamJob { + pub(crate) local_job_id: u32, + pub(crate) coinbase_tx_prefix: B064K<'static>, + pub(crate) coinbase_tx_suffix: B064K<'static>, +} + /// 1 to 1 connection with a downstream node that implement the mining (sub)protocol can be either /// a mining device or a downstream proxy. /// A downstream can only be linked with an upstream at a time. Support multi upstrems for @@ -45,8 +57,6 @@ pub struct DownstreamMiningNode { solution_sender: TSender>, withhold: bool, miner_coinbase_output: Vec, - // used to retreive the job id of the share that we send upstream - last_template_id: u64, pub jd: Option>>, } @@ -114,6 +124,80 @@ use core::convert::TryInto; use std::sync::Arc; impl DownstreamMiningNode { + pub(crate) fn decode_pool_coinbase_outputs( + mut encoded_outputs: &[u8], + ) -> Result, JdClientError> { + let mut outputs = Vec::new(); + while !encoded_outputs.is_empty() { + outputs.push( + TxOut::consensus_decode(&mut encoded_outputs) + .map_err(|_| JdClientError::Unrecoverable)?, + ); + } + Ok(outputs) + } + + pub(crate) fn preview_template_job( + self_mutex: &Arc>, + template: NewTemplate<'static>, + pool_output: &[u8], + ) -> Result<(B064K<'static>, B064K<'static>), JdClientError> { + let extranonce_len = self_mutex + .safe_lock(|state| { + state + .status + .get_channel() + .map(|channel| channel.get_extranonce_len()) + }) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)? + .map_err(JdClientError::RolesSv2Logic)?; + let pool_outputs = Self::decode_pool_coinbase_outputs(pool_output)?; + Self::preview_template_job_with_extranonce(template, pool_outputs, extranonce_len) + } + + fn preview_template_job_with_extranonce( + mut template: NewTemplate<'static>, + pool_outputs: Vec, + extranonce_len: usize, + ) -> Result<(B064K<'static>, B064K<'static>), JdClientError> { + if pool_outputs.is_empty() { + return Err(JdClientError::Unrecoverable); + } + let extranonce_len = + u8::try_from(extranonce_len).map_err(|_| JdClientError::Unrecoverable)?; + let mut creator = JobsCreators::new(extranonce_len); + // The throwaway creator only computes the exact coinbase fields. Its pinned + // implementation increments template_id without checking overflow, so never pass it a + // peer-controlled ID. + template.template_id = 0; + let job = creator + .on_new_template(&mut template, true, pool_outputs, 0) + .map_err(JdClientError::RolesSv2Logic)?; + Ok((job.coinbase_tx_prefix, job.coinbase_tx_suffix)) + } + + fn validate_final_coinbase_output_count( + template: &NewTemplate<'static>, + additional_output_count: usize, + ) -> Result<(), JdClientError> { + // JobsCreators uses the serialized outputs, rather than trusting the declared count. + let template_output_count = + tx_outputs_to_costum_scripts(template.coinbase_tx_outputs.as_ref()).len(); + let final_output_count = template_output_count + .checked_add(additional_output_count) + .ok_or(JdClientError::UnsupportedCoinbaseOutputCount { + count: usize::MAX, + max: MAX_SUPPORTED_COINBASE_OUTPUTS, + })?; + if final_output_count > MAX_SUPPORTED_COINBASE_OUTPUTS { + return Err(JdClientError::UnsupportedCoinbaseOutputCount { + count: final_output_count, + max: MAX_SUPPORTED_COINBASE_OUTPUTS, + }); + } + Ok(()) + } + #[allow(clippy::too_many_arguments)] pub fn new( sender: TSender>, @@ -134,10 +218,6 @@ impl DownstreamMiningNode { solution_sender, withhold, miner_coinbase_output, - // set it to an arbitrary value cause when we use it we always updated it. - // Is used before sending the share to upstream in the main loop when we have a share. - // Is upated in the message handler that si called earlier in the main loop. - last_template_id: 0, jd, } } @@ -257,42 +337,40 @@ impl DownstreamMiningNode { } Ok(SendTo::RelayNewMessage(Mining::SubmitSharesExtended(mut share))) => { tokio::task::spawn(async move { - // If we have a realy new message it means that we are in a pooled mining mods. - if let Some(upstream_mutex) = self_mutex - .safe_lock(|s| s.status.get_upstream()) - .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted) - .unwrap() + let local_job_id = share.job_id; + let upstream_mutex = match self_mutex + .safe_lock(|state| state.status.get_upstream()) { - // When re receive SetupConnectionSuccess we link the last_template_id with the - // pool's job_id. The below return as soon as we have a pairable job id for the - // template_id associated with this share. - let last_template_id = self_mutex - .safe_lock(|s| s.last_template_id) - .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted) - .unwrap(); - let job_id_future = - UpstreamMiningNode::get_job_id(&upstream_mutex, last_template_id); - //?check - if let Ok(Ok(job_id)) = - timeout(Duration::from_secs(20), job_id_future).await - { - share.job_id = job_id; - debug!( - "Sending valid block solution upstream, with job_id {}", - job_id + Ok(Some(upstream)) => upstream, + Ok(None) => { + error!("Upstream is unavailable while relaying a share"); + ProxyState::update_downstream_state( + DownstreamType::JdClientMiningDownstream, ); - let message = Mining::SubmitSharesExtended(share); - UpstreamMiningNode::send(&upstream_mutex, message) - .await - .unwrap(); - } else { - error!("Timeout getting job_id for last_template_id: {last_template_id}, discard share"); + return; + } + Err(error) => { + error!(%error, "Downstream state is unavailable while relaying a share"); + ProxyState::update_downstream_state( + DownstreamType::JdClientMiningDownstream, + ); + return; + } + }; + + let job_id_future = + UpstreamMiningNode::get_job_id(&upstream_mutex, local_job_id); + if let Ok(Ok(job_id)) = timeout(Duration::from_secs(20), job_id_future).await { + share.job_id = job_id; + debug!("Relaying share upstream with pool job_id {}", job_id); + let message = Mining::SubmitSharesExtended(share); + if let Err(error) = UpstreamMiningNode::send(&upstream_mutex, message).await + { + error!(%error, "Failed to relay share upstream"); + ProxyState::update_upstream_state(UpstreamType::JDCMiningUpstream); } } else { - error!("Upstream is None Here"); - ProxyState::update_downstream_state( - DownstreamType::JdClientMiningDownstream, - ); + error!(local_job_id, "Timeout getting pool job id; discard share"); } }); } @@ -343,11 +421,12 @@ impl DownstreamMiningNode { .map_err(|_| JdClientError::Unrecoverable) } - pub async fn on_new_template( + pub(crate) async fn on_new_template( self_mutex: &Arc>, mut new_template: NewTemplate<'static>, pool_output: &[u8], - ) -> Result<(), JdClientError> { + template_generation: Option, + ) -> Result, JdClientError> { // Make sure to set the template handled to true since we do not have a channel opened yet // and template can not be handled without it we will lock template handling forever. if !self_mutex @@ -355,54 +434,57 @@ impl DownstreamMiningNode { .map_err(|e| Error::PoisonLock(e.to_string()))? { super::IS_NEW_TEMPLATE_HANDLED.store(true, std::sync::atomic::Ordering::Release); - return Ok(()); + return Ok(None); } - let mut pool_out = &pool_output[0..]; - let pool_output = - TxOut::consensus_decode(&mut pool_out).expect("Upstream sent an invalid coinbase"); - - let to_send = { - let pool_outputs = self_mutex - .safe_lock(|s| { - let channel = s.status.get_channel().map_err(JdClientError::RolesSv2Logic); - - match channel { - Ok(channel) => { - channel.update_pool_outputs(vec![pool_output]); - match channel.on_new_template(&mut new_template) { - Ok(pool_outputs) => Ok(pool_outputs), - Err(e) => Err(JdClientError::RolesSv2Logic(e)), - } - } - Err(e) => Err(e), - } - }) - .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)?; - pool_outputs? - }; + let pool_outputs = Self::decode_pool_coinbase_outputs(pool_output)?; + Self::validate_final_coinbase_output_count(&new_template, pool_outputs.len())?; + + let to_send = self_mutex + .safe_lock(|state| { + let channel = state + .status + .get_channel() + .map_err(JdClientError::RolesSv2Logic)?; + channel.update_pool_outputs(pool_outputs); + channel + .on_new_template(&mut new_template) + .map_err(JdClientError::RolesSv2Logic) + }) + .map_err(|_| JdClientError::JdClientDownstreamMutexCorrupted)??; // to_send is HashMap but here we have only one downstream so // only one channel opened downstream. That means that we can take all the messages in the // map and send them downstream. - let to_send = to_send.into_values(); - for message in to_send { - let message = if let Mining::NewExtendedMiningJob(job) = message { - let jd = self_mutex - .safe_lock(|s| s.jd.clone()) - .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)? - .ok_or({ - // Propagate error. The caller will restart proxy - JdClientError::JdMissing - })?; - jd.safe_lock(|jd| jd.coinbase_tx_prefix = job.coinbase_tx_prefix.clone()) - .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)?; - jd.safe_lock(|jd| jd.coinbase_tx_suffix = job.coinbase_tx_suffix.clone()) - .map_err(|_| JdClientError::JobDeclaratorMutexCorrupted)?; - - Mining::NewExtendedMiningJob(job) + let messages = to_send.into_values().collect::>(); + let downstream_job = messages.iter().find_map(|message| { + if let Mining::NewExtendedMiningJob(job) = message { + Some(DownstreamJob { + local_job_id: job.job_id, + coinbase_tx_prefix: job.coinbase_tx_prefix.clone(), + coinbase_tx_suffix: job.coinbase_tx_suffix.clone(), + }) } else { - message - }; + None + } + }); + + if crate::merge_mining::enabled() { + if let Some(job) = &downstream_job { + let bound = template_generation.is_some_and(|generation| { + crate::merge_mining::global().bind_job_generation( + job.local_job_id, + generation, + job.coinbase_tx_prefix.to_vec(), + job.coinbase_tx_suffix.to_vec(), + ) + }); + if !bound { + crate::merge_mining::global().announce_unbound_job(job.local_job_id); + } + } + } + + for message in messages { Self::send(self_mutex, message) .await .map_err(|_| Error::DownstreamDown)?; // Caller will restart proxy @@ -410,12 +492,12 @@ impl DownstreamMiningNode { // See coment on the definition of the global for memory // ordering super::IS_NEW_TEMPLATE_HANDLED.store(true, std::sync::atomic::Ordering::Release); - Ok(()) + Ok(downstream_job) } pub async fn on_set_new_prev_hash( self_mutex: &Arc>, - new_prev_hash: roles_logic_sv2::template_distribution_sv2::SetNewPrevHash<'static>, + new_prev_hash: TemplateSetNewPrevHash<'static>, ) -> Result<(), JdClientError> { if !self_mutex .safe_lock(|s| s.status.have_channel()) @@ -594,12 +676,11 @@ impl error!("Share do not meet downstream target"); Ok(SendTo::Respond(Mining::SubmitSharesError(s))) } - OnNewShare::SendSubmitShareUpstream((m, Some(template_id))) => { + OnNewShare::SendSubmitShareUpstream((m, Some(_template_id))) => { if !self.status.is_solo_miner() { match m { Share::Extended(share) => { let for_upstream = Mining::SubmitSharesExtended(share); - self.last_template_id = template_id; Ok(SendTo::RelayNewMessage(for_upstream)) } // We are in an extended channel shares are extended @@ -653,7 +734,6 @@ impl // Safe unwrap alreay checked if it cointains upstream with is_solo_miner if !self.withhold && !self.status.is_solo_miner() { - self.last_template_id = template_id; let for_upstream = Mining::SubmitSharesExtended(share); Ok(SendTo::RelayNewMessage(for_upstream)) } else { @@ -680,3 +760,147 @@ impl } } impl IsMiningDownstream for DownstreamMiningNode {} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{consensus::serialize, script::PushBytesBuf, Amount, ScriptBuf}; + + fn template_with_outputs(actual_count: usize, declared_count: u32) -> NewTemplate<'static> { + let output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let outputs = (0..actual_count) + .flat_map(|_| serialize(&output)) + .collect::>(); + NewTemplate { + template_id: 1, + future_template: false, + version: 0, + coinbase_tx_version: 0, + coinbase_prefix: Vec::new().try_into().expect("empty prefix"), + coinbase_tx_input_sequence: 0, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: declared_count, + coinbase_tx_outputs: outputs.try_into().expect("serialized outputs"), + coinbase_tx_locktime: 0, + merkle_path: Vec::new().into(), + } + } + + #[test] + fn normal_coinbase_accepts_252_final_outputs() { + let template = template_with_outputs(250, 250); + assert!(DownstreamMiningNode::validate_final_coinbase_output_count(&template, 2,).is_ok()); + } + + #[test] + fn normal_coinbase_rejects_253_final_outputs() { + let template = template_with_outputs(251, 251); + assert!(matches!( + DownstreamMiningNode::validate_final_coinbase_output_count(&template, 2,), + Err(JdClientError::UnsupportedCoinbaseOutputCount { + count: 253, + max: MAX_SUPPORTED_COINBASE_OUTPUTS, + }) + )); + } + + #[test] + fn normal_coinbase_uses_serialized_instead_of_declared_output_count() { + let template = template_with_outputs(252, 251); + assert!(matches!( + DownstreamMiningNode::validate_final_coinbase_output_count(&template, 1,), + Err(JdClientError::UnsupportedCoinbaseOutputCount { count: 253, .. }) + )); + } + + #[test] + fn decodes_every_pool_coinbase_output() { + let outputs = vec![ + TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: Amount::from_sat(2), + script_pubkey: ScriptBuf::new(), + }, + ]; + let encoded = outputs.iter().flat_map(serialize).collect::>(); + assert_eq!( + DownstreamMiningNode::decode_pool_coinbase_outputs(&encoded) + .expect("valid pool outputs"), + outputs + ); + } + + #[test] + fn merge_preview_rejects_only_the_modified_b064k_boundary_job() { + let mut template_outputs = (0..6) + .map(|_| TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x51; 9_360]), + }) + .collect::>(); + template_outputs.push(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x51; 9_246]), + }); + let pristine_outputs = template_outputs + .iter() + .flat_map(serialize) + .collect::>(); + assert_eq!(pristine_outputs.len(), 65_483); + + let pristine = NewTemplate { + template_id: u64::MAX, + future_template: true, + version: 0, + coinbase_tx_version: 2, + coinbase_prefix: vec![1, 1, 0].try_into().expect("valid BIP34 test prefix"), + coinbase_tx_input_sequence: 0, + coinbase_tx_value_remaining: 1, + coinbase_tx_outputs_count: 7, + coinbase_tx_outputs: pristine_outputs + .clone() + .try_into() + .expect("pristine outputs"), + coinbase_tx_locktime: 0, + merkle_path: Vec::new().into(), + }; + let pool_outputs = vec![TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }]; + + let (_, pristine_suffix) = DownstreamMiningNode::preview_template_job_with_extranonce( + pristine.clone(), + pool_outputs.clone(), + 32, + ) + .expect("pristine job must fit"); + assert_eq!(pristine_suffix.as_ref().len(), u16::MAX as usize); + + let payload = [b"RSKBLOCK:".as_slice(), &[0_u8; 32]].concat(); + let push = PushBytesBuf::try_from(payload).expect("valid OP_RETURN payload"); + let merge_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return(push), + }; + let mut modified_outputs = pristine_outputs; + modified_outputs.extend_from_slice(&serialize(&merge_output)); + assert_eq!(modified_outputs.len(), u16::MAX as usize); + let mut modified = pristine; + modified.coinbase_tx_outputs_count += 1; + modified.coinbase_tx_outputs = modified_outputs.try_into().expect("modified outputs fit"); + + assert!(DownstreamMiningNode::preview_template_job_with_extranonce( + modified, + pool_outputs, + 32, + ) + .is_err()); + } +} diff --git a/src/jd_client/mining_upstream/upstream.rs b/src/jd_client/mining_upstream/upstream.rs index 53ac13e8..db4ab650 100644 --- a/src/jd_client/mining_upstream/upstream.rs +++ b/src/jd_client/mining_upstream/upstream.rs @@ -2,7 +2,7 @@ use crate::jd_client::IS_CUSTOM_JOB_SET; use crate::proxy_state::{DownstreamType, ProxyState, TpState, UpstreamType}; use crate::{jd_client::error::Error, jd_client::error::ProxyResult, shared::utils::AbortOnDrop}; -use crate::jd_client::mining_downstream::DownstreamMiningNode as Downstream; +use crate::jd_client::mining_downstream::{DownstreamJob, DownstreamMiningNode as Downstream}; use binary_sv2::{Seq0255, U256}; use roles_logic_sv2::{ @@ -19,7 +19,10 @@ use roles_logic_sv2::{ Error as RolesLogicError, }; use std::{collections::HashMap, sync::Arc}; -use tokio::sync::mpsc::{Receiver as TReceiver, Sender as TSender}; +use tokio::sync::{ + mpsc::{Receiver as TReceiver, Sender as TSender}, + Mutex as TokioMutex, +}; use tokio::task; use tracing::{error, info, warn}; @@ -29,7 +32,7 @@ use super::task_manager::TaskManager; #[derive(Debug)] struct CircularBuffer { - buffer: VecDeque<(u64, u32)>, + buffer: VecDeque<(u32, u32)>, capacity: usize, } @@ -41,20 +44,27 @@ impl CircularBuffer { } } - fn insert(&mut self, key: u64, value: u32) { + fn insert(&mut self, key: u32, value: u32) { + self.buffer.retain(|(existing, _)| *existing != key); if self.buffer.len() == self.capacity { self.buffer.pop_front(); } self.buffer.push_back((key, value)); } - fn get(&self, id: u64) -> Option { + fn get(&self, id: u32) -> Option { self.buffer .iter() .find_map(|&(key, value)| if key == id { Some(value) } else { None }) } } +#[derive(Debug)] +struct PendingCustomJob { + template_id: u64, + downstream_job: DownstreamJob, +} + impl std::default::Default for CircularBuffer { fn default() -> Self { Self::new(1000) @@ -63,26 +73,26 @@ impl std::default::Default for CircularBuffer { #[derive(Debug, Default)] struct TemplateToJobId { - template_id_to_job_id: CircularBuffer, - request_id_to_template_id: HashMap, + downstream_to_pool_job_id: CircularBuffer, + requests: HashMap, } impl TemplateToJobId { - fn register_template_id(&mut self, template_id: u64, request_id: u32) { - self.request_id_to_template_id - .insert(request_id, template_id); + fn register_request(&mut self, request_id: u32, request: PendingCustomJob) { + self.requests.insert(request_id, request); } - fn register_job_id(&mut self, template_id: u64, job_id: u32) { - self.template_id_to_job_id.insert(template_id, job_id); + fn register_job_id(&mut self, downstream_job_id: u32, pool_job_id: u32) { + self.downstream_to_pool_job_id + .insert(downstream_job_id, pool_job_id); } - fn get_job_id(&mut self, template_id: u64) -> Option { - self.template_id_to_job_id.get(template_id) + fn get_job_id(&self, downstream_job_id: u32) -> Option { + self.downstream_to_pool_job_id.get(downstream_job_id) } - fn take_template_id(&mut self, request_id: u32) -> Option { - self.request_id_to_template_id.remove(&request_id) + fn take_request(&mut self, request_id: u32) -> Option { + self.requests.remove(&request_id) } fn new() -> Self { @@ -108,6 +118,7 @@ pub struct Upstream { pub downstream: Option>>, channel_factory: Option, template_to_job_id: TemplateToJobId, + custom_job_send_lock: Arc>, req_ids: Id, } @@ -141,12 +152,13 @@ impl Upstream { downstream: None, channel_factory: None, template_to_job_id: TemplateToJobId::new(), + custom_job_send_lock: Arc::new(TokioMutex::new(())), req_ids: Id::new(), }))) } #[allow(clippy::too_many_arguments)] - pub async fn set_custom_jobs( + pub(crate) async fn set_custom_jobs( self_: &Arc>, declare_mining_job: DeclareMiningJob<'static>, set_new_prev_hash: roles_logic_sv2::template_distribution_sv2::SetNewPrevHash<'static>, @@ -159,8 +171,13 @@ impl Upstream { coinbase_tx_outs: Vec, coinbase_tx_locktime: u32, template_id: u64, + downstream_job: DownstreamJob, ) -> ProxyResult<()> { info!("Sending set custom mining job"); + let send_lock = self_ + .safe_lock(|state| state.custom_job_send_lock.clone()) + .map_err(|_| Error::JdClientUpstreamMutexCorrupted)?; + let _send_guard = send_lock.lock().await; let request_id = self_ .safe_lock(|s| s.req_ids.next()) .map_err(|_| Error::JdClientUpstreamMutexCorrupted)?; @@ -194,11 +211,23 @@ impl Upstream { let message = Mining::SetCustomMiningJob(to_send); self_ .safe_lock(|s| { - s.template_to_job_id - .register_template_id(template_id, request_id) + s.template_to_job_id.register_request( + request_id, + PendingCustomJob { + template_id, + downstream_job, + }, + ) }) .map_err(|_| Error::JdClientUpstreamMutexCorrupted)?; - Self::send(self_, message).await + if let Err(error) = Self::send(self_, message).await { + let _ = self_.safe_lock(|state| { + state.template_to_job_id.take_request(request_id); + }); + IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); + return Err(error); + } + Ok(()) } /// Parses the incoming SV2 message from the Upstream role and routes the message to the @@ -293,10 +322,13 @@ impl Upstream { .map_err(|_| Error::JdClientUpstreamMutexCorrupted)? } - pub async fn get_job_id(self_: &Arc>, template_id: u64) -> Result { + pub async fn get_job_id( + self_: &Arc>, + downstream_job_id: u32, + ) -> Result { loop { if let Some(id) = self_ - .safe_lock(|s| s.template_to_job_id.get_job_id(template_id)) + .safe_lock(|s| s.template_to_job_id.get_job_id(downstream_job_id)) .map_err(|_| Error::JdClientDownstreamMutexCorrupted)? { return Ok(id); @@ -567,19 +599,19 @@ impl ParseUpstreamMiningMessages Result, RolesLogicError> { - if let Some(template_id) = self.template_to_job_id.take_template_id(m.request_id) { + if let Some(request) = self.template_to_job_id.take_request(m.request_id) { self.template_to_job_id - .register_job_id(template_id, m.job_id); + .register_job_id(request.downstream_job.local_job_id, m.job_id); info!( "Set custom mining job success {}, for template {}", - m.job_id, template_id + m.job_id, request.template_id ); IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); Ok(SendTo::None(None)) } else { - error!( - "Attention received a SetupConnectionSuccess with unknown request_id: {}", - m.request_id + warn!( + request_id = m.request_id, + "ignoring unknown or late custom-job success" ); Ok(SendTo::None(None)) } @@ -588,9 +620,21 @@ impl ParseUpstreamMiningMessages Result, RolesLogicError> { - IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); + if let Some(request) = self.template_to_job_id.take_request(m.request_id) { + warn!( + template_id = request.template_id, + request_id = m.request_id, + "pool rejected custom job" + ); + IS_CUSTOM_JOB_SET.store(true, std::sync::atomic::Ordering::Release); + } else { + warn!( + request_id = m.request_id, + "ignoring unknown or late custom-job rejection" + ); + } Ok(SendTo::None(None)) } @@ -639,3 +683,62 @@ impl ParseUpstreamMiningMessages DownstreamJob { + DownstreamJob { + local_job_id, + coinbase_tx_prefix: Vec::new().try_into().expect("empty prefix"), + coinbase_tx_suffix: Vec::new().try_into().expect("empty suffix"), + } + } + + #[test] + fn pool_job_mappings_are_scoped_to_the_exact_miner_job() { + let mut mappings = TemplateToJobId::new(); + mappings.register_job_id(11, 101); + mappings.register_job_id(12, 102); + + assert_eq!(mappings.get_job_id(11), Some(101)); + assert_eq!(mappings.get_job_id(12), Some(102)); + } + + #[test] + fn custom_job_responses_keep_request_correlation_when_reordered() { + let mut mappings = TemplateToJobId::new(); + mappings.register_request( + 1, + PendingCustomJob { + template_id: 41, + downstream_job: downstream_job(11), + }, + ); + mappings.register_request( + 2, + PendingCustomJob { + template_id: 42, + downstream_job: downstream_job(12), + }, + ); + + assert_eq!( + mappings + .take_request(2) + .expect("second request") + .downstream_job + .local_job_id, + 12 + ); + assert_eq!( + mappings + .take_request(1) + .expect("first request") + .downstream_job + .local_job_id, + 11 + ); + } +} diff --git a/src/jd_client/template_receiver/mod.rs b/src/jd_client/template_receiver/mod.rs index 700c7aee..985bb57a 100644 --- a/src/jd_client/template_receiver/mod.rs +++ b/src/jd_client/template_receiver/mod.rs @@ -2,7 +2,8 @@ mod task_manager; use crate::proxy_state::{DownstreamType, JdState, TpState}; use crate::shared::utils::AbortOnDrop; use crate::{ - jd_client::mining_downstream::DownstreamMiningNode as Downstream, proxy_state::ProxyState, + jd_client::mining_downstream::{DownstreamJob, DownstreamMiningNode as Downstream}, + proxy_state::ProxyState, }; use super::{error::Error, job_declarator::JobDeclarator}; @@ -41,9 +42,31 @@ pub struct TemplateRx { down: Arc>, new_template_message: Option>, miner_coinbase_output: Vec, + merge_mining_enabled: bool, test_only_do_not_send_solution_to_tp: bool, } +fn coinbase_capacity_with_merge_mining(base: u32, enabled: bool) -> (u32, Option) { + if !enabled { + return (base, None); + } + match base.checked_add(crate::merge_mining::RESERVED_COINBASE_OUTPUT_BYTES) { + Some(advertised) => ( + advertised, + Some(crate::merge_mining::RESERVED_COINBASE_OUTPUT_BYTES as usize), + ), + None => (base, None), + } +} + +fn will_publish_template( + wait_for_last_template_to_be_completed: bool, + go_to_next_template: bool, + discard_last_and_use_this: bool, +) -> bool { + wait_for_last_template_to_be_completed || (!go_to_next_template && discard_last_and_use_this) +} + impl TemplateRx { #[allow(clippy::too_many_arguments)] pub async fn connect( @@ -101,6 +124,7 @@ impl TemplateRx { down, new_template_message: None, miner_coinbase_output: encoded_outputs, + merge_mining_enabled: crate::merge_mining::enabled(), test_only_do_not_send_solution_to_tp, })); @@ -212,6 +236,12 @@ impl TemplateRx { .safe_lock(|s| s.miner_coinbase_output.clone()) .map_err(|_| Error::TemplateRxMutexCorrupted)?; let miner_name = crate::config::Configuration::miner_name(); + let merge_mining_enabled = self_mutex + .safe_lock(|template_rx| template_rx.merge_mining_enabled) + .map_err(|_| Error::TemplateRxMutexCorrupted)?; + if merge_mining_enabled { + crate::merge_mining::global().begin_template_session(); + } let main_task = { let self_mutex = self_mutex.clone(); //? check @@ -219,6 +249,9 @@ impl TemplateRx { // Send CoinbaseOutputDataSize size to TP let mut pending_new_template: Option> = None; let mut pending_tx_data_template_id: Option = None; + let mut pending_downstream_job: Option = None; + let mut pending_template_generation: Option = None; + let mut merge_mining_reserved_bytes = None; loop { if last_token.is_none() { let jd = match self_mutex.safe_lock(|s| s.jd.clone()) { @@ -240,11 +273,17 @@ impl TemplateRx { if !coinbase_output_max_additional_size_sent { coinbase_output_max_additional_size_sent = true; - Self::send_max_coinbase_size( - &self_mutex, + let (advertised_size, reserved_bytes) = coinbase_capacity_with_merge_mining( coinbase_output_max_additional_size, - ) - .await; + merge_mining_enabled, + ); + merge_mining_reserved_bytes = reserved_bytes; + if merge_mining_enabled && reserved_bytes.is_none() { + warn!( + "Cannot reserve merge-mining coinbase output capacity; continuing with Bitcoin-only templates" + ); + } + Self::send_max_coinbase_size(&self_mutex, advertised_size).await; } let ready_for_new_template = super::IS_NEW_TEMPLATE_HANDLED @@ -360,6 +399,96 @@ impl TemplateRx { let discard_last_and_use_this = !last_is_future && !wait_for_last_template_to_be_completed; + let will_publish_template = will_publish_template( + wait_for_last_template_to_be_completed, + go_to_next_template, + discard_last_and_use_this, + ); + + if will_publish_template { + pending_downstream_job = None; + pending_template_generation = None; + if let Some(reserved_bytes) = merge_mining_reserved_bytes { + let pristine_template = m.clone(); + let pool_output_count = last_token + .as_ref() + .and_then(|token| token.as_ref()) + .and_then(|token| { + Downstream::decode_pool_coinbase_outputs( + token.coinbase_output.as_ref(), + ) + .ok() + }) + .map_or(usize::MAX, |outputs| outputs.len()); + match crate::merge_mining::global() + .apply_to_template_with_pool_output_count( + &mut m, + reserved_bytes, + pool_output_count, + ) { + Ok(true) => { + pending_template_generation = + crate::merge_mining::global() + .template_generation(m.template_id); + if let Some(template_generation) = + pending_template_generation + { + let pool_output = last_token + .as_ref() + .and_then(|token| token.as_ref()) + .map(|token| { + token.coinbase_output.to_vec() + }); + let preview = pool_output + .as_deref() + .ok_or(crate::jd_client::error::Error::Unrecoverable) + .and_then(|pool_output| { + Downstream::preview_template_job( + &down, + m.clone(), + pool_output, + ) + .map(|_| ()) + }); + if let Err(error) = preview { + crate::merge_mining::global() + .discard_template_generation( + template_generation, + ); + pending_template_generation = None; + m = pristine_template; + warn!( + template_id = m.template_id, + %error, + "RSK candidate job preflight failed; using the pristine Bitcoin template" + ); + } else { + info!( + template_id = m.template_id, + "added RSK commitment to canonical Bitcoin mining job" + ); + } + } else { + m = pristine_template; + warn!( + template_id = m.template_id, + "RSK context was unavailable; using the pristine Bitcoin template" + ); + } + } + Ok(false) => {} + Err(error) => { + m = pristine_template; + warn!( + template_id = m.template_id, + %error, + "RSK commitment was not applied; using the pristine Bitcoin template" + ); + } + } + } + } + if wait_for_last_template_to_be_completed { info!("wait_for_last_template_to_be_completed"); if new_phash { @@ -388,19 +517,27 @@ impl TemplateRx { None => break, }; let pool_output = token.coinbase_output.to_vec(); - if let Err(e) = Downstream::on_new_template( + match Downstream::on_new_template( &down, m.clone(), &pool_output[..], + pending_template_generation, ) .await { - error!("{e:?}"); - // Update global downstream state to down - ProxyState::update_downstream_state( - DownstreamType::JdClientMiningDownstream, - ); - }; + Ok(job) => pending_downstream_job = job, + Err(e) => { + super::IS_NEW_TEMPLATE_HANDLED.store( + true, + std::sync::atomic::Ordering::Release, + ); + error!("{e:?}"); + // Update global downstream state to down + ProxyState::update_downstream_state( + DownstreamType::JdClientMiningDownstream, + ); + } + } } else if go_to_next_template { // last_is_future this_future new_phash wait next discard // 1)true false true true true @@ -447,19 +584,27 @@ impl TemplateRx { None => break, }; let pool_output = token.coinbase_output.to_vec(); - if let Err(e) = Downstream::on_new_template( + match Downstream::on_new_template( &down, m.clone(), &pool_output[..], + pending_template_generation, ) .await { - error!("{e:?}"); - // Update global downstream state to down - ProxyState::update_downstream_state( - DownstreamType::JdClientMiningDownstream, - ); - }; + Ok(job) => pending_downstream_job = job, + Err(e) => { + super::IS_NEW_TEMPLATE_HANDLED.store( + true, + std::sync::atomic::Ordering::Release, + ); + error!("{e:?}"); + // Update global downstream state to down + ProxyState::update_downstream_state( + DownstreamType::JdClientMiningDownstream, + ); + } + } } else { unreachable!(); } @@ -484,14 +629,25 @@ impl TemplateRx { tokio::task::yield_now().await; } info!("IS_NEW_TEMPLATE_HANDLED ok"); + if crate::merge_mining::enabled() { + if let Ok(prev_hash) = + <[u8; 32]>::try_from(m.prev_hash.to_vec()) + { + crate::merge_mining::global().record_chain_state( + m.template_id, + prev_hash, + m.n_bits, + ); + } + } if let Some(jd) = jd.as_ref() { if let Err(e) = super::job_declarator::JobDeclarator::on_set_new_prev_hash( - jd.clone(), - m.clone(), - ).await { - error!("{e:?}"); - ProxyState::update_jd_state(JdState::Down); break; - }; + jd.clone(), + m.clone(), + ).await { + error!("{e:?}"); + ProxyState::update_jd_state(JdState::Down); break; + }; } if let Err(e) = Downstream::on_set_new_prev_hash(&down, m).await { @@ -518,6 +674,8 @@ impl TemplateRx { error!("TemplateRx mutex poisoned: {e}"); ProxyState::update_tp_state(TpState::Down); pending_tx_data_template_id = None; + pending_downstream_job = None; + pending_template_generation = None; last_token = None; continue; } @@ -527,6 +685,8 @@ impl TemplateRx { None => { ProxyState::update_tp_state(TpState::Down); pending_tx_data_template_id = None; + pending_downstream_job = None; + pending_template_generation = None; last_token = None; continue; } @@ -537,22 +697,39 @@ impl TemplateRx { m.template_id ); pending_tx_data_template_id = None; + pending_downstream_job = None; + pending_template_generation = None; last_token = None; continue; } + let transactions = m.transaction_list.into_inner(); + let excess_data = m.excess_data; + if merge_mining_enabled { + if let Some(template_generation) = + pending_template_generation + { + crate::merge_mining::global().record_transaction_count( + template_generation, + transactions.len(), + ); + } + } let token = match last_token.take() { Some(Some(token)) => token, Some(None) => break, None => break, }; + let downstream_job = pending_downstream_job.take(); + pending_template_generation = None; pending_tx_data_template_id = None; let jd = jd.clone(); tokio::task::spawn(async move { - let transactions_data = m.transaction_list; - let excess_data = m.excess_data; + let transactions_data = transactions.into(); let mining_token = token.mining_job_token.to_vec(); let pool_coinbase_out = token.coinbase_output.to_vec(); - if let Some(jd) = jd.as_ref() { + if let (Some(jd), Some(downstream_job)) = + (jd.as_ref(), downstream_job) + { if let Err(e) = super::job_declarator::JobDeclarator::on_new_template( jd, new_template_message, @@ -560,6 +737,7 @@ impl TemplateRx { transactions_data, excess_data, pool_coinbase_out, + downstream_job, ) .await { error!("{e:?}"); @@ -571,6 +749,8 @@ impl TemplateRx { Some(TemplateDistribution::RequestTransactionDataError(m)) => { if pending_tx_data_template_id == Some(m.template_id) { pending_tx_data_template_id = None; + pending_downstream_job = None; + pending_template_generation = None; last_token = None; } warn!("The prev_hash of the template requested to Template Provider no longer points to the latest tip. Continuing work on the updated template.") @@ -654,6 +834,53 @@ mod tests { use tokio::sync::{mpsc, oneshot}; use tokio::time::Duration; + #[test] + fn merge_mining_capacity_is_reserved_only_when_configured() { + assert_eq!(coinbase_capacity_with_merge_mining(1, false), (1, None)); + assert_eq!( + coinbase_capacity_with_merge_mining(1, true), + ( + 1 + crate::merge_mining::RESERVED_COINBASE_OUTPUT_BYTES, + Some(crate::merge_mining::RESERVED_COINBASE_OUTPUT_BYTES as usize) + ) + ); + assert_eq!( + coinbase_capacity_with_merge_mining(u32::MAX, true), + (u32::MAX, None) + ); + } + + #[test] + fn merge_mining_preparation_matches_template_publication_precedence() { + let cases = [ + (true, true, true, true), + (true, true, false, true), + (true, false, true, true), + (true, false, false, true), + (false, true, true, true), + (false, true, false, true), + (false, false, true, false), + (false, false, false, true), + ]; + + for (last_is_future, current_is_future, new_phash, expected) in cases { + let wait_for_last_template_to_be_completed = last_is_future || !new_phash; + let go_to_next_template = !current_is_future && new_phash; + let discard_last_and_use_this = + !last_is_future && !wait_for_last_template_to_be_completed; + + assert_eq!( + will_publish_template( + wait_for_last_template_to_be_completed, + go_to_next_template, + discard_last_and_use_this, + ), + expected, + "last_is_future={last_is_future}, current_is_future={current_is_future}, new_phash={new_phash}" + ); + } + } + fn make_new_template(template_id: u64, future_template: bool) -> NewTemplate<'static> { let coinbase_prefix: B0255<'static> = Vec::new() .try_into() @@ -853,6 +1080,7 @@ mod tests { down, new_template_message: None, miner_coinbase_output: encoded_outputs, + merge_mining_enabled: false, test_only_do_not_send_solution_to_tp: true, })); let _abortable = TemplateRx::start_templates(self_mutex, tp_to_client_rx) diff --git a/src/lib.rs b/src/lib.rs index 88384115..fca41247 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ mod debug_timing; mod ingress; pub use config::Configuration; pub mod jd_client; +mod merge_mining; mod minin_pool_connection; mod monitor; mod prioritized_transactions; @@ -309,8 +310,10 @@ async fn initialize_proxy( } let server_handle = tokio::spawn(api::start(router.clone(), stats_sender, downstream_handoff)); - abort_handles.push((server_handle.into(), "api_server".to_string())); - match monitor(router, abort_handles, epsilon, shutdown_signal.clone()).await { + let api_server_abortable: AbortOnDrop = server_handle.into(); + let reconnect = monitor(router, abort_handles, epsilon, shutdown_signal.clone()).await; + drop(api_server_abortable); + match reconnect { Reconnect::NewUpstream(new_pool_addr) => { ProxyState::update_proxy_state_up(); pool_addr = Some(new_pool_addr); diff --git a/src/merge_mining.rs b/src/merge_mining.rs new file mode 100644 index 00000000..313ed14f --- /dev/null +++ b/src/merge_mining.rs @@ -0,0 +1,2665 @@ +use axum::{extract::Query, http::StatusCode, Json}; +use bitcoin::{ + block::{Header, Version}, + consensus::{deserialize, serialize, Decodable, Encodable}, + hashes::Hash, + hex::{DisplayHex, FromHex}, + opcodes::all::OP_RETURN, + script::{Instruction, PushBytesBuf}, + Amount, BlockHash, CompactTarget, ScriptBuf, Transaction, TxMerkleNode, TxOut, +}; +use roles_logic_sv2::{ + mining_sv2::Target, template_distribution_sv2::NewTemplate, utils::merkle_root_from_path_, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + convert::TryInto, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, SyncSender, TrySendError}, + Arc, Mutex, OnceLock, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tracing::{debug, info, warn}; + +pub(crate) const RESERVED_COINBASE_OUTPUT_BYTES: u32 = 100; +const MAX_PAYLOAD_BYTES: usize = 80; +const MAX_TEMPLATES: usize = 128; +const MAX_JOBS: usize = 256; +const MAX_JOB_ANNOUNCEMENTS: usize = 256; +const MAX_PENDING_SHARES: usize = 128; +const MAX_EARLY_SHARES: usize = 64; +const MAX_FOUND_JOBS: usize = 32; +const MAX_DEDUP_IDENTITIES: usize = 128; +// The pinned converter prepends one pool output and encodes the total count in one byte. +const MAX_SINGLE_BYTE_COINBASE_OUTPUTS: u32 = 252; +const RSK_TAG: &[u8] = b"RSKBLOCK:"; +const RSK_HASH_BYTES: usize = 32; +const RSK_MAX_TRAILING_COINBASE_BYTES: usize = 128; +static GLOBAL: OnceLock = OnceLock::new(); + +pub(crate) fn global() -> &'static MergeMining { + GLOBAL.get_or_init(MergeMining::new) +} + +pub(crate) fn claim_job_binding_pending(job_id: u32) -> Option { + if !enabled() { + return None; + } + GLOBAL.get()?.claim_job_binding_pending(job_id) +} + +pub(crate) fn set_active_job_binding(binding_id: Option) { + if !enabled() { + return; + } + if let Some(merge_mining) = GLOBAL.get() { + merge_mining.set_active_job_binding(binding_id); + } +} + +pub(crate) fn clear_pending_job_binding(binding_id: Option) { + if !enabled() { + return; + } + if let (Some(merge_mining), Some(binding_id)) = (GLOBAL.get(), binding_id) { + merge_mining.clear_pending_job_binding(binding_id); + } +} + +pub(crate) fn configured() -> bool { + std::env::var("API_SECRET").is_ok_and(|secret| !secret.is_empty()) +} + +pub(crate) fn enabled() -> bool { + configured() +} + +#[derive(Clone)] +pub(crate) struct MergeMining { + core: Arc, + observer: Arc>, +} + +struct Core { + context: Mutex, + found: Mutex, + next_template_generation: AtomicU64, + next_binding_id: AtomicU64, + next_found_id: AtomicU64, + dropped_observations: AtomicU64, + queue_overflows: AtomicU64, + announcement_mismatches: AtomicU64, + observer_ready: AtomicBool, +} + +struct ObserverState { + work_tx: Option>, + retry_after: Option, +} + +struct ObserverLiveness { + core: Arc, +} + +impl Drop for ObserverLiveness { + fn drop(&mut self) { + self.core.observer_ready.store(false, Ordering::Release); + } +} + +#[derive(Default)] +struct ContextState { + desired: Option, + templates: HashMap, + current_templates: HashMap, + template_order: VecDeque, + active_chain: Option, + jobs: HashMap, + current_jobs: HashMap, + job_order: VecDeque, + job_announcements: VecDeque<(u32, Option)>, + active_binding: Option, + pending_activation_bindings: HashSet, +} + +#[derive(Default)] +struct FoundState { + pending: VecDeque, + recent: VecDeque<(String, String)>, +} + +#[derive(Clone)] +struct DesiredPair { + payload: Vec, + payload_hex: String, + target_le: [u8; 32], + target_hex: String, + output: Vec, +} + +struct TemplateDraft { + template_id: u64, + desired: DesiredPair, + merkle_path: Vec<[u8; 32]>, + block_tx_count: Option, + chain: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ChainState { + prev_hash: [u8; 32], + n_bits: u32, +} + +struct JobBinding { + binding_id: u64, + job_id: u32, + template_id: u64, + template_generation: u64, + chain: Option, + coinbase_prefix: Vec, + coinbase_suffix: Vec, +} + +enum Work { + Share(ShareObservation), + Wake, +} + +struct ShareObservation { + binding_id: u64, + observed_at_unix_ts: u64, + version: u32, + timestamp: u32, + nonce: u32, + full_extranonce: Vec, +} + +struct CandidateContext { + template_id: u64, + desired: DesiredPair, + merkle_path: Vec<[u8; 32]>, + block_tx_count: u32, + chain: ChainState, + coinbase_prefix: Vec, + coinbase_suffix: Vec, +} + +enum ResolveContext { + Ready(Box), + Incomplete, + Obsolete, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct FoundJob { + id: u64, + observed_at_unix_ts: u64, + template_id: u64, + version: u32, + header_timestamp: u32, + header_nonce: u32, + bitcoin_block_hash_hex: String, + block_header_hex: String, + coinbase_tx_hex: String, + merkle_hashes_hex: Vec, + block_tx_count: u32, + op_return_payload_hex: String, + rsk_target_hex: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct AcceptedPair { + payload_len_bytes: usize, + tx_out_len_bytes: usize, + replaced_pending: bool, +} + +#[derive(Debug)] +pub(crate) enum MergeMiningError { + EmptyPayload, + InvalidPayloadHex, + AmbiguousRskPayload, + PayloadTooLarge(usize), + InvalidTarget, + OutputTooLarge(usize), + InvalidTemplateOutputs, + InvalidRskCommitment, + TooManyTemplateOutputs, + TemplateOutputsTooLarge, + StateUnavailable, +} + +impl std::fmt::Display for MergeMiningError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyPayload => f.write_str("OP_RETURN payload cannot be empty"), + Self::InvalidPayloadHex => f.write_str("OP_RETURN payload must be even-length hex"), + Self::AmbiguousRskPayload => { + f.write_str("RSK work hash contains a second RSKBLOCK: marker") + } + Self::PayloadTooLarge(len) => write!( + f, + "OP_RETURN payload is {len} bytes; the maximum is {MAX_PAYLOAD_BYTES}" + ), + Self::InvalidTarget => { + f.write_str("RSK target must be exactly 32 bytes of big-endian hex") + } + Self::OutputTooLarge(len) => write!( + f, + "serialized OP_RETURN output is {len} bytes; only {RESERVED_COINBASE_OUTPUT_BYTES} bytes are reserved" + ), + Self::InvalidTemplateOutputs => { + f.write_str("template contains invalid serialized coinbase outputs") + } + Self::InvalidRskCommitment => { + f.write_str("appended RSK commitment would not be selected by RskJ") + } + Self::TooManyTemplateOutputs => { + f.write_str("template coinbase output count cannot be safely increased") + } + Self::TemplateOutputsTooLarge => { + f.write_str("modified template outputs exceed the SV2 field limit") + } + Self::StateUnavailable => f.write_str("merge-mining state is unavailable"), + } + } +} + +impl MergeMining { + fn new() -> Self { + let core = Arc::new(Core { + context: Mutex::new(ContextState::default()), + found: Mutex::new(FoundState::default()), + next_template_generation: AtomicU64::new(1), + next_binding_id: AtomicU64::new(1), + next_found_id: AtomicU64::new(1), + dropped_observations: AtomicU64::new(0), + queue_overflows: AtomicU64::new(0), + announcement_mismatches: AtomicU64::new(0), + observer_ready: AtomicBool::new(false), + }); + let merge_mining = Self { + core, + observer: Arc::new(Mutex::new(ObserverState { + work_tx: None, + retry_after: None, + })), + }; + merge_mining.ensure_observer(); + merge_mining + } + + pub(crate) fn set_desired_pair( + &self, + data_hex: &str, + target_hex: &str, + ) -> Result { + let desired = DesiredPair::parse(data_hex, target_hex)?; + if !self.ensure_observer() { + return Err(MergeMiningError::StateUnavailable); + } + let accepted = AcceptedPair { + payload_len_bytes: desired.payload.len(), + tx_out_len_bytes: desired.output.len(), + replaced_pending: false, + }; + let mut state = self + .core + .context + .lock() + .map_err(|_| MergeMiningError::StateUnavailable)?; + let mut accepted = accepted; + accepted.replaced_pending = state.desired.replace(desired).is_some(); + drop(state); + info!( + payload_len_bytes = accepted.payload_len_bytes, + tx_out_len_bytes = accepted.tx_out_len_bytes, + replaced_pending = accepted.replaced_pending, + "accepted desired RSK merge-mining pair" + ); + Ok(accepted) + } + + pub(crate) fn begin_template_session(&self) { + self.clear_template_session(); + self.wake_worker(); + } + + fn clear_template_session(&self) { + if let Ok(mut state) = self.core.context.lock() { + state.current_templates.clear(); + state.active_chain = None; + state.current_jobs.clear(); + state.job_announcements.clear(); + state.pending_activation_bindings.clear(); + } + } + + #[cfg(test)] + pub(crate) fn apply_to_template( + &self, + template: &mut NewTemplate<'static>, + reserved_bytes: usize, + ) -> Result { + self.apply_to_template_with_pool_output_count(template, reserved_bytes, 1) + } + + pub(crate) fn apply_to_template_with_pool_output_count( + &self, + template: &mut NewTemplate<'static>, + reserved_bytes: usize, + pool_output_count: usize, + ) -> Result { + let pool_output_count = u32::try_from(pool_output_count) + .map_err(|_| MergeMiningError::TooManyTemplateOutputs)?; + let max_template_outputs = MAX_SINGLE_BYTE_COINBASE_OUTPUTS + .checked_sub(pool_output_count) + .ok_or(MergeMiningError::TooManyTemplateOutputs)?; + self.apply_to_template_with_output_limit(template, reserved_bytes, max_template_outputs) + } + + fn apply_to_template_with_output_limit( + &self, + template: &mut NewTemplate<'static>, + reserved_bytes: usize, + max_template_outputs: u32, + ) -> Result { + { + let mut state = self + .core + .context + .lock() + .map_err(|_| MergeMiningError::StateUnavailable)?; + invalidate_current_template(&mut state, template.template_id); + } + if !self.ensure_observer() { + return Err(MergeMiningError::StateUnavailable); + } + let desired = { + let state = self + .core + .context + .lock() + .map_err(|_| MergeMiningError::StateUnavailable)?; + state.desired.clone() + }; + let Some(desired) = desired else { + return Ok(false); + }; + if desired.output.len() > reserved_bytes { + return Err(MergeMiningError::OutputTooLarge(desired.output.len())); + } + + let output_bytes = template.coinbase_tx_outputs.as_ref(); + let mut cursor = output_bytes; + let desired_tag_offset = desired + .output + .windows(RSK_TAG.len()) + .position(|window| window == RSK_TAG); + let mut last_desired_canonical_tag = None; + for _ in 0..template.coinbase_tx_outputs_count { + let output_start = output_bytes.len() - cursor.len(); + let output = TxOut::consensus_decode(&mut cursor) + .map_err(|_| MergeMiningError::InvalidTemplateOutputs)?; + if serialize(&output) == desired.output { + last_desired_canonical_tag = desired_tag_offset.map(|offset| output_start + offset); + } + } + if !cursor.is_empty() { + return Err(MergeMiningError::InvalidTemplateOutputs); + } + + let mut output_tail = output_bytes.to_vec(); + output_tail.extend_from_slice(&template.coinbase_tx_locktime.to_le_bytes()); + let already_last = is_rsk_payload(&desired.payload) + && raw_rsk_commitment_matches(&output_tail, &desired) + && last_raw_tag_position(&output_tail) == last_desired_canonical_tag; + let (next_count, next_outputs) = if already_last { + if template.coinbase_tx_outputs_count > max_template_outputs { + return Err(MergeMiningError::TooManyTemplateOutputs); + } + (template.coinbase_tx_outputs_count, None) + } else { + let count = template + .coinbase_tx_outputs_count + .checked_add(1) + .ok_or(MergeMiningError::TooManyTemplateOutputs)?; + if count > max_template_outputs { + return Err(MergeMiningError::TooManyTemplateOutputs); + } + let mut outputs = template.coinbase_tx_outputs.to_vec(); + outputs.extend_from_slice(&desired.output); + if is_rsk_payload(&desired.payload) { + let expected_tag_position = + desired_tag_offset.map(|offset| output_bytes.len() + offset); + let mut prospective_tail = outputs.clone(); + prospective_tail.extend_from_slice(&template.coinbase_tx_locktime.to_le_bytes()); + if !raw_rsk_commitment_matches(&prospective_tail, &desired) + || last_raw_tag_position(&prospective_tail) != expected_tag_position + { + return Err(MergeMiningError::InvalidRskCommitment); + } + } + let outputs = outputs + .try_into() + .map_err(|_| MergeMiningError::TemplateOutputsTooLarge)?; + (count, Some(outputs)) + }; + + let template_merkle_path = template.merkle_path.to_vec(); + let mut merkle_path = Vec::with_capacity(template_merkle_path.len()); + for sibling in template_merkle_path { + let sibling: [u8; 32] = sibling + .to_vec() + .try_into() + .map_err(|_| MergeMiningError::InvalidTemplateOutputs)?; + merkle_path.push(sibling); + } + + let generation = next_id(&self.core.next_template_generation) + .ok_or(MergeMiningError::StateUnavailable)?; + let mut state = self + .core + .context + .lock() + .map_err(|_| MergeMiningError::StateUnavailable)?; + let chain = if template.future_template { + None + } else { + state.active_chain + }; + if !insert_template( + &mut state, + generation, + TemplateDraft { + template_id: template.template_id, + desired, + merkle_path, + block_tx_count: None, + chain, + }, + ) { + return Ok(false); + } + if let Some(outputs) = next_outputs { + template.coinbase_tx_outputs = outputs; + template.coinbase_tx_outputs_count = next_count; + } + Ok(true) + } + + pub(crate) fn discard_template_generation(&self, generation: u64) { + if let Ok(mut state) = self.core.context.lock() { + remove_template_generation(&mut state, generation); + } + } + + pub(crate) fn record_transaction_count( + &self, + template_generation: u64, + transaction_count: usize, + ) { + let Some(block_tx_count) = transaction_count + .checked_add(1) + .and_then(|count| u32::try_from(count).ok()) + .filter(|count| *count <= i32::MAX as u32) + else { + warn!( + template_generation, + transaction_count, "ignoring invalid merge-mining transaction count" + ); + return; + }; + if let Ok(mut state) = self.core.context.lock() { + if let Some(template) = state.templates.get_mut(&template_generation) { + if template.block_tx_count.is_none() { + template.block_tx_count = Some(block_tx_count); + } + } + } + self.wake_worker(); + } + + pub(crate) fn record_chain_state(&self, template_id: u64, prev_hash: [u8; 32], n_bits: u32) { + if let Ok(mut state) = self.core.context.lock() { + let chain = ChainState { prev_hash, n_bits }; + state.active_chain = Some(chain); + let generation = state.current_templates.get(&template_id).copied(); + if let Some(template) = + generation.and_then(|generation| state.templates.get_mut(&generation)) + { + template.chain = Some(chain); + } + // A job must always be reconstructed against the exact chain context that was + // active when it became usable. A later same-template-id prevhash refresh may update + // the template draft, but it must not silently rewrite existing job bindings. + if let Some(generation) = generation { + for job in state + .jobs + .values_mut() + .filter(|job| job.template_generation == generation && job.chain.is_none()) + { + job.chain = Some(chain); + } + } + } + self.wake_worker(); + } + + pub(crate) fn template_generation(&self, template_id: u64) -> Option { + self.core + .context + .lock() + .ok()? + .current_templates + .get(&template_id) + .copied() + } + + #[cfg(test)] + fn bind_job( + &self, + job_id: u32, + template_id: u64, + coinbase_prefix: Vec, + coinbase_suffix: Vec, + ) { + let mut state = match self.core.context.lock() { + Ok(state) => state, + Err(_) => return, + }; + let Some(template_generation) = state.current_templates.get(&template_id).copied() else { + announce_job(&mut state, job_id, None); + return; + }; + let binding_id = create_job_binding( + &self.core, + &mut state, + job_id, + template_generation, + coinbase_prefix, + coinbase_suffix, + ); + announce_job(&mut state, job_id, binding_id); + drop(state); + self.wake_worker(); + } + + pub(crate) fn bind_job_generation( + &self, + job_id: u32, + template_generation: u64, + coinbase_prefix: Vec, + coinbase_suffix: Vec, + ) -> bool { + let mut state = match self.core.context.lock() { + Ok(state) => state, + Err(_) => return false, + }; + let Some(binding_id) = create_job_binding( + &self.core, + &mut state, + job_id, + template_generation, + coinbase_prefix, + coinbase_suffix, + ) else { + return false; + }; + announce_job(&mut state, job_id, Some(binding_id)); + drop(state); + self.wake_worker(); + true + } + + pub(crate) fn announce_unbound_job(&self, job_id: u32) { + if let Ok(mut state) = self.core.context.lock() { + state.current_jobs.remove(&job_id); + announce_job(&mut state, job_id, None); + } + } + + #[cfg(test)] + pub(crate) fn claim_job_binding(&self, job_id: u32) -> Option { + self.claim_job_binding_(job_id, false) + } + + fn claim_job_binding_pending(&self, job_id: u32) -> Option { + self.claim_job_binding_(job_id, true) + } + + fn claim_job_binding_(&self, job_id: u32, mark_pending: bool) -> Option { + let mut state = self.core.context.lock().ok()?; + let Some(position) = state + .job_announcements + .iter() + .position(|(announced_job_id, _)| *announced_job_id == job_id) + else { + let mismatches = self + .core + .announcement_mismatches + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + if mismatches.is_power_of_two() { + warn!( + mismatches, + received_job_id = job_id, + queued_announcements = state.job_announcements.len(), + "merge-mining job has no matching announcement; keeping it Bitcoin-only" + ); + } + return None; + }; + let (_, binding_id) = state.job_announcements.drain(..=position).next_back()?; + if mark_pending { + if let Some(binding_id) = binding_id { + if state.jobs.contains_key(&binding_id) { + state.pending_activation_bindings.insert(binding_id); + } + } + } + binding_id + } + + fn set_active_job_binding(&self, binding_id: Option) { + if let Ok(mut state) = self.core.context.lock() { + if let Some(binding_id) = binding_id { + state.pending_activation_bindings.remove(&binding_id); + } + let retained_binding = + binding_id.filter(|binding_id| state.jobs.contains_key(binding_id)); + if binding_id.is_some() && retained_binding.is_none() { + warn!( + ?binding_id, + "merge-mining binding for active miner job is no longer available" + ); + } + state.active_binding = retained_binding; + } + } + + #[cfg(test)] + fn mark_job_binding_pending(&self, binding_id: u64) { + if let Ok(mut state) = self.core.context.lock() { + if state.jobs.contains_key(&binding_id) { + state.pending_activation_bindings.insert(binding_id); + } + } + } + + fn clear_pending_job_binding(&self, binding_id: u64) { + if let Ok(mut state) = self.core.context.lock() { + state.pending_activation_bindings.remove(&binding_id); + } + } + + pub(crate) fn try_observe_share( + &self, + binding_id: u64, + version: u32, + timestamp: u32, + nonce: u32, + full_extranonce: Vec, + ) { + let work = Work::Share(ShareObservation { + binding_id, + observed_at_unix_ts: unix_timestamp(), + version, + timestamp, + nonce, + full_extranonce, + }); + let sender = self.observer_sender(); + let reason = match sender { + Some(sender) => match sender.try_send(work) { + Ok(()) => return, + Err(error) => work_send_error(&error), + }, + None => "worker-unavailable", + }; + let dropped = self + .core + .dropped_observations + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + if dropped.is_power_of_two() { + debug!( + dropped, + reason, "merge-mining observation skipped without delaying Bitcoin share handling" + ); + } + } + + pub(crate) fn take_found_job(&self) -> Result, MergeMiningError> { + if !self.ensure_observer() { + return Err(MergeMiningError::StateUnavailable); + } + self.core + .found + .lock() + .map(|mut found| found.pending.pop_front()) + .map_err(|_| MergeMiningError::StateUnavailable) + } + + fn wake_worker(&self) { + if !self.ensure_observer() { + return; + } + if let Some(sender) = self.observer_sender() { + let _ = sender.try_send(Work::Wake); + } + } + + fn observer_sender(&self) -> Option> { + if !self.core.observer_ready.load(Ordering::Acquire) { + return None; + } + self.observer.try_lock().ok()?.work_tx.clone() + } + + fn ensure_observer(&self) -> bool { + if self.core.observer_ready.load(Ordering::Acquire) { + return true; + } + let Ok(mut observer) = self.observer.try_lock() else { + return false; + }; + if self.core.observer_ready.load(Ordering::Acquire) { + return true; + } + let now = Instant::now(); + if observer + .retry_after + .is_some_and(|retry_after| retry_after > now) + { + return false; + } + + let (work_tx, work_rx) = mpsc::sync_channel(MAX_PENDING_SHARES); + let worker_core = self.core.clone(); + match std::thread::Builder::new() + .name("merge-mining-observer".to_string()) + .spawn(move || { + let _liveness = ObserverLiveness { + core: worker_core.clone(), + }; + worker_loop(worker_core, work_rx); + }) { + Ok(_) => { + observer.work_tx = Some(work_tx); + observer.retry_after = None; + self.core.observer_ready.store(true, Ordering::Release); + true + } + Err(error) => { + observer.work_tx = None; + observer.retry_after = now.checked_add(Duration::from_secs(5)); + warn!(%error, "merge-mining observer could not start; continuing Bitcoin-only and retrying later"); + false + } + } + } +} + +impl DesiredPair { + fn parse(data_hex: &str, target_hex: &str) -> Result { + let payload_hex = data_hex.trim().to_ascii_lowercase(); + if payload_hex.is_empty() { + return Err(MergeMiningError::EmptyPayload); + } + if payload_hex.len() % 2 != 0 { + return Err(MergeMiningError::InvalidPayloadHex); + } + let payload = + Vec::from_hex(&payload_hex).map_err(|_| MergeMiningError::InvalidPayloadHex)?; + if payload.is_empty() { + return Err(MergeMiningError::EmptyPayload); + } + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(MergeMiningError::PayloadTooLarge(payload.len())); + } + if is_rsk_payload(&payload) + && payload[RSK_TAG.len()..] + .windows(RSK_TAG.len()) + .any(|window| window == RSK_TAG) + { + return Err(MergeMiningError::AmbiguousRskPayload); + } + + let normalized_target = target_hex + .trim() + .strip_prefix("0x") + .or_else(|| target_hex.trim().strip_prefix("0X")) + .unwrap_or(target_hex.trim()) + .to_ascii_lowercase(); + let target_be = + Vec::from_hex(&normalized_target).map_err(|_| MergeMiningError::InvalidTarget)?; + let target_be: [u8; 32] = target_be + .try_into() + .map_err(|_| MergeMiningError::InvalidTarget)?; + let mut target_le = target_be; + target_le.reverse(); + + let push = PushBytesBuf::try_from(payload.clone()) + .map_err(|_| MergeMiningError::InvalidPayloadHex)?; + let output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return(push), + }; + let mut encoded = Vec::new(); + output + .consensus_encode(&mut encoded) + .map_err(|_| MergeMiningError::InvalidPayloadHex)?; + if encoded.len() > RESERVED_COINBASE_OUTPUT_BYTES as usize { + return Err(MergeMiningError::OutputTooLarge(encoded.len())); + } + + Ok(Self { + payload, + payload_hex, + target_le, + target_hex: normalized_target, + output: encoded, + }) + } +} + +fn next_id(counter: &AtomicU64) -> Option { + counter + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .ok() + .filter(|id| *id != 0) +} + +fn invalidate_current_template(state: &mut ContextState, template_id: u64) { + state.current_templates.remove(&template_id); + state.current_jobs.retain(|_, binding_id| { + state + .jobs + .get(binding_id) + .is_some_and(|job| job.template_id != template_id) + }); +} + +fn generation_is_protected(state: &ContextState, generation: u64) -> bool { + let is_active = state + .active_binding + .and_then(|binding_id| state.jobs.get(&binding_id)) + .is_some_and(|job| job.template_generation == generation); + is_active + || state.pending_activation_bindings.iter().any(|binding_id| { + state + .jobs + .get(binding_id) + .is_some_and(|job| job.template_generation == generation) + }) +} + +fn insert_template(state: &mut ContextState, generation: u64, template: TemplateDraft) -> bool { + let template_id = template.template_id; + state.templates.insert(generation, template); + state.current_templates.insert(template_id, generation); + state.template_order.push_back(generation); + while state.template_order.len() > MAX_TEMPLATES { + let expired_index = state + .template_order + .iter() + .position(|candidate| { + *candidate != generation + && !state + .jobs + .values() + .any(|job| job.template_generation == *candidate) + }) + .or_else(|| { + state.template_order.iter().position(|candidate| { + *candidate != generation + && !generation_is_protected(state, *candidate) + && !state.job_announcements.iter().any(|(_, binding_id)| { + binding_id + .and_then(|binding_id| state.jobs.get(&binding_id)) + .is_some_and(|job| job.template_generation == *candidate) + }) + }) + }); + if let Some(expired) = expired_index.and_then(|index| state.template_order.remove(index)) { + remove_template_generation(state, expired); + } else { + remove_template_generation(state, generation); + return false; + } + } + true +} + +fn remove_template_generation(state: &mut ContextState, generation: u64) { + state + .template_order + .retain(|candidate| *candidate != generation); + if let Some(template) = state.templates.remove(&generation) { + if state.current_templates.get(&template.template_id) == Some(&generation) { + state.current_templates.remove(&template.template_id); + } + } + + let expired_bindings: Vec = state + .jobs + .iter() + .filter_map(|(binding_id, job)| { + (job.template_generation == generation).then_some(*binding_id) + }) + .collect(); + for binding_id in &expired_bindings { + if let Some(job) = state.jobs.remove(binding_id) { + if state.current_jobs.get(&job.job_id) == Some(binding_id) { + state.current_jobs.remove(&job.job_id); + } + } + } + if state + .active_binding + .is_some_and(|binding_id| expired_bindings.contains(&binding_id)) + { + state.active_binding = None; + } + state + .pending_activation_bindings + .retain(|binding_id| !expired_bindings.contains(binding_id)); + state + .job_order + .retain(|binding_id| !expired_bindings.contains(binding_id)); +} + +fn insert_job(state: &mut ContextState, job: JobBinding) { + let binding_id = job.binding_id; + state.current_jobs.insert(job.job_id, binding_id); + state.jobs.insert(binding_id, job); + state.job_order.push_back(binding_id); + while state.job_order.len() > MAX_JOBS { + let expired = state + .job_order + .iter() + .position(|candidate| { + Some(*candidate) != state.active_binding + && !state.pending_activation_bindings.contains(candidate) + }) + .and_then(|index| state.job_order.remove(index)); + if let Some(expired) = expired { + if let Some(expired_job) = state.jobs.remove(&expired) { + if state.current_jobs.get(&expired_job.job_id) == Some(&expired) { + state.current_jobs.remove(&expired_job.job_id); + } + } + } else { + break; + } + } +} + +fn create_job_binding( + core: &Core, + state: &mut ContextState, + job_id: u32, + template_generation: u64, + coinbase_prefix: Vec, + coinbase_suffix: Vec, +) -> Option { + state.current_jobs.remove(&job_id); + let template = state.templates.get(&template_generation)?; + if !is_rsk_payload(&template.desired.payload) { + return None; + } + let Some(binding_id) = next_id(&core.next_binding_id) else { + warn!("merge-mining job binding identifier space exhausted"); + return None; + }; + insert_job( + state, + JobBinding { + binding_id, + job_id, + template_id: template.template_id, + template_generation, + chain: template.chain, + coinbase_prefix, + coinbase_suffix, + }, + ); + Some(binding_id) +} + +fn announce_job(state: &mut ContextState, job_id: u32, binding_id: Option) { + if state.job_announcements.len() >= MAX_JOB_ANNOUNCEMENTS { + state.job_announcements.pop_front(); + } + state.job_announcements.push_back((job_id, binding_id)); +} + +fn worker_loop(core: Arc, work_rx: mpsc::Receiver) { + let mut early = VecDeque::new(); + loop { + match work_rx.recv_timeout(std::time::Duration::from_millis(250)) { + Ok(Work::Share(observation)) => { + handle_observation(&core, observation, &mut early); + retry_early(&core, &mut early); + } + Ok(Work::Wake) | Err(mpsc::RecvTimeoutError::Timeout) => retry_early(&core, &mut early), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } +} + +fn handle_observation( + core: &Arc, + observation: ShareObservation, + early: &mut VecDeque, +) { + match resolve_context(core, &observation) { + ResolveContext::Ready(context) => process_candidate(core, observation, *context), + ResolveContext::Incomplete => { + if early.len() >= MAX_EARLY_SHARES { + early.pop_front(); + core.dropped_observations.fetch_add(1, Ordering::Relaxed); + } + early.push_back(observation); + } + ResolveContext::Obsolete => {} + } +} + +fn retry_early(core: &Arc, early: &mut VecDeque) { + let attempts = early.len(); + for _ in 0..attempts { + let Some(observation) = early.pop_front() else { + break; + }; + handle_observation(core, observation, early); + } +} + +fn resolve_context(core: &Core, observation: &ShareObservation) -> ResolveContext { + let state = match core.context.lock() { + Ok(state) => state, + Err(_) => return ResolveContext::Obsolete, + }; + let Some(job) = state.jobs.get(&observation.binding_id) else { + return ResolveContext::Obsolete; + }; + let Some(template) = state.templates.get(&job.template_generation) else { + return ResolveContext::Obsolete; + }; + if !is_rsk_payload(&template.desired.payload) { + return ResolveContext::Obsolete; + } + let (Some(block_tx_count), Some(chain)) = (template.block_tx_count, job.chain) else { + return ResolveContext::Incomplete; + }; + if tree_height(block_tx_count) != Some(template.merkle_path.len()) { + warn!( + template_id = job.template_id, + block_tx_count, + merkle_path_len = template.merkle_path.len(), + "skipping incoherent merge-mining merkle context" + ); + return ResolveContext::Obsolete; + } + ResolveContext::Ready(Box::new(CandidateContext { + template_id: job.template_id, + desired: template.desired.clone(), + merkle_path: template.merkle_path.clone(), + block_tx_count, + chain, + coinbase_prefix: job.coinbase_prefix.clone(), + coinbase_suffix: job.coinbase_suffix.clone(), + })) +} + +fn process_candidate(core: &Core, observation: ShareObservation, context: CandidateContext) { + let mut coinbase_bytes = Vec::with_capacity( + context.coinbase_prefix.len() + + observation.full_extranonce.len() + + context.coinbase_suffix.len(), + ); + coinbase_bytes.extend_from_slice(&context.coinbase_prefix); + coinbase_bytes.extend_from_slice(&observation.full_extranonce); + coinbase_bytes.extend_from_slice(&context.coinbase_suffix); + let mut coinbase: Transaction = match deserialize(&coinbase_bytes) { + Ok(coinbase) => coinbase, + Err(error) => { + debug!(template_id = context.template_id, %error, "skipping invalid merge-mining coinbase reconstruction"); + return; + } + }; + for input in &mut coinbase.input { + input.witness.clear(); + } + let stripped_coinbase = serialize(&coinbase); + if !has_canonical_last_rsk_commitment(&coinbase, &context.desired) + || !raw_rsk_commitment_matches(&stripped_coinbase, &context.desired) + { + warn!( + template_id = context.template_id, + "skipping merge-mining share whose coinbase commitment does not match its template" + ); + return; + } + let coinbase_txid = coinbase.compute_txid(); + let merkle_root = merkle_root_from_path_( + coinbase_txid.to_raw_hash().to_byte_array(), + &context.merkle_path, + ); + let header = Header { + version: Version::from_consensus(observation.version as i32), + prev_blockhash: BlockHash::from_byte_array(context.chain.prev_hash), + merkle_root: TxMerkleNode::from_byte_array(merkle_root), + time: observation.timestamp, + bits: CompactTarget::from_consensus(context.chain.n_bits), + nonce: observation.nonce, + }; + let candidate_target = Target::from(header.block_hash().to_raw_hash().to_byte_array()); + if candidate_target > Target::from(context.desired.target_le) { + return; + } + + let header_hex = serialize(&header).as_hex().to_string(); + let coinbase_hex = stripped_coinbase.as_hex().to_string(); + let Some(id) = next_id(&core.next_found_id) else { + warn!("merge-mining found-job identifier space exhausted"); + return; + }; + let job = FoundJob { + id, + observed_at_unix_ts: observation.observed_at_unix_ts, + template_id: context.template_id, + version: observation.version, + header_timestamp: observation.timestamp, + header_nonce: observation.nonce, + bitcoin_block_hash_hex: header.block_hash().to_string(), + block_header_hex: header_hex.clone(), + coinbase_tx_hex: coinbase_hex.clone(), + merkle_hashes_hex: context + .merkle_path + .iter() + .map(|hash| { + hash.iter() + .rev() + .copied() + .collect::>() + .as_hex() + .to_string() + }) + .collect(), + block_tx_count: context.block_tx_count, + op_return_payload_hex: context.desired.payload_hex, + rsk_target_hex: context.desired.target_hex, + }; + + let mut found = match core.found.lock() { + Ok(found) => found, + Err(_) => return, + }; + if found + .recent + .iter() + .any(|(header, coinbase)| header == &header_hex && coinbase == &coinbase_hex) + { + return; + } + if found.recent.len() >= MAX_DEDUP_IDENTITIES { + found.recent.pop_front(); + } + found.recent.push_back((header_hex, coinbase_hex)); + if found.pending.len() >= MAX_FOUND_JOBS { + found.pending.pop_front(); + let overflows = core + .queue_overflows + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + if overflows.is_power_of_two() { + warn!(overflows, "merge-mining found-job queue full; dropped oldest candidate without affecting Bitcoin mining"); + } + } + debug!(template_id = job.template_id, bitcoin_block_hash = %job.bitcoin_block_hash_hex, "queued RSK merge-mining proof candidate"); + found.pending.push_back(job); +} + +fn is_rsk_payload(payload: &[u8]) -> bool { + payload.len() == RSK_TAG.len() + RSK_HASH_BYTES && payload.starts_with(RSK_TAG) +} + +fn last_raw_tag_position(bytes: &[u8]) -> Option { + bytes + .windows(RSK_TAG.len()) + .rposition(|window| window == RSK_TAG) +} + +fn raw_rsk_commitment_matches(bytes: &[u8], desired: &DesiredPair) -> bool { + if !is_rsk_payload(&desired.payload) { + return false; + } + let Some(position) = last_raw_tag_position(bytes) else { + return false; + }; + let Some(end) = position.checked_add(desired.payload.len()) else { + return false; + }; + bytes.get(position..end) == Some(desired.payload.as_slice()) + && bytes + .len() + .checked_sub(end) + .is_some_and(|trailing| trailing <= RSK_MAX_TRAILING_COINBASE_BYTES) +} + +fn tree_height(block_tx_count: u32) -> Option { + if block_tx_count == 0 || block_tx_count > i32::MAX as u32 { + return None; + } + let mut width = block_tx_count as usize; + let mut height = 0; + while width > 1 { + width = width.div_ceil(2); + height += 1; + } + Some(height) +} + +fn single_op_return_payload(script: &ScriptBuf) -> Option> { + if !script.is_op_return() { + return None; + } + let mut instructions = script.instructions(); + match instructions.next()? { + Ok(Instruction::Op(op)) if op == OP_RETURN => {} + _ => return None, + } + let payload = match instructions.next()? { + Ok(Instruction::PushBytes(bytes)) => bytes.as_bytes().to_vec(), + _ => return None, + }; + instructions.next().is_none().then_some(payload) +} + +fn has_canonical_last_rsk_commitment(transaction: &Transaction, desired: &DesiredPair) -> bool { + transaction + .output + .iter() + .filter_map(|output| { + single_op_return_payload(&output.script_pubkey) + .filter(|payload| payload.starts_with(RSK_TAG)) + .map(|_| serialize(output) == desired.output) + }) + .next_back() + .unwrap_or(false) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn work_send_error(error: &TrySendError) -> &'static str { + match error { + TrySendError::Full(_) => "queue-full", + TrySendError::Disconnected(_) => "worker-unavailable", + } +} + +#[derive(Deserialize)] +pub(crate) struct SetPairRequest { + secret: String, + data_hex: String, + #[serde(default)] + rsk_target_hex: Option, +} + +#[derive(Deserialize)] +pub(crate) struct PollRequest { + secret: String, +} + +#[derive(Serialize)] +pub(crate) struct ApiEnvelope { + success: bool, + message: Option, + data: Option, +} + +impl ApiEnvelope { + fn success(data: Option) -> Self { + Self { + success: true, + message: None, + data, + } + } + + fn error(message: impl Into) -> Self { + Self { + success: false, + message: Some(message.into()), + data: None, + } + } +} + +pub(crate) async fn set_pair_api( + Json(request): Json, +) -> (StatusCode, Json>) { + if let Err((status, message)) = authorize(&request.secret) { + return (status, Json(ApiEnvelope::error(message))); + } + let Some(rsk_target_hex) = request.rsk_target_hex.as_deref() else { + return ( + StatusCode::BAD_REQUEST, + Json(ApiEnvelope::error( + MergeMiningError::InvalidTarget.to_string(), + )), + ); + }; + match global().set_desired_pair(&request.data_hex, rsk_target_hex) { + Ok(accepted) => ( + StatusCode::ACCEPTED, + Json(ApiEnvelope::success(Some(accepted))), + ), + Err(MergeMiningError::StateUnavailable) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiEnvelope::error("merge-mining state is unavailable")), + ), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(ApiEnvelope::error(error.to_string())), + ), + } +} + +pub(crate) async fn poll_found_job_api( + Query(request): Query, +) -> (StatusCode, Json>) { + if let Err((status, message)) = authorize(&request.secret) { + return (status, Json(ApiEnvelope::error(message))); + } + match global().take_found_job() { + Ok(job) => (StatusCode::OK, Json(ApiEnvelope::success(job))), + Err(error) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiEnvelope::error(error.to_string())), + ), + } +} + +fn authorize(supplied: &str) -> Result<(), (StatusCode, &'static str)> { + let expected = std::env::var("API_SECRET") + .ok() + .filter(|secret| !secret.is_empty()) + .ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "merge-mining API secret is not configured", + ))?; + if constant_time_eq(expected.as_bytes(), supplied.as_bytes()) { + Ok(()) + } else { + Err((StatusCode::UNAUTHORIZED, "unauthorized")) + } +} + +fn constant_time_eq(expected: &[u8], supplied: &[u8]) -> bool { + let mut difference = expected.len() ^ supplied.len(); + let length = expected.len().max(supplied.len()); + for index in 0..length { + let left = expected.get(index).copied().unwrap_or(0); + let right = supplied.get(index).copied().unwrap_or(0); + difference |= usize::from(left ^ right); + } + difference == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use binary_sv2::{Seq0255, B0255, B064K, U256}; + use bitcoin::{absolute::LockTime, transaction, OutPoint, Sequence, TxIn, Witness}; + use std::time::Duration; + + const PAYLOAD_HEX: &str = + "52534b424c4f434b3a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + const EASY_TARGET: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + fn template(template_id: u64) -> NewTemplate<'static> { + NewTemplate { + template_id, + future_template: false, + version: 0x2000_0000, + coinbase_tx_version: 2, + coinbase_prefix: B0255::try_from(Vec::new()).expect("empty prefix"), + coinbase_tx_input_sequence: u32::MAX, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs: B064K::try_from(Vec::new()).expect("empty outputs"), + coinbase_tx_locktime: 0, + merkle_path: Seq0255::::from(Vec::new()), + } + } + + fn current_binding(merge: &MergeMining, job_id: u32) -> Option { + merge + .core + .context + .lock() + .expect("state") + .current_jobs + .get(&job_id) + .copied() + } + + fn candidate( + target_hex: &str, + merkle_path: Vec<[u8; 32]>, + block_tx_count: u32, + nonce: u32, + ) -> (ShareObservation, CandidateContext) { + let desired = DesiredPair::parse(PAYLOAD_HEX, target_hex).expect("valid desired pair"); + let payload = Vec::from_hex(PAYLOAD_HEX).expect("payload hex"); + let output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload).expect("push bytes"), + ), + }; + let marker = [0x51_u8; 8]; + let coinbase = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(marker.to_vec()), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![output], + }; + let encoded = serialize(&coinbase); + let marker_at = encoded + .windows(marker.len()) + .position(|window| window == marker) + .expect("marker in coinbase"); + ( + ShareObservation { + binding_id: 1, + observed_at_unix_ts: 1_800_000_000, + version: 0x2000_0000, + timestamp: 1_800_000_000, + nonce, + full_extranonce: marker.to_vec(), + }, + CandidateContext { + template_id: 9, + desired, + merkle_path, + block_tx_count, + chain: ChainState { + prev_hash: [3; 32], + n_bits: 0x207f_ffff, + }, + coinbase_prefix: encoded[..marker_at].to_vec(), + coinbase_suffix: encoded[marker_at + marker.len()..].to_vec(), + }, + ) + } + + fn disconnect_observer(merge: &MergeMining) { + let sender = merge + .observer + .lock() + .expect("observer state") + .work_tx + .take(); + drop(sender); + for _ in 0..1_000 { + if !merge.core.observer_ready.load(Ordering::Acquire) { + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!("observer did not stop after its channel disconnected"); + } + + #[test] + fn unavailable_observer_leaves_the_bitcoin_template_pristine() { + let merge = MergeMining::new(); + disconnect_observer(&merge); + merge.observer.lock().expect("observer state").retry_after = + Some(Instant::now() + Duration::from_secs(60)); + + assert!(matches!( + merge.set_desired_pair(PAYLOAD_HEX, EASY_TARGET), + Err(MergeMiningError::StateUnavailable) + )); + let mut bitcoin_template = template(7); + assert!(matches!( + merge.apply_to_template( + &mut bitcoin_template, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ), + Err(MergeMiningError::StateUnavailable) + )); + assert_eq!(bitcoin_template.coinbase_tx_outputs_count, 0); + assert!(matches!( + merge.take_found_job(), + Err(MergeMiningError::StateUnavailable) + )); + assert!(merge.core.context.lock().expect("state").desired.is_none()); + } + + #[test] + fn unavailable_observer_invalidates_a_reused_template_id() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(70), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("first generation"); + merge.bind_job(7, 70, Vec::new(), Vec::new()); + assert!(current_binding(&merge, 7).is_some()); + + disconnect_observer(&merge); + merge.observer.lock().expect("observer state").retry_after = + Some(Instant::now() + Duration::from_secs(60)); + let mut replacement = template(70); + assert!(matches!( + merge.apply_to_template(&mut replacement, RESERVED_COINBASE_OUTPUT_BYTES as usize,), + Err(MergeMiningError::StateUnavailable) + )); + + let state = merge.core.context.lock().expect("state"); + assert!(!state.current_templates.contains_key(&70)); + assert!(!state.current_jobs.contains_key(&7)); + } + + #[test] + fn failed_job_preflight_discards_the_unpublished_template_generation() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let mut candidate = template(71); + assert!(merge + .apply_to_template(&mut candidate, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("merge candidate")); + let generation = merge.template_generation(71).expect("template generation"); + + merge.discard_template_generation(generation); + + let state = merge.core.context.lock().expect("state"); + assert!(!state.current_templates.contains_key(&71)); + assert!(!state.templates.contains_key(&generation)); + } + + #[test] + fn observer_failure_after_injection_does_not_block_job_binding() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let mut merge_template = template(7); + assert!(merge + .apply_to_template(&mut merge_template, RESERVED_COINBASE_OUTPUT_BYTES as usize,) + .expect("merge template")); + let generation = merge.template_generation(7).expect("template generation"); + + disconnect_observer(&merge); + merge.observer.lock().expect("observer state").retry_after = + Some(Instant::now() + Duration::from_secs(60)); + assert!(merge.bind_job_generation(9, generation, Vec::new(), Vec::new())); + + let state = merge.core.context.lock().expect("state"); + assert_eq!(state.jobs.len(), 1); + assert_eq!(state.job_announcements.len(), 1); + } + + #[test] + fn observer_restarts_after_disconnection() { + let merge = MergeMining::new(); + disconnect_observer(&merge); + + assert!(merge.ensure_observer()); + assert!(merge.core.observer_ready.load(Ordering::Acquire)); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("observer recovered"); + } + + #[test] + fn validates_and_atomically_replaces_desired_pair() { + let merge = MergeMining::new(); + let first = merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + assert_eq!(first.payload_len_bytes, 41); + assert_eq!(first.tx_out_len_bytes, 52); + assert!(!first.replaced_pending); + + assert!(merge.set_desired_pair("0", EASY_TARGET).is_err()); + let replacement = merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("replacement"); + assert!(replacement.replaced_pending); + } + + #[test] + fn rejects_an_rsk_work_hash_containing_another_tag() { + let merge = MergeMining::new(); + let mut payload = RSK_TAG.to_vec(); + payload.extend_from_slice(RSK_TAG); + payload.resize(RSK_TAG.len() + RSK_HASH_BYTES, 0); + + assert!(matches!( + merge.set_desired_pair(&payload.as_hex().to_string(), EASY_TARGET), + Err(MergeMiningError::AmbiguousRskPayload) + )); + } + + #[test] + fn rejects_a_raw_tag_formed_across_the_hash_locktime_boundary() { + let merge = MergeMining::new(); + let mut work_hash = vec![0; RSK_HASH_BYTES]; + work_hash[RSK_HASH_BYTES - 5..].copy_from_slice(b"RSKBL"); + let mut payload = RSK_TAG.to_vec(); + payload.extend_from_slice(&work_hash); + merge + .set_desired_pair(&payload.as_hex().to_string(), EASY_TARGET) + .expect("the hash itself contains no complete second tag"); + + let mut candidate = template(701); + candidate.coinbase_tx_locktime = u32::from_le_bytes(*b"OCK:"); + let original_outputs = candidate.coinbase_tx_outputs.to_vec(); + assert!(matches!( + merge.apply_to_template(&mut candidate, RESERVED_COINBASE_OUTPUT_BYTES as usize), + Err(MergeMiningError::InvalidRskCommitment) + )); + assert_eq!(candidate.coinbase_tx_outputs_count, 0); + assert_eq!(candidate.coinbase_tx_outputs.to_vec(), original_outputs); + assert!(merge.template_generation(701).is_none()); + } + + #[test] + fn injection_is_idempotent_and_keeps_desired_rskblock_last() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let mut template = template(7); + assert!(merge + .apply_to_template(&mut template, RESERVED_COINBASE_OUTPUT_BYTES as usize,) + .expect("injection")); + assert_eq!(template.coinbase_tx_outputs_count, 1); + assert!(merge + .apply_to_template(&mut template, RESERVED_COINBASE_OUTPUT_BYTES as usize,) + .expect("idempotent injection")); + assert_eq!(template.coinbase_tx_outputs_count, 1); + } + + #[test] + fn idempotence_obeys_rskj_raw_tag_and_trailing_byte_rules() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let desired_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(Vec::from_hex(PAYLOAD_HEX).expect("payload")) + .expect("push bytes"), + ), + }; + + let with_filler = |template_id, filler_len| { + let filler = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x51; filler_len]), + }; + let mut outputs = serialize(&desired_output); + outputs.extend_from_slice(&serialize(&filler)); + let mut template = template(template_id); + template.coinbase_tx_outputs_count = 2; + template.coinbase_tx_outputs = B064K::try_from(outputs).expect("outputs"); + template + }; + + let mut exactly_128 = with_filler(72, 115); + assert!(merge + .apply_to_template(&mut exactly_128, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("128 trailing bytes")); + assert_eq!(exactly_128.coinbase_tx_outputs_count, 2); + + let mut trailing_129 = with_filler(73, 116); + assert!(merge + .apply_to_template(&mut trailing_129, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("reappend after 129 trailing bytes")); + assert_eq!(trailing_129.coinbase_tx_outputs_count, 3); + } + + #[test] + fn hidden_raw_tag_at_output_boundary_uses_pristine_fallback() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let ordinary = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let desired_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(Vec::from_hex(PAYLOAD_HEX).expect("payload")) + .expect("push bytes"), + ), + }; + let mut hidden_script = vec![0x51]; + hidden_script.extend_from_slice(RSK_TAG); + hidden_script.extend_from_slice(&[0xff; RSK_HASH_BYTES]); + let hidden = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(hidden_script), + }; + let mut low_count_outputs = serialize(&desired_output); + low_count_outputs.extend_from_slice(&serialize(&hidden)); + let mut low_count = template(741); + low_count.coinbase_tx_outputs_count = 2; + low_count.coinbase_tx_outputs = + B064K::try_from(low_count_outputs).expect("low-count outputs"); + assert!(merge + .apply_to_template(&mut low_count, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("hidden tag requires a final canonical commitment")); + assert_eq!(low_count.coinbase_tx_outputs_count, 3); + + let mut outputs = (0..249) + .flat_map(|_| serialize(&ordinary)) + .collect::>(); + outputs.extend_from_slice(&serialize(&desired_output)); + outputs.extend_from_slice(&serialize(&hidden)); + let mut boundary = template(74); + boundary.coinbase_tx_outputs_count = 251; + boundary.coinbase_tx_outputs = B064K::try_from(outputs.clone()).expect("outputs"); + + assert!(matches!( + merge.apply_to_template(&mut boundary, RESERVED_COINBASE_OUTPUT_BYTES as usize), + Err(MergeMiningError::TooManyTemplateOutputs) + )); + assert_eq!(boundary.coinbase_tx_outputs_count, 251); + assert_eq!(boundary.coinbase_tx_outputs.to_vec(), outputs); + } + + #[test] + fn noncanonical_matching_rsk_push_is_not_treated_as_applied() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let payload = Vec::from_hex(PAYLOAD_HEX).expect("payload hex"); + let mut script = vec![0x6a, 0x4c, payload.len() as u8]; + script.extend_from_slice(&payload); + let noncanonical = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(script), + }; + let mut template = template(71); + template.coinbase_tx_outputs_count = 1; + template.coinbase_tx_outputs = + B064K::try_from(serialize(&noncanonical)).expect("serialized output"); + + assert!(merge + .apply_to_template(&mut template, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("injection")); + assert_eq!(template.coinbase_tx_outputs_count, 2); + } + + #[test] + fn insufficient_reservation_leaves_template_unchanged() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let mut template = template(8); + let original = template.coinbase_tx_outputs.to_vec(); + assert!(matches!( + merge.apply_to_template(&mut template, 51), + Err(MergeMiningError::OutputTooLarge(52)) + )); + assert_eq!(template.coinbase_tx_outputs_count, 0); + assert_eq!(template.coinbase_tx_outputs.to_vec(), original); + } + + #[test] + fn injection_refuses_to_cross_compact_size_output_boundary() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let outputs = (0..251) + .flat_map(|_| serialize(&output)) + .collect::>(); + let mut boundary_template = template(80); + boundary_template.coinbase_tx_outputs_count = 251; + boundary_template.coinbase_tx_outputs = + B064K::try_from(outputs.clone()).expect("serialized outputs"); + + assert!(matches!( + merge.apply_to_template( + &mut boundary_template, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ), + Err(MergeMiningError::TooManyTemplateOutputs) + )); + assert_eq!(boundary_template.coinbase_tx_outputs_count, 251); + assert_eq!(boundary_template.coinbase_tx_outputs.to_vec(), outputs); + + let outputs = (0..250) + .flat_map(|_| serialize(&output)) + .collect::>(); + let mut safe_template = template(801); + safe_template.coinbase_tx_outputs_count = 250; + safe_template.coinbase_tx_outputs = B064K::try_from(outputs).expect("serialized outputs"); + assert!(merge + .apply_to_template(&mut safe_template, RESERVED_COINBASE_OUTPUT_BYTES as usize,) + .expect("safe final output count")); + assert_eq!(safe_template.coinbase_tx_outputs_count, 251); + + let outputs = (0..250) + .flat_map(|_| serialize(&output)) + .collect::>(); + let mut two_pool_output_template = template(803); + two_pool_output_template.coinbase_tx_outputs_count = 250; + two_pool_output_template.coinbase_tx_outputs = + B064K::try_from(outputs.clone()).expect("serialized outputs"); + assert!(matches!( + merge.apply_to_template_with_pool_output_count( + &mut two_pool_output_template, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + 2, + ), + Err(MergeMiningError::TooManyTemplateOutputs) + )); + assert_eq!(two_pool_output_template.coinbase_tx_outputs_count, 250); + assert_eq!( + two_pool_output_template.coinbase_tx_outputs.to_vec(), + outputs + ); + } + + #[test] + fn canonical_last_rsk_output_is_idempotent_at_the_output_boundary() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let ordinary_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let payload = Vec::from_hex(PAYLOAD_HEX).expect("payload hex"); + let rsk_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload).expect("push bytes"), + ), + }; + let mut outputs = (0..250) + .flat_map(|_| serialize(&ordinary_output)) + .collect::>(); + outputs.extend_from_slice(&serialize(&rsk_output)); + let mut boundary_template = template(802); + boundary_template.coinbase_tx_outputs_count = 251; + boundary_template.coinbase_tx_outputs = + B064K::try_from(outputs.clone()).expect("serialized outputs"); + + assert!(merge + .apply_to_template( + &mut boundary_template, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("idempotent boundary output")); + assert_eq!(boundary_template.coinbase_tx_outputs_count, 251); + assert_eq!(boundary_template.coinbase_tx_outputs.to_vec(), outputs); + + let mut too_many_outputs = serialize(&ordinary_output); + too_many_outputs.extend_from_slice(&outputs); + let mut invalid_template = template(804); + invalid_template.coinbase_tx_outputs_count = 252; + invalid_template.coinbase_tx_outputs = + B064K::try_from(too_many_outputs.clone()).expect("serialized outputs"); + + assert!(matches!( + merge.apply_to_template( + &mut invalid_template, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ), + Err(MergeMiningError::TooManyTemplateOutputs) + )); + assert_eq!(invalid_template.coinbase_tx_outputs_count, 252); + assert_eq!( + invalid_template.coinbase_tx_outputs.to_vec(), + too_many_outputs + ); + } + + #[test] + fn reused_template_id_failure_cannot_reactivate_stale_context() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(81), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("first generation"); + merge.bind_job(18, 81, Vec::new(), Vec::new()); + let old_binding = current_binding(&merge, 18).expect("old binding"); + assert_eq!(merge.claim_job_binding(18), Some(old_binding)); + + let mut invalid_reuse = template(81); + invalid_reuse.coinbase_tx_outputs_count = 1; + assert!(matches!( + merge.apply_to_template(&mut invalid_reuse, RESERVED_COINBASE_OUTPUT_BYTES as usize), + Err(MergeMiningError::InvalidTemplateOutputs) + )); + assert!(current_binding(&merge, 18).is_none()); + + merge.bind_job(19, 81, Vec::new(), Vec::new()); + assert_eq!(merge.claim_job_binding(19), None); + let state = merge.core.context.lock().expect("state"); + assert!(state.jobs.contains_key(&old_binding)); + assert!(!state.current_templates.contains_key(&81)); + } + + #[test] + fn lagging_consumer_claims_exact_binding_across_raw_id_reuse() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("first pair"); + merge + .apply_to_template(&mut template(82), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("first generation"); + merge.bind_job(20, 82, vec![1], vec![2]); + let old_binding = current_binding(&merge, 20).expect("old binding"); + + merge + .set_desired_pair(PAYLOAD_HEX, &format!("{}01", "00".repeat(31))) + .expect("second pair"); + merge + .apply_to_template(&mut template(82), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("second generation"); + merge.bind_job(20, 82, vec![3], vec![4]); + let new_binding = current_binding(&merge, 20).expect("new binding"); + + assert_ne!(old_binding, new_binding); + assert_eq!(merge.claim_job_binding(20), Some(old_binding)); + assert_eq!(merge.claim_job_binding(20), Some(new_binding)); + let state = merge.core.context.lock().expect("state"); + assert_ne!( + state.jobs[&old_binding].template_generation, + state.jobs[&new_binding].template_generation + ); + } + + #[test] + fn an_unbound_job_announcement_preserves_the_following_binding() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(84), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("merge template"); + let generation = merge.template_generation(84).expect("generation"); + + merge.announce_unbound_job(30); + merge.bind_job_generation(31, generation, vec![1], vec![2]); + let merge_binding = current_binding(&merge, 31).expect("merge binding"); + + assert_eq!(merge.claim_job_binding(30), None); + assert_eq!(merge.claim_job_binding(31), Some(merge_binding)); + } + + #[test] + fn missing_or_reordered_announcement_does_not_shift_later_bindings() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(85), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("merge template"); + let generation = merge.template_generation(85).expect("generation"); + + merge.announce_unbound_job(40); + assert!(merge.bind_job_generation(41, generation, vec![1], vec![2])); + let expected = current_binding(&merge, 41).expect("binding"); + + assert_eq!(merge.claim_job_binding(999), None); + assert_eq!(merge.claim_job_binding(41), Some(expected)); + assert_eq!(merge.claim_job_binding(40), None); + assert!(merge + .core + .context + .lock() + .expect("state") + .job_announcements + .is_empty()); + } + + #[test] + fn transaction_count_is_generation_scoped_and_write_once() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(86), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("first template"); + let first = merge.template_generation(86).expect("first generation"); + merge.record_transaction_count(first, 2); + + merge + .apply_to_template(&mut template(86), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("replacement template"); + let second = merge.template_generation(86).expect("second generation"); + assert_ne!(first, second); + + merge.record_transaction_count(first, 99); + merge.record_transaction_count(second, 3); + let state = merge.core.context.lock().expect("state"); + assert_eq!(state.templates[&first].block_tx_count, Some(3)); + assert_eq!(state.templates[&second].block_tx_count, Some(4)); + } + + #[test] + fn missing_reconstruction_context_cannot_create_a_binding() { + let merge = MergeMining::new(); + + assert!(!merge.bind_job_generation(32, 999, vec![1], vec![2])); + assert!(merge + .core + .context + .lock() + .expect("state") + .job_announcements + .is_empty()); + } + + #[test] + fn new_template_session_clears_only_mutable_session_indexes() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("pair"); + merge + .apply_to_template(&mut template(83), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("template"); + merge.record_chain_state(83, [7; 32], 0x207f_ffff); + merge.bind_job(21, 83, Vec::new(), Vec::new()); + let binding = current_binding(&merge, 21).expect("binding"); + + merge.begin_template_session(); + + let state = merge.core.context.lock().expect("state"); + assert!(state.current_templates.is_empty()); + assert!(state.current_jobs.is_empty()); + assert!(state.active_chain.is_none()); + assert!(state.job_announcements.is_empty()); + assert!(state.jobs.contains_key(&binding)); + assert!(state + .templates + .contains_key(&state.jobs[&binding].template_generation)); + } + + #[test] + fn future_template_churn_does_not_evict_an_active_job_context() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("pair"); + merge + .apply_to_template(&mut template(90), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("active template"); + merge.bind_job(40, 90, Vec::new(), Vec::new()); + let binding = current_binding(&merge, 40).expect("active binding"); + assert_eq!(merge.claim_job_binding(40), Some(binding)); + merge.set_active_job_binding(Some(binding)); + let active_generation = + merge.core.context.lock().expect("state").jobs[&binding].template_generation; + + for template_id in 1_000..1_000 + MAX_TEMPLATES as u64 + 8 { + let mut future = template(template_id); + future.future_template = true; + merge + .apply_to_template(&mut future, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("future template"); + let generation = merge + .template_generation(template_id) + .expect("future generation"); + let job_id = u32::try_from(template_id).expect("test job id"); + assert!(merge.bind_job_generation(job_id, generation, Vec::new(), Vec::new(),)); + assert!(merge.claim_job_binding(job_id).is_some()); + } + + let state = merge.core.context.lock().expect("state"); + assert!(state.templates.contains_key(&active_generation)); + assert!(state.jobs.contains_key(&binding)); + assert_eq!(state.active_binding, Some(binding)); + assert!(state.template_order.len() <= MAX_TEMPLATES); + } + + #[test] + fn queued_job_context_is_protected_until_it_becomes_active() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("pair"); + + let bind = |merge: &MergeMining, template_id: u64, job_id: u32| { + merge + .apply_to_template( + &mut template(template_id), + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("template"); + let generation = merge.template_generation(template_id).expect("generation"); + assert!(merge.bind_job_generation(job_id, generation, Vec::new(), Vec::new(),)); + let binding = merge.claim_job_binding(job_id).expect("binding"); + (generation, binding) + }; + + let (old_generation, old_binding) = bind(&merge, 91, 41); + merge.set_active_job_binding(Some(old_binding)); + let (queued_generation, queued_binding) = bind(&merge, 92, 42); + merge.mark_job_binding_pending(queued_binding); + + for index in 0..MAX_TEMPLATES as u64 + 8 { + let template_id = 4_000 + index; + let job_id = u32::try_from(template_id).expect("test job id"); + bind(&merge, template_id, job_id); + } + + { + let state = merge.core.context.lock().expect("state"); + assert!(state.templates.contains_key(&old_generation)); + assert!(state.templates.contains_key(&queued_generation)); + assert_eq!(state.active_binding, Some(old_binding)); + assert!(state.pending_activation_bindings.contains(&queued_binding)); + } + + merge.set_active_job_binding(Some(queued_binding)); + merge + .apply_to_template( + &mut template(5_000), + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("next template"); + let state = merge.core.context.lock().expect("state"); + assert!(!state.templates.contains_key(&old_generation)); + assert!(state.templates.contains_key(&queued_generation)); + assert_eq!(state.active_binding, Some(queued_binding)); + assert!(state.pending_activation_bindings.is_empty()); + } + + #[test] + fn claimed_future_stays_protected_before_prevhash_delivery() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("pair"); + + merge + .apply_to_template(&mut template(93), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("active template"); + merge.bind_job(43, 93, Vec::new(), Vec::new()); + let active_binding = merge.claim_job_binding(43).expect("active binding"); + merge.set_active_job_binding(Some(active_binding)); + + merge + .apply_to_template(&mut template(94), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("future template"); + merge.bind_job(44, 94, Vec::new(), Vec::new()); + let future_binding = merge + .claim_job_binding_pending(44) + .expect("claimed future binding"); + let future_generation = + merge.core.context.lock().expect("state").jobs[&future_binding].template_generation; + + for index in 0..MAX_TEMPLATES as u64 - 2 { + let template_id = 6_000 + index; + merge + .apply_to_template( + &mut template(template_id), + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("protected announced template"); + let generation = merge.template_generation(template_id).expect("generation"); + let job_id = u32::try_from(template_id).expect("test job id"); + assert!(merge.bind_job_generation(job_id, generation, Vec::new(), Vec::new(),)); + } + + let mut overflow = template(7_000); + assert!(!merge + .apply_to_template(&mut overflow, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("bounded fallback")); + assert_eq!(overflow.coinbase_tx_outputs_count, 0); + assert!(merge + .core + .context + .lock() + .expect("state") + .templates + .contains_key(&future_generation)); + + merge.clear_pending_job_binding(future_binding); + assert!(merge + .apply_to_template(&mut overflow, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("released future can retire")); + assert!(!merge + .core + .context + .lock() + .expect("state") + .templates + .contains_key(&future_generation)); + } + + #[test] + fn published_history_pressure_keeps_new_merge_templates_live() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("pair"); + + let mut newest_generation = 0; + for index in 0..MAX_TEMPLATES as u64 + 8 { + let template_id = 3_000 + index; + merge + .apply_to_template( + &mut template(template_id), + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("template"); + newest_generation = merge + .template_generation(template_id) + .expect("new template must not evict itself"); + merge.bind_job(index as u32, template_id, Vec::new(), Vec::new()); + assert!(merge.claim_job_binding(index as u32).is_some()); + } + + let state = merge.core.context.lock().expect("state"); + assert!(state.templates.contains_key(&newest_generation)); + assert!(state.template_order.len() <= MAX_TEMPLATES); + assert!(state.jobs.len() <= MAX_TEMPLATES); + } + + #[test] + fn early_share_is_completed_after_template_context_arrives() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + let mut template = template(9); + merge + .apply_to_template(&mut template, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("injection"); + + let payload = Vec::from_hex(PAYLOAD_HEX).expect("payload hex"); + let output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(payload).expect("push bytes"), + ), + }; + let marker = [0x51_u8; 8]; + let coinbase = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(marker.to_vec()), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![output], + }; + let encoded = serialize(&coinbase); + let marker_at = encoded + .windows(marker.len()) + .position(|window| window == marker) + .expect("marker in coinbase"); + merge.bind_job( + 17, + 9, + encoded[..marker_at].to_vec(), + encoded[marker_at + marker.len()..].to_vec(), + ); + let binding_id = current_binding(&merge, 17).expect("bound job"); + merge.try_observe_share(binding_id, 0x2000_0000, 1_800_000_000, 1, marker.to_vec()); + let generation = merge.template_generation(9).expect("generation"); + merge.record_transaction_count(generation, 0); + merge.record_chain_state(9, [3; 32], 0x207f_ffff); + + let mut found = None; + for _ in 0..50 { + found = merge.take_found_job().expect("queue"); + if found.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let found = found.expect("found job"); + assert_eq!(found.template_id, 9); + assert_eq!(found.block_header_hex.len(), 160); + assert!(found.merkle_hashes_hex.is_empty()); + let exported: Transaction = + deserialize(&Vec::from_hex(&found.coinbase_tx_hex).expect("coinbase hex")) + .expect("coinbase"); + assert!(exported.input.iter().all(|input| input.witness.is_empty())); + } + + #[test] + fn exports_multiple_merkle_siblings_in_template_order() { + let merge = MergeMining::new(); + let first = std::array::from_fn(|index| index as u8); + let second = std::array::from_fn(|index| (index as u8).wrapping_add(32)); + let (observation, context) = candidate(EASY_TARGET, vec![first, second], 4, 1); + + process_candidate(&merge.core, observation, context); + + let found = merge.take_found_job().expect("queue").expect("candidate"); + assert_eq!(found.block_tx_count, 4); + assert_eq!( + found.merkle_hashes_hex, + vec![ + first + .iter() + .rev() + .copied() + .collect::>() + .as_hex() + .to_string(), + second + .iter() + .rev() + .copied() + .collect::>() + .as_hex() + .to_string(), + ] + ); + } + + #[test] + fn candidate_above_rsk_target_is_not_queued() { + let merge = MergeMining::new(); + let (observation, context) = candidate(&"00".repeat(32), Vec::new(), 1, 1); + + process_candidate(&merge.core, observation, context); + + assert!(merge.take_found_job().expect("queue").is_none()); + } + + #[test] + fn reconstructed_coinbase_rejects_a_later_hidden_raw_tag() { + let merge = MergeMining::new(); + let (observation, mut context) = candidate(EASY_TARGET, Vec::new(), 1, 1); + let desired_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(context.desired.payload.clone()).expect("push bytes"), + ), + }; + let mut hidden_script = vec![0x51]; + hidden_script.extend_from_slice(RSK_TAG); + hidden_script.extend_from_slice(&[0xff; RSK_HASH_BYTES]); + let marker = [0x51_u8; 8]; + let coinbase = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(marker.to_vec()), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![ + desired_output, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(hidden_script), + }, + ], + }; + assert!(has_canonical_last_rsk_commitment( + &coinbase, + &context.desired + )); + let encoded = serialize(&coinbase); + assert!(!raw_rsk_commitment_matches(&encoded, &context.desired)); + let marker_at = encoded + .windows(marker.len()) + .position(|window| window == marker) + .expect("marker"); + context.coinbase_prefix = encoded[..marker_at].to_vec(); + context.coinbase_suffix = encoded[marker_at + marker.len()..].to_vec(); + + process_candidate(&merge.core, observation, context); + assert!(merge.take_found_job().expect("queue").is_none()); + } + + #[test] + fn raw_rsk_rule_is_inclusive_and_ignores_stripped_witness() { + let desired = DesiredPair::parse(PAYLOAD_HEX, EASY_TARGET).expect("desired"); + let desired_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return( + PushBytesBuf::try_from(desired.payload.clone()).expect("push bytes"), + ), + }; + let transaction_with_filler = |filler_len| Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![ + desired_output.clone(), + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x51; filler_len]), + }, + ], + }; + assert!(raw_rsk_commitment_matches( + &serialize(&transaction_with_filler(115)), + &desired + )); + assert!(!raw_rsk_commitment_matches( + &serialize(&transaction_with_filler(116)), + &desired + )); + + let mut witness_only_tag = transaction_with_filler(0); + let mut hidden = RSK_TAG.to_vec(); + hidden.extend_from_slice(&[0xff; RSK_HASH_BYTES]); + witness_only_tag.input[0].witness.push(hidden); + assert!(!raw_rsk_commitment_matches( + &serialize(&witness_only_tag), + &desired + )); + witness_only_tag.input[0].witness.clear(); + assert!(raw_rsk_commitment_matches( + &serialize(&witness_only_tag), + &desired + )); + } + + #[test] + fn chain_context_is_coherent_in_either_arrival_order() { + let before = MergeMining::new(); + before + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + before.record_chain_state(21, [1; 32], 0x1d00_ffff); + before + .apply_to_template(&mut template(21), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("template"); + + let after = MergeMining::new(); + after + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + after + .apply_to_template(&mut template(22), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("template"); + after.record_chain_state(22, [2; 32], 0x207f_ffff); + + let before_state = before.core.context.lock().expect("state"); + let before_generation = before_state.current_templates[&21]; + let before_chain = before_state.templates[&before_generation] + .chain + .expect("chain"); + assert_eq!(before_chain.prev_hash, [1; 32]); + assert_eq!(before_chain.n_bits, 0x1d00_ffff); + let after_state = after.core.context.lock().expect("state"); + let after_generation = after_state.current_templates[&22]; + let after_chain = after_state.templates[&after_generation] + .chain + .expect("chain"); + assert_eq!(after_chain.prev_hash, [2; 32]); + assert_eq!(after_chain.n_bits, 0x207f_ffff); + } + + #[test] + fn same_template_prevhash_refresh_does_not_rewrite_bound_job_context() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge + .apply_to_template(&mut template(35), RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("template"); + merge.record_chain_state(35, [5; 32], 0x1d00_ffff); + merge.bind_job(40, 35, vec![1], vec![2]); + let first_binding = current_binding(&merge, 40).expect("first binding"); + + merge.record_chain_state(35, [6; 32], 0x207f_ffff); + merge.bind_job(41, 35, vec![3], vec![4]); + let second_binding = current_binding(&merge, 41).expect("second binding"); + + let state = merge.core.context.lock().expect("state"); + assert_eq!( + state.jobs[&first_binding].chain, + Some(ChainState { + prev_hash: [5; 32], + n_bits: 0x1d00_ffff, + }) + ); + assert_eq!( + state.jobs[&second_binding].chain, + Some(ChainState { + prev_hash: [6; 32], + n_bits: 0x207f_ffff, + }) + ); + } + + #[test] + fn non_future_template_inherits_active_chain_but_future_template_waits() { + let merge = MergeMining::new(); + merge + .set_desired_pair(PAYLOAD_HEX, EASY_TARGET) + .expect("valid pair"); + merge.record_chain_state(31, [3; 32], 0x1d00_ffff); + + let mut non_future = template(32); + merge + .apply_to_template(&mut non_future, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("non-future template"); + + let mut future = template(33); + future.future_template = true; + merge + .apply_to_template(&mut future, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("future template"); + + let mut same_id_future = template(31); + same_id_future.future_template = true; + merge + .apply_to_template(&mut same_id_future, RESERVED_COINBASE_OUTPUT_BYTES as usize) + .expect("same-ID future template"); + + { + let state = merge.core.context.lock().expect("state"); + let non_future_generation = state.current_templates[&32]; + let inherited = state.templates[&non_future_generation] + .chain + .expect("active chain"); + assert_eq!(inherited.prev_hash, [3; 32]); + assert_eq!(inherited.n_bits, 0x1d00_ffff); + let future_generation = state.current_templates[&33]; + assert!(state.templates[&future_generation].chain.is_none()); + let same_id_future_generation = state.current_templates[&31]; + assert!(state.templates[&same_id_future_generation].chain.is_none()); + } + + merge.record_chain_state(34, [4; 32], 0x207f_ffff); + let mut reused_non_future = template(31); + merge + .apply_to_template( + &mut reused_non_future, + RESERVED_COINBASE_OUTPUT_BYTES as usize, + ) + .expect("reused-ID non-future template"); + + let state = merge.core.context.lock().expect("state"); + let reused_generation = state.current_templates[&31]; + let reused_chain = state.templates[&reused_generation] + .chain + .expect("latest active chain"); + assert_eq!(reused_chain.prev_hash, [4; 32]); + assert_eq!(reused_chain.n_bits, 0x207f_ffff); + } + + #[test] + fn found_job_poll_is_fifo_and_destructive() { + let merge = MergeMining::new(); + let (first_observation, first_context) = candidate(EASY_TARGET, Vec::new(), 1, 1); + let (second_observation, second_context) = candidate(EASY_TARGET, Vec::new(), 1, 2); + process_candidate(&merge.core, first_observation, first_context); + process_candidate(&merge.core, second_observation, second_context); + + let first = merge.take_found_job().expect("queue").expect("first"); + let second = merge.take_found_job().expect("queue").expect("second"); + assert_eq!(first.header_nonce, 1); + assert_eq!(second.header_nonce, 2); + assert!(first.id < second.id); + assert!(merge.take_found_job().expect("queue").is_none()); + } + + #[test] + fn secret_comparison_checks_content_and_length() { + assert!(constant_time_eq(b"secret", b"secret")); + assert!(!constant_time_eq(b"secret", b"secrex")); + assert!(!constant_time_eq(b"secret", b"secret-longer")); + } +} diff --git a/src/minin_pool_connection/mod.rs b/src/minin_pool_connection/mod.rs index bd637525..bdba8955 100644 --- a/src/minin_pool_connection/mod.rs +++ b/src/minin_pool_connection/mod.rs @@ -155,7 +155,7 @@ pub fn relay_up( break; }; } else { - panic!("Internal Mining downstream try to send invalid message"); + error!("Dropping invalid internal mining message; keeping the proxy alive"); } } }); diff --git a/src/translator/downstream/accept_connection.rs b/src/translator/downstream/accept_connection.rs index e7eced4c..8d8bb62a 100644 --- a/src/translator/downstream/accept_connection.rs +++ b/src/translator/downstream/accept_connection.rs @@ -4,13 +4,13 @@ use crate::{ proxy_state::{DownstreamType, ProxyState}, translator::{ error::Error, proxy::Bridge, upstream::diff_management::UpstreamDifficultyConfig, + MiningNotify, }, }; use super::{downstream::Downstream, task_manager::TaskManager, DownstreamMessages}; use roles_logic_sv2::utils::Mutex; use std::sync::Arc; -use sv1_api::server_to_client; use tokio::sync::{ broadcast, mpsc::{Receiver, Sender}, @@ -23,7 +23,7 @@ use tracing::{debug, error, info}; pub async fn start_accept_connection( task_manager: Arc>, tx_sv1_submit: Sender, - tx_mining_notify: broadcast::Sender>, + tx_mining_notify: broadcast::Sender, bridge: Arc>, upstream_difficulty_config: Arc>, mut downstreams: Receiver, diff --git a/src/translator/downstream/downstream.rs b/src/translator/downstream/downstream.rs index b221c10c..5faee99f 100644 --- a/src/translator/downstream/downstream.rs +++ b/src/translator/downstream/downstream.rs @@ -9,7 +9,11 @@ use crate::{ proxy_state::{DownstreamType, ProxyState, UpstreamType}, share_log_enabled, shared::utils::AbortOnDrop, - translator::{error::Error, utils::validate_share}, + translator::{ + error::Error, + utils::{effective_version, validate_share}, + MiningNotify, + }, }; use super::{ @@ -139,6 +143,7 @@ pub struct Downstream { pub(super) stats_sender: StatsSender, pub recent_jobs: RecentJobs, pub first_job: Notify<'static>, + pub(super) first_job_merge_mining_binding_id: Option, pub share_monitor: SharesMonitor, pub user_agent: std::cell::RefCell, // RefCell is used here because `handle_subscribe` and `handle_authorize` take &self not &mut self and we need to mutate user_agent pub token: Arc>, @@ -156,9 +161,9 @@ impl Downstream { pub async fn new_downstream( connection_id: u32, tx_sv1_bridge: Sender, - rx_sv1_notify: broadcast::Receiver>, + rx_sv1_notify: broadcast::Receiver, extranonce1: Vec, - last_notify: Option>, + last_notify: Option, extranonce2_len: usize, host: String, upstream_difficulty_config: Arc>, @@ -185,6 +190,8 @@ impl Downstream { ); assert!(last_notify.is_some()); + let last_notify = + last_notify.expect("we have an assertion at the beginning of this function"); let (tx_outgoing, receiver_outgoing) = channel(crate::TRANSLATOR_BUFFER_SIZE); // The PID controller uses negative proportional (P) and integral (I) gains to reduce difficulty @@ -242,7 +249,8 @@ impl Downstream { last_call_to_update_hr: 0, stats_sender, recent_jobs: RecentJobs::new(), - first_job: last_notify.expect("we have an assertion at the beginning of this function"), + first_job: last_notify.notify, + first_job_merge_mining_binding_id: last_notify.merge_mining_binding_id, share_monitor: SharesMonitor::new(connection_id, token.clone()), user_agent: std::cell::RefCell::new(String::new()), token, @@ -338,7 +346,7 @@ impl Downstream { /// new `Downstream` for each connection. pub async fn accept_connections( tx_sv1_submit: Sender, - tx_mining_notify: broadcast::Sender>, + tx_mining_notify: broadcast::Sender, bridge: Arc>, upstream_difficulty_config: Arc>, downstreams: Receiver, @@ -759,12 +767,32 @@ impl Downstream { let mut upstream_request = request.clone(); upstream_request.job_id = job.notify.job_id.clone(); + // Merge-mining observation is an independent, bounded, nonblocking side path. Run it + // before the normal Bitcoin-share difficulty gate so an authenticated miner submission + // that only meets the RSK target can never be hidden by the Bitcoin relay policy. + if let Some(binding_id) = job.merge_mining_binding_id { + let version = effective_version( + job.notify.version.0, + request.version_bits.as_ref(), + version_rolling_mask.as_ref(), + ); + let mut full_extranonce = extranonce1.clone(); + full_extranonce.extend_from_slice(request.extra_nonce2.0.as_ref()); + crate::merge_mining::global().try_observe_share( + binding_id, + version, + request.time.0, + request.nonce.0, + full_extranonce, + ); + } + if !validate_share( &upstream_request, &job.notify, job.difficulty, - extranonce1, - version_rolling_mask, + &extranonce1, + version_rolling_mask.clone(), ) { let share = ShareInfo::new( request.user_name.clone(), @@ -953,6 +981,7 @@ impl Downstream { upstream_difficulty_config, last_call_to_update_hr: 0, first_job, + first_job_merge_mining_binding_id: None, stats_sender, recent_jobs: RecentJobs::new(), share_monitor: SharesMonitor::new(connection_id, token.clone()), @@ -1009,10 +1038,11 @@ impl IsServer<'static> for Downstream { self.version_rolling_mask = Some(version_rolling_mask.clone()); self.version_rolling_min_bit = Some(version_rolling_min_bit_count.clone()); let mut first_job = self.first_job.clone(); - self.recent_jobs.add_job( + self.recent_jobs.add_job_with_binding( &mut first_job, self.version_rolling_mask.clone(), self.current_difficulty(), + self.first_job_merge_mining_binding_id, ); self.first_job = first_job; @@ -1187,6 +1217,13 @@ const TRACKED_RECENT_JOBS: usize = 3; pub(crate) struct IssuedJob { pub notify: Notify<'static>, pub difficulty: f32, + merge_mining_binding_id: Option, +} + +#[derive(Debug, Clone)] +struct RawJob { + notify: Notify<'static>, + merge_mining_binding_id: Option, } #[derive(Debug)] @@ -1194,7 +1231,7 @@ pub struct RecentJobs { v1_to_v2: HashMap, v2_to_v1: HashMap>, issued_jobs: HashMap, - jobs: VecDeque>, + jobs: VecDeque, last_v2s: CircularBuffer, tracked_jobs: usize, } @@ -1203,21 +1240,23 @@ fn apply_mask(mask: Option, message: &mut server_to_client::Notify<'st message.version = HexU32Be(message.version.0 & !mask.0); } } + impl RecentJobs { - pub(crate) fn add_job( + pub(crate) fn add_job_with_binding( &mut self, notify: &mut Notify<'static>, mask: Option, difficulty: f32, + candidate_binding_id: Option, ) { apply_mask(mask, notify); // save it with the v2 id - self.jobs.push_back(notify.clone()); - let new_id = self.new_v1( - notify.job_id.parse::().unwrap(), - notify.clone(), - difficulty, - ); + let v2_id = notify.job_id.parse::().unwrap(); + self.jobs.push_back(RawJob { + notify: notify.clone(), + merge_mining_binding_id: candidate_binding_id, + }); + let new_id = self.new_v1(v2_id, notify.clone(), difficulty, candidate_binding_id); // send it with the v1 id notify.job_id = new_id.to_string(); if self.jobs.len() > self.tracked_jobs { @@ -1227,24 +1266,36 @@ impl RecentJobs { pub(crate) fn clone_last(&mut self, difficulty: f32) -> Option> { if let Some(job) = self.jobs.back() { - let mut job = job.clone(); - let new_id = self.new_v1(job.job_id.parse::().unwrap(), job.clone(), difficulty); - job.job_id = new_id.to_string(); - Some(job.clone()) + let mut notify = job.notify.clone(); + let v2_id = notify.job_id.parse::().unwrap(); + let new_id = self.new_v1( + v2_id, + notify.clone(), + difficulty, + job.merge_mining_binding_id, + ); + notify.job_id = new_id.to_string(); + Some(notify) } else { None } } pub(crate) fn current_jobs(&self) -> VecDeque> { - self.jobs.clone() + self.jobs.iter().map(|job| job.notify.clone()).collect() } pub(crate) fn get_matching_job(&self, v1_id: u32) -> Option { self.issued_jobs.get(&v1_id).cloned() } - fn new_v1(&mut self, v2_id: u32, notify: Notify<'static>, difficulty: f32) -> u32 { + fn new_v1( + &mut self, + v2_id: u32, + notify: Notify<'static>, + difficulty: f32, + merge_mining_binding_id: Option, + ) -> u32 { let mut v1_id = rand::thread_rng().gen(); while self.v1_to_v2.contains_key(&v1_id) { v1_id = rand::thread_rng().gen(); @@ -1261,8 +1312,14 @@ impl RecentJobs { } } self.v1_to_v2.insert(v1_id, v2_id); - self.issued_jobs - .insert(v1_id, IssuedJob { notify, difficulty }); + self.issued_jobs.insert( + v1_id, + IssuedJob { + notify, + difficulty, + merge_mining_binding_id, + }, + ); v1_id } fn remove_v2(&mut self, v2_id: u32) { @@ -1832,7 +1889,7 @@ mod tests { let mut recent_jobs = RecentJobs::new(); let mut job = first_job("42"); - recent_jobs.add_job(&mut job, None, 1.0); + recent_jobs.add_job_with_binding(&mut job, None, 1.0, None); let first_v1_id = job.job_id.parse::().unwrap(); let first_snapshot = recent_jobs.get_matching_job(first_v1_id).unwrap(); assert_eq!(first_snapshot.notify.job_id, "42"); @@ -1848,26 +1905,85 @@ mod tests { assert_eq!(original_snapshot.difficulty, 1.0); } + #[test] + fn recent_jobs_keep_merge_mining_binding_without_changing_bitcoin_difficulty() { + let mut recent_jobs = RecentJobs::new(); + let mut job = first_job("42"); + + recent_jobs.add_job_with_binding(&mut job, None, 16_777_216.0, Some(7)); + let first_v1_id = job.job_id.parse::().unwrap(); + let first_snapshot = recent_jobs.get_matching_job(first_v1_id).unwrap(); + assert_eq!(first_snapshot.difficulty, 16_777_216.0); + assert_eq!(first_snapshot.merge_mining_binding_id, Some(7)); + + let reissued_job = recent_jobs.clone_last(33_554_432.0).unwrap(); + let reissued_v1_id = reissued_job.job_id.parse::().unwrap(); + let reissued_snapshot = recent_jobs.get_matching_job(reissued_v1_id).unwrap(); + assert_eq!(reissued_snapshot.difficulty, 33_554_432.0); + assert_eq!(reissued_snapshot.merge_mining_binding_id, Some(7)); + } + + #[test] + fn recent_jobs_keep_merge_mining_generation_across_v2_id_reuse() { + let mut recent_jobs = RecentJobs::new(); + let old_notify = first_job("42"); + recent_jobs.jobs.push_back(RawJob { + notify: old_notify.clone(), + merge_mining_binding_id: Some(7), + }); + let old_v1_id = recent_jobs.new_v1(42, old_notify, 1.0, Some(7)); + + let new_notify = first_job("42"); + recent_jobs.jobs.push_back(RawJob { + notify: new_notify.clone(), + merge_mining_binding_id: Some(8), + }); + let new_v1_id = recent_jobs.new_v1(42, new_notify, 4.0, Some(8)); + + assert_eq!( + recent_jobs + .get_matching_job(old_v1_id) + .unwrap() + .merge_mining_binding_id, + Some(7) + ); + assert_eq!( + recent_jobs + .get_matching_job(new_v1_id) + .unwrap() + .merge_mining_binding_id, + Some(8) + ); + assert_eq!( + recent_jobs.jobs.front().unwrap().merge_mining_binding_id, + Some(7) + ); + assert_eq!( + recent_jobs.jobs.back().unwrap().merge_mining_binding_id, + Some(8) + ); + } + #[test] fn recent_jobs_preserve_three_v2_job_retention() { let mut recent_jobs = RecentJobs::new(); let mut job_1 = first_job("1"); - recent_jobs.add_job(&mut job_1, None, 1.0); + recent_jobs.add_job_with_binding(&mut job_1, None, 1.0, None); let job_1_v1 = job_1.job_id.parse::().unwrap(); let job_1_reissued = recent_jobs.clone_last(2.0).unwrap(); let job_1_reissued_v1 = job_1_reissued.job_id.parse::().unwrap(); let mut job_2 = first_job("2"); - recent_jobs.add_job(&mut job_2, None, 2.0); + recent_jobs.add_job_with_binding(&mut job_2, None, 2.0, None); let job_2_v1 = job_2.job_id.parse::().unwrap(); let mut job_3 = first_job("3"); - recent_jobs.add_job(&mut job_3, None, 3.0); + recent_jobs.add_job_with_binding(&mut job_3, None, 3.0, None); let job_3_v1 = job_3.job_id.parse::().unwrap(); let mut job_4 = first_job("4"); - recent_jobs.add_job(&mut job_4, None, 4.0); + recent_jobs.add_job_with_binding(&mut job_4, None, 4.0, None); let job_4_v1 = job_4.job_id.parse::().unwrap(); assert_eq!(recent_jobs.current_jobs().len(), 3); diff --git a/src/translator/downstream/notify.rs b/src/translator/downstream/notify.rs index 55e98c14..d7f02791 100644 --- a/src/translator/downstream/notify.rs +++ b/src/translator/downstream/notify.rs @@ -1,6 +1,7 @@ use crate::proxy_state::{DownstreamType, ProxyState}; use crate::translator::downstream::SUBSCRIBE_TIMEOUT_SECS; use crate::translator::error::Error; +use crate::translator::MiningNotify; use super::{downstream::Downstream, task_manager::TaskManager}; use roles_logic_sv2::utils::Mutex; @@ -18,10 +19,11 @@ fn current_or_initial_job(downstream: &mut Downstream) -> server_to_client::Noti } let mut first_job = downstream.first_job.clone(); - downstream.recent_jobs.add_job( + downstream.recent_jobs.add_job_with_binding( &mut first_job, downstream.version_rolling_mask.clone(), difficulty, + downstream.first_job_merge_mining_binding_id, ); first_job } @@ -29,7 +31,7 @@ fn current_or_initial_job(downstream: &mut Downstream) -> server_to_client::Noti pub async fn start_notify( task_manager: Arc>, downstream: Arc>, - mut rx_sv1_notify: broadcast::Receiver>, + mut rx_sv1_notify: broadcast::Receiver, host: String, connection_id: u32, ) -> Result<(), Error<'static>> { @@ -126,7 +128,7 @@ pub async fn start_notify( warn!("Translator impossible to start update task: {e}"); } else if authorized_in_time { loop { - let mut sv1_mining_notify_msg = match rx_sv1_notify.recv().await { + let mining_notify = match rx_sv1_notify.recv().await { Ok(msg) => msg, Err(broadcast::error::RecvError::Lagged(skipped)) => { warn!( @@ -137,14 +139,21 @@ pub async fn start_notify( } Err(broadcast::error::RecvError::Closed) => break, }; + let mut sv1_mining_notify_msg = mining_notify.notify; + let merge_mining_binding_id = mining_notify.merge_mining_binding_id; if downstream .safe_lock(|d| { d.first_job = sv1_mining_notify_msg.clone(); + d.first_job_merge_mining_binding_id = merge_mining_binding_id; let mask = d.version_rolling_mask.clone(); let difficulty = d.current_difficulty(); - d.recent_jobs - .add_job(&mut sv1_mining_notify_msg, mask, difficulty); + d.recent_jobs.add_job_with_binding( + &mut sv1_mining_notify_msg, + mask, + difficulty, + merge_mining_binding_id, + ); debug!( "Downstream {}: Added job_id {} to recent_notifies. Current jobs: {:?}", connection_id, diff --git a/src/translator/mod.rs b/src/translator/mod.rs index adcaeb74..89afb6eb 100644 --- a/src/translator/mod.rs +++ b/src/translator/mod.rs @@ -9,7 +9,11 @@ mod utils; use bitcoin::Address; use error::Error; -use roles_logic_sv2::{parsers::Mining, utils::Mutex}; +use roles_logic_sv2::{ + mining_sv2::{NewExtendedMiningJob, SetNewPrevHash}, + parsers::Mining, + utils::Mutex, +}; use tracing::error; use std::sync::Arc; @@ -28,6 +32,21 @@ use self::upstream::diff_management::UpstreamDifficultyConfig; mod task_manager; use task_manager::TaskManager; +#[derive(Debug, Clone)] +pub(crate) struct MiningNotify { + pub(crate) notify: server_to_client::Notify<'static>, + pub(crate) merge_mining_binding_id: Option, +} + +#[derive(Debug)] +pub(crate) enum BridgeWork { + NewExtendedMiningJob { + job: NewExtendedMiningJob<'static>, + merge_mining_binding_id: Option, + }, + SetNewPrevHash(SetNewPrevHash<'static>), +} + pub async fn start( downstreams: TReceiver, pool_connection: TSender<( @@ -63,16 +82,10 @@ pub async fn start( let (tx_sv2_submit_shares_ext, rx_sv2_submit_shares_ext) = channel(crate::TRANSLATOR_BUFFER_SIZE); - // Sender/Receiver to send a SV2 `SetNewPrevHash` message from the `Upstream` to the `Bridge` - // (Sender>, Receiver>) - let (tx_sv2_set_new_prev_hash, rx_sv2_set_new_prev_hash) = - channel(crate::TRANSLATOR_BUFFER_SIZE); - - // Sender/Receiver to send a SV2 `NewExtendedMiningJob` message from the `Upstream` to the - // `Bridge` - // (Sender>, Receiver>) - let (tx_sv2_new_ext_mining_job, rx_sv2_new_ext_mining_job) = - channel(crate::TRANSLATOR_BUFFER_SIZE); + // One FIFO preserves the pool's ordering between jobs and prevhash transitions. Processing + // these message types in separate tasks can pair a post-prevhash job with stale factory state + // even though both source streams were individually ordered. + let (tx_bridge_work, rx_bridge_work) = channel(crate::TRANSLATOR_BUFFER_SIZE); // Sender/Receiver to send a new extranonce from the `Upstream` to this `main` function to be // passed to the `Downstream` upon a Downstream role connection @@ -83,8 +96,8 @@ pub async fn start( // Sender/Receiver to send SV1 `mining.notify` message from the `Bridge` to the `Downstream` let (tx_sv1_notify, _): ( - broadcast::Sender, - broadcast::Receiver, + broadcast::Sender, + broadcast::Receiver, ) = broadcast::channel(crate::TRANSLATOR_BUFFER_SIZE); let channel_nominal_hashrate = 0.0; @@ -97,8 +110,7 @@ pub async fn start( // Instantiate a new `Upstream` (SV2 Pool) let upstream = upstream::Upstream::new( - tx_sv2_set_new_prev_hash, - tx_sv2_new_ext_mining_job, + tx_bridge_work, crate::MIN_EXTRANONCE_SIZE - 1, tx_sv2_extranonce, target.clone(), @@ -162,20 +174,14 @@ pub async fn start( } }; - let bridge_aborter = match proxy::Bridge::start( - b.clone(), - rx_sv2_set_new_prev_hash, - rx_sv2_new_ext_mining_job, - rx_sv1_bridge, - ) - .await - { - Ok(abortable) => abortable, - Err(e) => { - error!("Failed to start bridge: {e}"); - return; - } - }; + let bridge_aborter = + match proxy::Bridge::start(b.clone(), rx_bridge_work, rx_sv1_bridge).await { + Ok(abortable) => abortable, + Err(e) => { + error!("Failed to start bridge: {e}"); + return; + } + }; let downstream_aborter = match downstream::Downstream::accept_connections( tx_sv1_bridge, diff --git a/src/translator/proxy/bridge.rs b/src/translator/proxy/bridge.rs index f7cfef78..15270351 100644 --- a/src/translator/proxy/bridge.rs +++ b/src/translator/proxy/bridge.rs @@ -13,7 +13,7 @@ use std::sync::{ atomic::{AtomicU32, Ordering}, Arc, }; -use sv1_api::{client_to_server::Submit, server_to_client, utils::HexU32Be}; +use sv1_api::{client_to_server::Submit, utils::HexU32Be}; use tokio::sync::broadcast; use super::{ @@ -23,6 +23,7 @@ use super::{ UpstreamSubmitShare, }, error::{Error, ProxyResult}, + MiningNotify, }, task_manager::TaskManager, }; @@ -30,7 +31,10 @@ use crate::{ proxy_state::{ProxyState, TranslatorState, UpstreamType}, share_log_enabled, shared::utils::AbortOnDrop, - translator::utils::{allow_submit_share, submit_error_to_rejection_reason}, + translator::{ + utils::{allow_submit_share, submit_error_to_rejection_reason}, + BridgeWork, + }, }; use lazy_static::lazy_static; use roles_logic_sv2::{channel_logic::channel_factory::OnNewShare, Error as RolesLogicError}; @@ -40,6 +44,12 @@ lazy_static! { static ref SUBMIT_FAIL_COUNTER: AtomicU32 = AtomicU32::new(0); } +#[derive(Debug, Clone)] +struct BoundExtendedJob { + job: NewExtendedMiningJob<'static>, + merge_mining_binding_id: Option, +} + /// Bridge between the SV2 `Upstream` and SV1 `Downstream` responsible for the following messaging /// translation: /// 1. SV1 `mining.submit` -> SV2 `SubmitSharesExtended` @@ -51,7 +61,7 @@ pub struct Bridge { tx_sv2_submit_shares_ext: tokio::sync::mpsc::Sender, /// Sends SV1 `mining.notify` message (translated from the SV2 `SetNewPrevHash` and /// `NewExtendedMiningJob` messages stored in the `NextMiningNotify`) to the `Downstream`. - tx_sv1_notify: broadcast::Sender>, + tx_sv1_notify: broadcast::Sender, /// Stores the most recent SV1 `mining.notify` values to be sent to the `Downstream` upon /// receiving a new SV2 `SetNewPrevHash` and `NewExtendedMiningJob` messages **before** any /// Downstream role connects to the proxy. @@ -62,9 +72,9 @@ pub struct Bridge { /// first notify values to be relayed to the `Downstream` once a Downstream role connects. Once /// a Downstream role connects and receives the first notify values, this member field is no /// longer used. - last_notify: Option>, + last_notify: Option, pub(self) channel_factory: ProxyExtendedChannelFactory, - future_jobs: Vec>, + future_jobs: Vec, last_p_hash: Option>, target: Arc>>, } @@ -97,7 +107,7 @@ impl Bridge { /// Instantiate a new `Bridge`. pub fn new( tx_sv2_submit_shares_ext: tokio::sync::mpsc::Sender, - tx_sv1_notify: broadcast::Sender>, + tx_sv1_notify: broadcast::Sender, extranonces: ExtendedExtranonce, target: Arc>>, channel_id: u32, @@ -182,8 +192,7 @@ impl Bridge { /// respective roles. pub async fn start( self_: Arc>, - rx_sv2_set_new_prev_hash: tokio::sync::mpsc::Receiver>, - rx_sv2_new_ext_mining_job: tokio::sync::mpsc::Receiver>, + rx_bridge_work: tokio::sync::mpsc::Receiver, rx_sv1_downstream: tokio::sync::mpsc::Receiver, ) -> Result> { let task_manager = TaskManager::initialize(); @@ -192,20 +201,11 @@ impl Bridge { .map_err(|_| Error::BridgeTaskManagerMutexPoisoned)? .ok_or(Error::BridgeTaskManagerFailed)?; - let new_prev_hash_handler = - Self::handle_new_prev_hash(self_.clone(), rx_sv2_set_new_prev_hash)?; - let new_ext_m_job_handler = - Self::handle_new_extended_mining_job(self_.clone(), rx_sv2_new_ext_mining_job)?; + let upstream_work_handler = Self::handle_upstream_work(self_.clone(), rx_bridge_work)?; let downs_message_handler = Self::handle_downstream_messages(self_, rx_sv1_downstream); - TaskManager::add_handle_new_prev_hash(task_manager.clone(), new_prev_hash_handler.into()) + TaskManager::add_handle_upstream_work(task_manager.clone(), upstream_work_handler.into()) .await .map_err(|_| Error::BridgeTaskManagerFailed)?; - TaskManager::add_handle_new_extended_mining_job( - task_manager.clone(), - new_ext_m_job_handler.into(), - ) - .await - .map_err(|_| Error::BridgeTaskManagerFailed)?; TaskManager::add_handle_downstream_messages( task_manager.clone(), downs_message_handler.into(), @@ -527,13 +527,8 @@ impl Bridge { async fn handle_new_prev_hash_( self_: Arc>, sv2_set_new_prev_hash: SetNewPrevHash<'static>, - tx_sv1_notify: broadcast::Sender>, + tx_sv1_notify: broadcast::Sender, ) -> Result<(), Error<'static>> { - while !super::super::upstream::upstream::IS_NEW_JOB_HANDLED - .load(std::sync::atomic::Ordering::SeqCst) - { - tokio::task::yield_now().await; - } self_ .safe_lock(|s| s.last_p_hash = Some(sv2_set_new_prev_hash.clone())) .map_err(|_| Error::BridgeMutexPoisoned)?; @@ -559,20 +554,23 @@ impl Bridge { let mut match_a_future_job = false; while let Some(job) = future_jobs.pop() { - if job.job_id == sv2_set_new_prev_hash.job_id { + if job.job.job_id == sv2_set_new_prev_hash.job_id { // Create the mining.notify to be sent to the Downstream. let notify = super::super::proxy::next_mining_notify::create_notify( sv2_set_new_prev_hash.clone(), - job, + job.job, true, extranonce_len, ); // Get the sender to send the mining.notify to the Downstream + let notify = MiningNotify { + notify, + merge_mining_binding_id: job.merge_mining_binding_id, + }; + crate::merge_mining::set_active_job_binding(notify.merge_mining_binding_id); if tx_sv1_notify.send(notify.clone()).is_err() { - error!("Failed to send mining.notify"); - // Update translator state to down - ProxyState::update_translator_state(TranslatorState::Down); + debug!("mining.notify has no current listeners"); }; match_a_future_job = true; self_ @@ -580,7 +578,14 @@ impl Bridge { s.last_notify = Some(notify); }) .map_err(|_| Error::BridgeMutexPoisoned)?; + for discarded in future_jobs.drain(..) { + crate::merge_mining::clear_pending_job_binding( + discarded.merge_mining_binding_id, + ); + } break; + } else { + crate::merge_mining::clear_pending_job_binding(job.merge_mining_binding_id); } } if !match_a_future_job { @@ -589,59 +594,11 @@ impl Bridge { Ok(()) } - /// Receives a SV2 `SetNewPrevHash` message from the `Upstream` and creates a SV1 - /// `mining.notify` message (in conjunction with a previously received SV2 - /// `NewExtendedMiningJob` message) which is sent to the `Downstream`. The protocol requires - /// that before every received `SetNewPrevHash`, a `NewExtendedMiningJob` with a - /// corresponding `job_id` has already been received. If this is not the case, an error has - /// occurred on the Upstream pool role and the connection will close. - fn handle_new_prev_hash( - self_: Arc>, - mut rx_sv2_set_new_prev_hash: tokio::sync::mpsc::Receiver>, - ) -> Result, Error<'static>> { - info!("Received SV2 SetNewPrevHash messages from Pool"); - let tx_sv1_notify = self_ - .safe_lock(|s| s.tx_sv1_notify.clone()) - .map_err(|_| Error::BridgeMutexPoisoned)?; - Ok(tokio::task::spawn(async move { - loop { - // Receive `SetNewPrevHash` from `Upstream` - let sv2_set_new_prev_hash: SetNewPrevHash = - match rx_sv2_set_new_prev_hash.recv().await { - Some(set_new_prev_hash) => set_new_prev_hash, - None => { - error!("Failed to receive SetNewPrevHash"); - ProxyState::update_translator_state(TranslatorState::Down); - break; - } - }; - let mut dbg_prev_hash = sv2_set_new_prev_hash.prev_hash.to_vec(); - dbg_prev_hash.reverse(); - debug!( - "Received NewPrevHash {} for channel {} with job {}", - dbg_prev_hash.as_hex(), - sv2_set_new_prev_hash.channel_id, - sv2_set_new_prev_hash.job_id - ); - if let Err(e) = Self::handle_new_prev_hash_( - self_.clone(), - sv2_set_new_prev_hash, - tx_sv1_notify.clone(), - ) - .await - { - error!("Failed to handle SetNewPrevHash: {e}"); - ProxyState::update_upstream_state(UpstreamType::TranslatorUpstream); - return; - } - } - })) - } - async fn handle_new_extended_mining_job_( self_: Arc>, sv2_new_extended_mining_job: NewExtendedMiningJob<'static>, - tx_sv1_notify: broadcast::Sender>, + tx_sv1_notify: broadcast::Sender, + merge_mining_binding_id: Option, ) -> Result<(), Error<'static>> { // convert to non segwit jobs so we dont have to depend if miner's support segwit or not self_ @@ -654,13 +611,20 @@ impl Bridge { Error::RolesSv2Logic(RolesLogicError::JobIsNotFutureButPrevHashNotPresent) })?; - let extranonce_len = self_.safe_lock(|s| s.channel_factory.get_extranonce_len())?; + let extranonce_len = self_ + .safe_lock(|s| s.channel_factory.get_extranonce_len()) + .map_err(|_| Error::BridgeMutexPoisoned)?; // If future_job=true, this job is meant for a future SetNewPrevHash that the proxy // has yet to receive. Insert this new job into the job_mapper . if sv2_new_extended_mining_job.is_future() { self_ - .safe_lock(|s| s.future_jobs.push(sv2_new_extended_mining_job.clone())) + .safe_lock(|s| { + s.future_jobs.push(BoundExtendedJob { + job: sv2_new_extended_mining_job.clone(), + merge_mining_binding_id, + }) + }) .map_err(|_| Error::BridgeMutexPoisoned)?; Ok(()) @@ -685,9 +649,17 @@ impl Bridge { extranonce_len, ); // Get the sender to send the mining.notify to the Downstream - tx_sv1_notify - .send(notify.clone()) - .map_err(|_| Error::AsyncChannelError)?; + let notify = MiningNotify { + notify, + merge_mining_binding_id, + }; + crate::merge_mining::set_active_job_binding(notify.merge_mining_binding_id); + if tx_sv1_notify.send(notify.clone()).is_err() { + debug!( + job_id = sv2_new_extended_mining_job.job_id, + "mining.notify has no current listeners" + ); + } self_ .safe_lock(|s| { @@ -698,54 +670,68 @@ impl Bridge { } } - /// Receives a SV2 `NewExtendedMiningJob` message from the `Upstream`. If `future_job=true`, - /// this job is intended for a future SV2 `SetNewPrevHash` that has yet to be received. This - /// job is stored until a SV2 `SetNewPrevHash` message with a corresponding `job_id` is - /// received. If `future_job=false`, this job is intended for the SV2 `SetNewPrevHash` that is - /// currently being mined on. In this case, a SV1 `mining.notify` is created and is sent to the - /// `Downstream`. If `future_job=false` but this job's `job_id` does not match the current SV2 - /// `SetNewPrevHash` `job_id`, an error has occurred on the Upstream pool role and the - /// connection will close. - fn handle_new_extended_mining_job( + /// Processes jobs and prevhash transitions from one FIFO so their upstream ordering cannot + /// be inverted by independently scheduled bridge tasks. + fn handle_upstream_work( self_: Arc>, - mut rx_sv2_new_ext_mining_job: tokio::sync::mpsc::Receiver>, + mut receiver: tokio::sync::mpsc::Receiver, ) -> Result, Error<'static>> { let tx_sv1_notify = self_ .safe_lock(|s| s.tx_sv1_notify.clone()) .map_err(|_| Error::BridgeMutexPoisoned)?; - debug!("Starting handle_new_extended_mining_job task"); + debug!("Starting ordered upstream work handler"); Ok(tokio::task::spawn(async move { - loop { - // Receive `NewExtendedMiningJob` from `Upstream` - let sv2_new_extended_mining_job: NewExtendedMiningJob = - match rx_sv2_new_ext_mining_job.recv().await { - Some(sv2_new_extended_mining_job) => sv2_new_extended_mining_job, - None => { - error!("Failed to receive NewExtendedMiningJob from upstream"); - ProxyState::update_translator_state(TranslatorState::Down); - break; + while let Some(work) = receiver.recv().await { + match work { + BridgeWork::SetNewPrevHash(set_new_prev_hash) => { + let mut dbg_prev_hash = set_new_prev_hash.prev_hash.to_vec(); + dbg_prev_hash.reverse(); + debug!( + "Received NewPrevHash {} for channel {} with job {}", + dbg_prev_hash.as_hex(), + set_new_prev_hash.channel_id, + set_new_prev_hash.job_id + ); + if let Err(e) = Self::handle_new_prev_hash_( + self_.clone(), + set_new_prev_hash, + tx_sv1_notify.clone(), + ) + .await + { + error!("Failed to handle SetNewPrevHash: {e}"); + ProxyState::update_upstream_state(UpstreamType::TranslatorUpstream); + return; } - }; - if let Err(e) = Self::handle_new_extended_mining_job_( - self_.clone(), - sv2_new_extended_mining_job, - tx_sv1_notify.clone(), - ) - .await - { - error!("Failed to handle NewExtendedMiningJob {e}",); - ProxyState::update_translator_state(TranslatorState::Down); - }; - super::super::upstream::upstream::IS_NEW_JOB_HANDLED - .store(true, std::sync::atomic::Ordering::SeqCst); + } + BridgeWork::NewExtendedMiningJob { + job: new_job, + merge_mining_binding_id, + } => { + if let Err(e) = Self::handle_new_extended_mining_job_( + self_.clone(), + new_job, + tx_sv1_notify.clone(), + merge_mining_binding_id, + ) + .await + { + error!("Failed to handle NewExtendedMiningJob {e}",); + ProxyState::update_translator_state(TranslatorState::Down); + return; + }; + } + } } + error!("Failed to receive ordered upstream work"); + ProxyState::update_translator_state(TranslatorState::Down); })) } } #[derive(Debug)] pub struct OpenSv1Downstream { pub channel_id: u32, - pub last_notify: Option>, + pub last_notify: Option, pub extranonce: Vec, pub extranonce2_len: u16, } @@ -822,7 +808,7 @@ mod test { } } - fn seed_bridge_job(bridge: &mut Bridge) { + fn test_extended_job(job_id: u32) -> NewExtendedMiningJob<'static> { let out_id = bitcoin::hashes::sha256d::Hash::from_slice(&[0_u8; 32]).unwrap(); let previous_output = bitcoin::OutPoint { txid: bitcoin::Txid::from_raw_hash(out_id), @@ -841,6 +827,19 @@ mod test { output: vec![], }; let tx = bitcoin::consensus::serialize(&tx); + NewExtendedMiningJob { + channel_id: 1, + job_id, + min_ntime: binary_sv2::Sv2Option::new(Some(TEST_NTIME)), + version: 0, + version_rolling_allowed: false, + merkle_path: vec![].into(), + coinbase_tx_prefix: tx[0..42].to_vec().try_into().unwrap(), + coinbase_tx_suffix: tx[58..].to_vec().try_into().unwrap(), + } + } + + fn seed_bridge_job(bridge: &mut Bridge) { let prev_hash = SetNewPrevHash { channel_id: 1, job_id: TEST_JOB_ID, @@ -849,20 +848,51 @@ mod test { nbits: 0x1d00ffff, }; bridge.channel_factory.on_new_prev_hash(prev_hash).unwrap(); - let new_mining_job = NewExtendedMiningJob { + bridge + .channel_factory + .on_new_extended_mining_job(test_extended_job(TEST_JOB_ID)) + .unwrap(); + } + + #[tokio::test] + async fn bound_job_without_notify_receivers_does_not_fail_the_bridge() { + let extranonces = ExtendedExtranonce::new(0..6, 6..8, 8..16); + let bridge = test_utils::create_bridge(extranonces).expect("bridge"); + let prev_hash = SetNewPrevHash { channel_id: 1, job_id: TEST_JOB_ID, - min_ntime: binary_sv2::Sv2Option::new(Some(TEST_NTIME)), - version: 0, - version_rolling_allowed: false, - merkle_path: vec![].into(), - coinbase_tx_prefix: tx[0..42].to_vec().try_into().unwrap(), - coinbase_tx_suffix: tx[58..].to_vec().try_into().unwrap(), + prev_hash: [3; 32].into(), + min_ntime: TEST_NTIME, + nbits: 0x1d00ffff, }; bridge - .channel_factory - .on_new_extended_mining_job(new_mining_job) - .unwrap(); + .safe_lock(|bridge| { + bridge.last_p_hash = Some(prev_hash.clone()); + bridge.channel_factory.on_new_prev_hash(prev_hash) + }) + .expect("bridge lock") + .expect("prevhash"); + let (notify_tx, notify_rx) = broadcast::channel(1); + drop(notify_rx); + + Bridge::handle_new_extended_mining_job_( + bridge.clone(), + test_extended_job(TEST_JOB_ID), + notify_tx, + Some(42), + ) + .await + .expect("a job with MM metadata must follow the normal bridge path"); + + let binding_id = bridge + .safe_lock(|bridge| { + bridge + .last_notify + .as_ref() + .and_then(|notify| notify.merge_mining_binding_id) + }) + .expect("bridge lock"); + assert_eq!(binding_id, Some(42)); } fn classify_submit( diff --git a/src/translator/proxy/task_manager.rs b/src/translator/proxy/task_manager.rs index 8f0a58c3..0cc5d487 100644 --- a/src/translator/proxy/task_manager.rs +++ b/src/translator/proxy/task_manager.rs @@ -8,9 +8,8 @@ use tracing::warn; #[derive(Debug)] #[allow(dead_code)] enum Task { - NewExtendedMiningJob(AbortOnDrop), + UpstreamWork(AbortOnDrop), DownstreamMessages(AbortOnDrop), - NewPrevHash(AbortOnDrop), } pub struct TaskManager { @@ -41,23 +40,13 @@ impl TaskManager { self.abort.take() } - pub async fn add_handle_new_extended_mining_job( + pub async fn add_handle_upstream_work( self_: Arc>, abortable: AbortOnDrop, ) -> Result<(), ()> { let send_task = self_.safe_lock(|s| s.send_task.clone()).unwrap(); send_task - .send(Task::NewExtendedMiningJob(abortable)) - .await - .map_err(|_| ()) - } - pub async fn add_handle_new_prev_hash( - self_: Arc>, - abortable: AbortOnDrop, - ) -> Result<(), ()> { - let send_task = self_.safe_lock(|s| s.send_task.clone()).unwrap(); - send_task - .send(Task::NewPrevHash(abortable)) + .send(Task::UpstreamWork(abortable)) .await .map_err(|_| ()) } diff --git a/src/translator/upstream/upstream.rs b/src/translator/upstream/upstream.rs index c8e454e0..563341d9 100644 --- a/src/translator/upstream/upstream.rs +++ b/src/translator/upstream/upstream.rs @@ -12,8 +12,8 @@ use roles_logic_sv2::{ mining::{ParseUpstreamMiningMessages, SendTo}, }, mining_sv2::{ - ExtendedExtranonce, Extranonce, NewExtendedMiningJob, OpenExtendedMiningChannel, - SetCustomMiningJob, SetNewPrevHash, SubmitSharesExtended, + ExtendedExtranonce, Extranonce, OpenExtendedMiningChannel, SetCustomMiningJob, + SubmitSharesExtended, }, parsers::Mining, routing_logic::{MiningRoutingLogic, NoRouting}, @@ -21,10 +21,7 @@ use roles_logic_sv2::{ utils::Mutex, Error as RolesLogicError, }; -use std::{ - collections::BTreeMap, - sync::{atomic::AtomicBool, Arc}, -}; +use std::{collections::BTreeMap, sync::Arc}; use tokio::{ sync::{ mpsc::{Receiver as TReceiver, Sender as TSender}, @@ -38,11 +35,10 @@ use super::task_manager::TaskManager; use crate::{ proxy_state::{ProxyState, UpstreamType}, shared::utils::AbortOnDrop, - translator::utils::submit_error_to_rejection_reason, + translator::{utils::submit_error_to_rejection_reason, BridgeWork}, }; use bitcoin::BlockHash; -pub static IS_NEW_JOB_HANDLED: AtomicBool = AtomicBool::new(true); /// Represents the currently active `prevhash` of the mining job being worked on OR being submitted /// from the Downstream role. #[derive(Debug, Clone)] @@ -65,12 +61,9 @@ pub struct Upstream { last_job_id: Option, /// Bytes used as implicit first part of `extranonce`. extranonce_prefix: Option>, - /// Sends SV2 `SetNewPrevHash` messages to be translated (along with SV2 `NewExtendedMiningJob` - /// messages) into SV1 `mining.notify` messages. Received and translated by the `Bridge`. - tx_sv2_set_new_prev_hash: tokio::sync::mpsc::Sender>, - /// Sends SV2 `NewExtendedMiningJob` messages to be translated (along with SV2 `SetNewPrevHash` - /// messages) into SV1 `mining.notify` messages. Received and translated by the `Bridge`. - tx_sv2_new_ext_mining_job: tokio::sync::mpsc::Sender>, + /// Preserves the upstream order between jobs and prevhash transitions until the `Bridge` + /// translates them into SV1 `mining.notify` messages. + tx_bridge_work: tokio::sync::mpsc::Sender, /// Sends the extranonce1 and the channel id received in the SV2 `OpenExtendedMiningChannelSuccess` message to be /// used by the `Downstream` and sent to the Downstream role in a SV2 `mining.subscribe` /// response message. Passed to the `Downstream` on connection creation. @@ -111,8 +104,7 @@ impl Upstream { /// from the `Downstream`. #[allow(clippy::too_many_arguments)] pub async fn new( - tx_sv2_set_new_prev_hash: tokio::sync::mpsc::Sender>, - tx_sv2_new_ext_mining_job: tokio::sync::mpsc::Sender>, + tx_bridge_work: tokio::sync::mpsc::Sender, min_extranonce_size: u16, tx_sv2_extranonce: tokio::sync::mpsc::Sender<(ExtendedExtranonce, u32)>, target: Arc>>, @@ -121,8 +113,7 @@ impl Upstream { ) -> ProxyResult<'static, Arc>> { Ok(Arc::new(Mutex::new(Self { extranonce_prefix: None, - tx_sv2_set_new_prev_hash, - tx_sv2_new_ext_mining_job, + tx_bridge_work, channel_id: None, job_id: None, last_job_id: None, @@ -281,17 +272,15 @@ impl Upstream { diff_update_rx: watch::Receiver, ) -> ProxyResult<'static, (AbortOnDrop, AbortOnDrop)> { let clone = self_.clone(); - let (tx_frame, tx_sv2_extranonce, tx_sv2_new_ext_mining_job, tx_sv2_set_new_prev_hash) = - clone - .safe_lock(|s| { - ( - s.sender.clone(), - s.tx_sv2_extranonce.clone(), - s.tx_sv2_new_ext_mining_job.clone(), - s.tx_sv2_set_new_prev_hash.clone(), - ) - }) - .map_err(|_| Error::TranslatorUpstreamMutexPoisoned)?; + let (tx_frame, tx_sv2_extranonce, tx_bridge_work) = clone + .safe_lock(|s| { + ( + s.sender.clone(), + s.tx_sv2_extranonce.clone(), + s.tx_bridge_work.clone(), + ) + }) + .map_err(|_| Error::TranslatorUpstreamMutexPoisoned)?; let diff_manager_handle = { let self_ = self_.clone(); task::spawn(async move { Self::run_diff_management(self_, diff_update_rx).await }) @@ -378,17 +367,42 @@ impl Upstream { } Mining::NewExtendedMiningJob(m) => { info!("Parsing incoming NewExtendedMiningJob message from Pool for Channel Id: {}", m.channel_id); + // Claim feature metadata at the same point as the job itself. + // The legacy translator coalesces future jobs, so deferring this + // claim until Bridge delivery would leave bindings for dropped + // futures at the head of the queue. + let merge_mining_binding_id = + crate::merge_mining::claim_job_binding_pending(m.job_id); if m.is_future() { - future_j = Some(m) + if let Some((_, replaced_binding_id)) = + future_j.replace((m, merge_mining_binding_id)) + { + crate::merge_mining::clear_pending_job_binding( + replaced_binding_id, + ); + } } else { let job_id = m.job_id; if let Err(e) = self_.safe_lock(|s| { let _ = s.job_id.insert(job_id); }) { + crate::merge_mining::clear_pending_job_binding( + merge_mining_binding_id, + ); error!("Translator upstream mutex poisoned: {e}"); return; }; - if tx_sv2_new_ext_mining_job.send(m).await.is_err() { + if tx_bridge_work + .send(BridgeWork::NewExtendedMiningJob { + job: m, + merge_mining_binding_id, + }) + .await + .is_err() + { + crate::merge_mining::clear_pending_job_binding( + merge_mining_binding_id, + ); error!("Failed to send NewExtendedMiningJob"); return; }; @@ -396,24 +410,48 @@ impl Upstream { } Mining::SetNewPrevHash(m) => { info!("Parsing incoming SetNewPrevHash message from Pool for Channel Id: {}", m.channel_id); - if let Some(j) = future_j.clone() { + if let Some((j, merge_mining_binding_id)) = future_j.clone() { future_j = None; let job_id = m.job_id; if let Err(e) = self_.safe_lock(|s| { let _ = s.job_id.insert(job_id); }) { + crate::merge_mining::clear_pending_job_binding( + merge_mining_binding_id, + ); error!("Translator upstream mutex poisoned: {e}"); return; }; - if tx_sv2_new_ext_mining_job.send(j).await.is_err() { + if tx_bridge_work + .send(BridgeWork::NewExtendedMiningJob { + job: j, + merge_mining_binding_id, + }) + .await + .is_err() + { + crate::merge_mining::clear_pending_job_binding( + merge_mining_binding_id, + ); error!("Failed to send NewExtendedMiningJob"); return; }; - if tx_sv2_set_new_prev_hash.send(m).await.is_err() { + if tx_bridge_work + .send(BridgeWork::SetNewPrevHash(m)) + .await + .is_err() + { + crate::merge_mining::clear_pending_job_binding( + merge_mining_binding_id, + ); error!("Failed to send SetNewPrevHash"); return; }; - } else if tx_sv2_set_new_prev_hash.send(m).await.is_err() { + } else if tx_bridge_work + .send(BridgeWork::SetNewPrevHash(m)) + .await + .is_err() + { error!("Failed to send SetNewPrevHash"); return; } @@ -829,7 +867,6 @@ impl ParseUpstreamMiningMessages roles_logic_sv2::mining_sv2::NewExtendedMiningJob<'static> { + roles_logic_sv2::mining_sv2::NewExtendedMiningJob { + channel_id: 1, + job_id, + min_ntime: binary_sv2::Sv2Option::new(if future { None } else { Some(1_700_000_000) }), + version: 0, + version_rolling_allowed: false, + merkle_path: vec![].into(), + coinbase_tx_prefix: Vec::new().try_into().expect("empty prefix"), + coinbase_tx_suffix: Vec::new().try_into().expect("empty suffix"), + } + } + + fn bridge_prev_hash(job_id: u32) -> roles_logic_sv2::mining_sv2::SetNewPrevHash<'static> { + roles_logic_sv2::mining_sv2::SetNewPrevHash { + channel_id: 1, + job_id, + prev_hash: [3; 32].into(), + min_ntime: 1_700_000_000, + nbits: 0x1d00ffff, + } + } + async fn test_upstream() -> Arc> { - let (tx_sv2_set_new_prev_hash, _rx_sv2_set_new_prev_hash) = mpsc::channel(1); - let (tx_sv2_new_ext_mining_job, _rx_sv2_new_ext_mining_job) = mpsc::channel(1); + let (tx_bridge_work, _rx_bridge_work) = mpsc::channel(1); let (tx_sv2_extranonce, _rx_sv2_extranonce) = mpsc::channel(1); let (sender, _receiver) = mpsc::channel(1); Upstream::new( - tx_sv2_set_new_prev_hash, - tx_sv2_new_ext_mining_job, + tx_bridge_work, crate::MIN_EXTRANONCE_SIZE - 1, tx_sv2_extranonce, Arc::new(Mutex::new(vec![0; 32])), @@ -954,6 +1015,75 @@ mod tests { .unwrap() } + #[tokio::test] + async fn bridge_work_preserves_cross_type_source_order() { + let (tx_bridge_work, mut rx_bridge_work) = mpsc::channel(4); + let (tx_sv2_extranonce, _rx_sv2_extranonce) = mpsc::channel(1); + let (sender, _receiver) = mpsc::channel(1); + let (difficulty_config, diff_update_rx) = + UpstreamDifficultyConfig::new(crate::CHANNEL_DIFF_UPDTATE_INTERVAL, 0.0); + let upstream = Upstream::new( + tx_bridge_work, + crate::MIN_EXTRANONCE_SIZE - 1, + tx_sv2_extranonce, + Arc::new(Mutex::new(vec![0; 32])), + Arc::new(Mutex::new(difficulty_config)), + sender, + ) + .await + .expect("upstream"); + let (incoming_tx, incoming_rx) = mpsc::channel(4); + let (diff_manager, main_loop) = + Upstream::parse_incoming(upstream, incoming_rx, diff_update_rx).expect("parser"); + + incoming_tx + .send(Mining::SetNewPrevHash(bridge_prev_hash(10))) + .await + .expect("prevhash"); + incoming_tx + .send(Mining::NewExtendedMiningJob(bridge_job(10, false))) + .await + .expect("nonfuture job"); + + assert!(matches!( + timeout(Duration::from_secs(1), rx_bridge_work.recv()) + .await + .expect("work timeout"), + Some(BridgeWork::SetNewPrevHash(message)) if message.job_id == 10 + )); + assert!(matches!( + timeout(Duration::from_secs(1), rx_bridge_work.recv()) + .await + .expect("work timeout"), + Some(BridgeWork::NewExtendedMiningJob { job, .. }) if job.job_id == 10 + )); + + incoming_tx + .send(Mining::NewExtendedMiningJob(bridge_job(11, true))) + .await + .expect("future job"); + incoming_tx + .send(Mining::SetNewPrevHash(bridge_prev_hash(11))) + .await + .expect("prevhash"); + + assert!(matches!( + timeout(Duration::from_secs(1), rx_bridge_work.recv()) + .await + .expect("work timeout"), + Some(BridgeWork::NewExtendedMiningJob { job, .. }) if job.job_id == 11 + )); + assert!(matches!( + timeout(Duration::from_secs(1), rx_bridge_work.recv()) + .await + .expect("work timeout"), + Some(BridgeWork::SetNewPrevHash(message)) if message.job_id == 11 + )); + + drop(diff_manager); + drop(main_loop); + } + #[tokio::test] async fn submit_success_resolves_pending_submit() { let upstream = test_upstream().await; @@ -1056,16 +1186,14 @@ mod tests { #[tokio::test] async fn immediate_signal_emits_update_channel_before_periodic_interval() { - let (tx_sv2_set_new_prev_hash, _rx_sv2_set_new_prev_hash) = mpsc::channel(1); - let (tx_sv2_new_ext_mining_job, _rx_sv2_new_ext_mining_job) = mpsc::channel(1); + let (tx_bridge_work, _rx_bridge_work) = mpsc::channel(1); let (tx_sv2_extranonce, _rx_sv2_extranonce) = mpsc::channel(1); let (sender, mut receiver) = mpsc::channel(4); let (difficulty_config, diff_update_rx) = UpstreamDifficultyConfig::new(crate::CHANNEL_DIFF_UPDTATE_INTERVAL, 0.0); let difficulty_config = Arc::new(Mutex::new(difficulty_config)); let upstream = Upstream::new( - tx_sv2_set_new_prev_hash, - tx_sv2_new_ext_mining_job, + tx_bridge_work, crate::MIN_EXTRANONCE_SIZE - 1, tx_sv2_extranonce, Arc::new(Mutex::new(vec![0; 32])), diff --git a/src/translator/utils.rs b/src/translator/utils.rs index 7f8cbf01..be066e77 100644 --- a/src/translator/utils.rs +++ b/src/translator/utils.rs @@ -176,7 +176,7 @@ pub fn validate_share( request: &client_to_server::Submit<'static>, job: &Notify<'static>, difficulty: f32, - extranonce1: Vec, + extranonce1: &[u8], version_rolling_mask: Option, ) -> bool { if share_log_enabled() { @@ -200,20 +200,15 @@ pub fn validate_share( } let mut extranonce = Vec::new(); - extranonce.extend_from_slice(extranonce1.as_ref()); + extranonce.extend_from_slice(extranonce1); extranonce.extend_from_slice(request.extra_nonce2.0.as_ref()); let extranonce: &[u8] = extranonce.as_ref(); - let job_version = job.version.0; - let request_version = request - .version_bits - .clone() - .map(|vb| vb.0) - .unwrap_or(job_version); - let mask = version_rolling_mask - .unwrap_or(sv1_api::utils::HexU32Be(0x1FFFE000_u32)) - .0; - let version = (job_version & !mask) | (request_version & mask); + let version = effective_version( + job.version.0, + request.version_bits.as_ref(), + version_rolling_mask.as_ref(), + ); let mut hash = get_hash( request.nonce.0, @@ -246,6 +241,16 @@ pub fn validate_share( false } +pub(crate) fn effective_version( + job_version: u32, + request_version: Option<&sv1_api::utils::HexU32Be>, + version_rolling_mask: Option<&sv1_api::utils::HexU32Be>, +) -> u32 { + let request_version = request_version.map_or(job_version, |version| version.0); + let mask = version_rolling_mask.map_or(0x1fff_e000_u32, |mask| mask.0); + (job_version & !mask) | (request_version & mask) +} + pub fn submit_error_to_rejection_reason(error_code: &str) -> RejectionReason { match error_code { code if code