diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-02 05:44:41 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-02 05:44:41 +0000 |
| commit | a3666bded1553b101b631d87e99961bf3ccd0a3e (patch) | |
| tree | 7b351f42899bb5f38d6b1efdaf54cf04b8456d69 /src | |
| parent | d7b1e8a1d54192df2fcf633fbcdffb43389ff8e2 (diff) | |
pool: refuse a (job, nonce) pair this connection already submitted
mining.submit validated the nonce, rebuilt the block, re-hashed it with
RandomX -- and never asked whether it had seen this exact (job_id,
nonce) pair before. Nothing in that pipeline distinguishes a replay
from a first submission: a duplicated valid share passes every check
and lands in roundShareCount[address]++ a second time, and
roundShareCount is the divisor the payout split uses, so replaying one
valid share N times multiplies that address's cut of the round by N.
Keep a per-connection set of credited nonces on the Miner, reject a
repeat with a "Duplicate share" stratum error before the rebuild and
hash it would waste, and record the nonce at the moment the share is
credited. The set is keyed to the job id the miner is currently working
and cleared inside SendJob -- the single funnel every new-job path
(initial authorize, block found, new block, 60s refresh, no-job
watchdog, vardiff retargets) goes through -- so legitimate
resubmissions across jobs are never refused. The clear is keyed on the
id rather than unconditional because the vardiff retarget and stall
watchdog re-send the CURRENT job, and an unconditional clear there
would reopen the very replay window this closes. Job ids come from a
monotonic atomic counter in RebuildJob, so an id is never recycled
within a process.
Closes #8
Diffstat (limited to 'src')
| -rw-r--r-- | src/rpc.cpp | 38 |
1 files changed, 38 insertions, 0 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp index 82f895b..f1a7153 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -2511,6 +2511,16 @@ struct Miner { | |||
| 2511 | int64 lastShareTime = 0; // GetTime() of the previous accepted share | 2511 | int64 lastShareTime = 0; // GetTime() of the previous accepted share |
| 2512 | double hashRate = 0.0; // smoothed H/s estimate | 2512 | double hashRate = 0.0; // smoothed H/s estimate |
| 2513 | 2513 | ||
| 2514 | // Replay guard for mining.submit (#8): nonces already credited on this | ||
| 2515 | // connection for the job it is currently working. Keyed to the job id in | ||
| 2516 | // seenNoncesJob and cleared by SendJob whenever that id changes, so a | ||
| 2517 | // resubmission under a NEW job is never turned away, while a replayed | ||
| 2518 | // (job_id, nonce) pair under the same job is refused before it can be | ||
| 2519 | // credited twice into roundShareCount -- the figure the payout split | ||
| 2520 | // divides by. | ||
| 2521 | std::set<uint32_t> seenNonces; | ||
| 2522 | std::string seenNoncesJob; | ||
| 2523 | |||
| 2514 | explicit Miner(btf_socket_t f) | 2524 | explicit Miner(btf_socket_t f) |
| 2515 | : fd(f), connectTime(GetTime()), | 2525 | : fd(f), connectTime(GetTime()), |
| 2516 | extranonce2(gNextExtranonce2.fetch_add(1)) {} | 2526 | extranonce2(gNextExtranonce2.fetch_add(1)) {} |
| @@ -2693,6 +2703,17 @@ static json MakeNotifyParams(const StratumJob& job, const CBlock& b, | |||
| 2693 | static void SendJob(Miner* m, bool clean) | 2703 | static void SendJob(Miner* m, bool clean) |
| 2694 | { | 2704 | { |
| 2695 | if (!gHaveJob) return; | 2705 | if (!gHaveJob) return; |
| 2706 | // Every path that hands a miner a new job funnels through here, so this | ||
| 2707 | // is the one place the replay guard needs to follow the job id: nonces | ||
| 2708 | // credited under the previous job mean nothing under the new one. Keying | ||
| 2709 | // the clear to the id (rather than clearing unconditionally) keeps a | ||
| 2710 | // same-job re-notify -- the vardiff retarget and stall watchdog both | ||
| 2711 | // re-send the CURRENT job -- from reopening the duplicate window it | ||
| 2712 | // exists to close. | ||
| 2713 | if (m->seenNoncesJob != gCurrentJob.jobId) { | ||
| 2714 | m->seenNonces.clear(); | ||
| 2715 | m->seenNoncesJob = gCurrentJob.jobId; | ||
| 2716 | } | ||
| 2696 | CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2); | 2717 | CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2); |
| 2697 | // Use this miner's own shareTarget (set by vardiff) in the notify params. | 2718 | // Use this miner's own shareTarget (set by vardiff) in the notify params. |
| 2698 | // The participant miner reads p[3] as its share target, and HandleLine | 2719 | // The participant miner reads p[3] as its share target, and HandleLine |
| @@ -2951,6 +2972,22 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 2951 | } | 2972 | } |
| 2952 | unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); | 2973 | unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); |
| 2953 | 2974 | ||
| 2975 | // Reject a replayed (job, nonce) before it costs a rebuild and a | ||
| 2976 | // RandomX hash: nothing about the work below distinguishes a second | ||
| 2977 | // submission of a valid share from the first, so without this check | ||
| 2978 | // it passes every test and is credited again -- and roundShareCount | ||
| 2979 | // is what the payout split divides by, so each replay multiplies the | ||
| 2980 | // address's cut of the round. The set is per-connection and cleared | ||
| 2981 | // by SendJob on every job change, so only a same-job repeat is | ||
| 2982 | // refused. | ||
| 2983 | if (m->seenNonces.count(nNonce)) { | ||
| 2984 | LogPrint("worker", "[worker->pool] duplicate share from %s" | ||
| 2985 | " nonce=%u job=%s -- already credited, not counting again\n", | ||
| 2986 | m->address.c_str(), nNonce, submitJobId.c_str()); | ||
| 2987 | reply(false, "Duplicate share"); | ||
| 2988 | return true; | ||
| 2989 | } | ||
| 2990 | |||
| 2954 | // Rebuild this miner's exact block (unique merkle via extranonce2) | 2991 | // Rebuild this miner's exact block (unique merkle via extranonce2) |
| 2955 | CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2); | 2992 | CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2); |
| 2956 | b.nNonce = nNonce; | 2993 | b.nNonce = nNonce; |
| @@ -2978,6 +3015,7 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 2978 | m->roundShares++; | 3015 | m->roundShares++; |
| 2979 | m->totalShares++; | 3016 | m->totalShares++; |
| 2980 | m->vardiffShares++; | 3017 | m->vardiffShares++; |
| 3018 | m->seenNonces.insert(nNonce); // replay guard: credit each nonce once per job | ||
| 2981 | int64 shareTime = GetTime(); | 3019 | int64 shareTime = GetTime(); |
| 2982 | m->lastSeen = shareTime; | 3020 | m->lastSeen = shareTime; |
| 2983 | roundShareCount[m->address]++; | 3021 | roundShareCount[m->address]++; |