mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789b529bd5 | ||
|
|
5889e3af5b | ||
|
|
f10cef61ce | ||
|
|
07a2b173d0 |
@@ -72,6 +72,7 @@ STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
|
|||||||
# Disconnect slow/non-reading SV1 clients before their per-socket write queue
|
# Disconnect slow/non-reading SV1 clients before their per-socket write queue
|
||||||
# can grow without bound. Includes bytes already buffered by Node.js.
|
# can grow without bound. Includes bytes already buffered by Node.js.
|
||||||
STRATUM_MAX_SOCKET_BUFFER_BYTES=262144
|
STRATUM_MAX_SOCKET_BUFFER_BYTES=262144
|
||||||
|
STRATUM_MAX_INBOUND_LINE_BYTES=65536
|
||||||
# Immediately publish a consensus-valid, subsidy-only solo job before the full
|
# Immediately publish a consensus-valid, subsidy-only solo job before the full
|
||||||
# transaction template. SV2 uses the same activation to select its pre-staged
|
# transaction template. SV2 uses the same activation to select its pre-staged
|
||||||
# future job; the full job then follows on the active tip.
|
# future job; the full job then follows on the active tip.
|
||||||
|
|||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
lineage=${RENEWED_LINEAGE:-/etc/letsencrypt/live/public-pool.io}
|
||||||
|
secrets_dir=${PUBLIC_POOL_SECRETS_DIR:-/home/ben/public-pool-timescaledb-test/secrets}
|
||||||
|
container=${PUBLIC_POOL_CONTAINER:-public-pool}
|
||||||
|
owner=${PUBLIC_POOL_CERT_OWNER:-ben}
|
||||||
|
group=${PUBLIC_POOL_CERT_GROUP:-ben}
|
||||||
|
|
||||||
|
cert_source="$lineage/fullchain.pem"
|
||||||
|
key_source="$lineage/privkey.pem"
|
||||||
|
|
||||||
|
test -r "$cert_source"
|
||||||
|
test -r "$key_source"
|
||||||
|
openssl x509 -in "$cert_source" -noout >/dev/null
|
||||||
|
openssl pkey -in "$key_source" -noout >/dev/null
|
||||||
|
|
||||||
|
cert_temp=$(mktemp "$secrets_dir/.cert.pem.XXXXXX")
|
||||||
|
key_temp=$(mktemp "$secrets_dir/.key.pem.XXXXXX")
|
||||||
|
trap 'rm -f "$cert_temp" "$key_temp"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
cat "$cert_source" > "$cert_temp"
|
||||||
|
cat "$key_source" > "$key_temp"
|
||||||
|
chown "$owner:$group" "$cert_temp" "$key_temp"
|
||||||
|
chmod 0644 "$cert_temp"
|
||||||
|
chmod 0600 "$key_temp"
|
||||||
|
mv -f "$cert_temp" "$secrets_dir/cert.pem"
|
||||||
|
mv -f "$key_temp" "$secrets_dir/key.pem"
|
||||||
|
|
||||||
|
# HTTPS can reload its secure context, but the Stratum TLS listeners currently
|
||||||
|
# read their certificate only when they start.
|
||||||
|
docker restart --time 30 "$container" >/dev/null
|
||||||
@@ -169,6 +169,59 @@ describe('StratumV1Client', () => {
|
|||||||
expect(socket.on).toHaveBeenCalled();
|
expect(socket.on).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disconnects when an unterminated inbound message exceeds the configured limit', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||||
|
if (key === 'STRATUM_MAX_INBOUND_LINE_BYTES') return '16';
|
||||||
|
if (key === 'NETWORK') return 'testnet';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
client = new StratumV1Client(
|
||||||
|
socket,
|
||||||
|
stratumV1JobsService,
|
||||||
|
bitcoinRpcService,
|
||||||
|
clientService,
|
||||||
|
notificationService,
|
||||||
|
blocksService,
|
||||||
|
configService,
|
||||||
|
addressSettings,
|
||||||
|
shareAccountingService as any,
|
||||||
|
redisMessagingService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
socketEmitter(Buffer.from('x'.repeat(17)));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(socket.destroy).toHaveBeenCalled();
|
||||||
|
expect((client as any).buffer).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts multiple complete messages when each line is within the inbound limit', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||||
|
if (key === 'STRATUM_MAX_INBOUND_LINE_BYTES') return '64';
|
||||||
|
if (key === 'NETWORK') return 'testnet';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
client = new StratumV1Client(
|
||||||
|
socket,
|
||||||
|
stratumV1JobsService,
|
||||||
|
bitcoinRpcService,
|
||||||
|
clientService,
|
||||||
|
notificationService,
|
||||||
|
blocksService,
|
||||||
|
configService,
|
||||||
|
addressSettings,
|
||||||
|
shareAccountingService as any,
|
||||||
|
redisMessagingService as any,
|
||||||
|
);
|
||||||
|
jest.spyOn(client as any, 'handleMessage').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
socketEmitter(Buffer.from('{"id":1}\n{"id":2}\n'));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect((client as any).handleMessage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(socket.destroy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should clean up socket state only once when destroyed repeatedly', async () => {
|
it('should clean up socket state only once when destroyed repeatedly', async () => {
|
||||||
const timer = setInterval(() => undefined, 1000);
|
const timer = setInterval(() => undefined, 1000);
|
||||||
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
|
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
|
||||||
@@ -409,24 +462,47 @@ describe('StratumV1Client', () => {
|
|||||||
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
|
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clamp suggested difficulty to the configured minimum', async () => {
|
it('should reject suggested difficulty below the protocol minimum', async () => {
|
||||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'STRATUM_MIN_DIFFICULTY':
|
|
||||||
return '1';
|
|
||||||
case 'DEV_FEE_ADDRESS':
|
|
||||||
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
|
||||||
case 'NETWORK':
|
|
||||||
return 'testnet';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||||
|
|
||||||
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
|
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
|
||||||
await new Promise((r) => setTimeout(r, 1));
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[1]}\n`, expect.any(Function));
|
expect(socket.write).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Suggest difficulty validation error'),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect((client as any).sessionDifficulty).toBe(100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([12884901888, 1e303])(
|
||||||
|
'should reject excessive suggested difficulty %s',
|
||||||
|
async (suggestedDifficulty) => {
|
||||||
|
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||||
|
|
||||||
|
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[${suggestedDifficulty}]}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
|
expect(socket.write).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Suggest difficulty validation error'),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect((client as any).sessionDifficulty).toBe(100000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should reject excessive password-provided starting difficulty', async () => {
|
||||||
|
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||||
|
|
||||||
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
|
emitMessage(`{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker","d=12884901888"]}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
|
expect(socket.write).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Authorization validation error'),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect((client as any).sessionDifficulty).toBe(100000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set difficulty', async () => {
|
it('should set difficulty', async () => {
|
||||||
@@ -482,7 +558,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||||
@@ -510,7 +586,7 @@ describe('StratumV1Client', () => {
|
|||||||
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
|
|
||||||
|
|
||||||
@@ -536,7 +612,7 @@ describe('StratumV1Client', () => {
|
|||||||
sessionId: MockRecording1.EXTRA_NONCE,
|
sessionId: MockRecording1.EXTRA_NONCE,
|
||||||
jobId: '1',
|
jobId: '1',
|
||||||
jobTemplateId: '1',
|
jobTemplateId: '1',
|
||||||
creditedDifficulty: 0,
|
creditedDifficulty: 1e-9,
|
||||||
isBlockCandidate: false,
|
isBlockCandidate: false,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -547,7 +623,7 @@ describe('StratumV1Client', () => {
|
|||||||
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
|
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -563,7 +639,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -597,7 +673,7 @@ describe('StratumV1Client', () => {
|
|||||||
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
|
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -671,7 +747,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -693,7 +769,7 @@ describe('StratumV1Client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -712,7 +788,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -737,7 +813,7 @@ describe('StratumV1Client', () => {
|
|||||||
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
|
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -775,7 +851,7 @@ describe('StratumV1Client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -800,11 +876,12 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
|
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
|
||||||
|
expect((client as any).rememberSubmission('old-tip-share', '1')).toBe(true);
|
||||||
const nextTip = {
|
const nextTip = {
|
||||||
...MockRecording1.BLOCK_TEMPLATE,
|
...MockRecording1.BLOCK_TEMPLATE,
|
||||||
previousblockhash: '11'.repeat(32),
|
previousblockhash: '11'.repeat(32),
|
||||||
@@ -817,7 +894,9 @@ describe('StratumV1Client', () => {
|
|||||||
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true);
|
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bound duplicate tracking and expire entries by TTL', () => {
|
it('should preserve live accepted-share dedup entries when capacity is reached', () => {
|
||||||
|
const getSubmissionContext = jest.spyOn(stratumV1JobsService, 'getSubmissionContext')
|
||||||
|
.mockReturnValue({} as any);
|
||||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||||
if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000';
|
if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000';
|
||||||
if (key === 'STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES') return '2';
|
if (key === 'STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES') return '2';
|
||||||
@@ -826,13 +905,18 @@ describe('StratumV1Client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect((client as any).isDuplicateSubmission('one')).toBe(false);
|
expect((client as any).isDuplicateSubmission('one')).toBe(false);
|
||||||
|
expect((client as any).rememberSubmission('one', '1')).toBe(true);
|
||||||
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
||||||
expect((client as any).isDuplicateSubmission('three')).toBe(false);
|
expect((client as any).rememberSubmission('two', '1')).toBe(true);
|
||||||
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two', 'three']);
|
expect((client as any).rememberSubmission('three', '1')).toBe(false);
|
||||||
|
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['one', 'two']);
|
||||||
|
|
||||||
jest.advanceTimersByTime(1001);
|
jest.advanceTimersByTime(1001);
|
||||||
|
expect((client as any).isDuplicateSubmission('two')).toBe(true);
|
||||||
|
getSubmissionContext.mockReturnValue(null);
|
||||||
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
||||||
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two']);
|
expect((client as any).rememberSubmission('three', '1')).toBe(true);
|
||||||
|
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['three']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hash and reject stale non-block shares without accounting them', async () => {
|
it('should hash and reject stale non-block shares without accounting them', async () => {
|
||||||
@@ -846,7 +930,7 @@ describe('StratumV1Client', () => {
|
|||||||
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -876,7 +960,7 @@ describe('StratumV1Client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -913,7 +997,7 @@ describe('StratumV1Client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -939,7 +1023,7 @@ describe('StratumV1Client', () => {
|
|||||||
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
|
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -956,7 +1040,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
stratumV1JobsService.blocks = {};
|
stratumV1JobsService.blocks = {};
|
||||||
@@ -981,13 +1065,39 @@ describe('StratumV1Client', () => {
|
|||||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
|
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
|
||||||
expect(await clientService.connectedClientCount()).toBe(1);
|
expect(await clientService.connectedClientCount()).toBe(1);
|
||||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||||
|
expect((client as any).miningSubmissionHashes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: 'before the advertised job time', offsetSeconds: -1 },
|
||||||
|
{ label: 'more than two hours in the future', offsetSeconds: (2 * 60 * 60) + 1 },
|
||||||
|
])('rejects ntime $label before hashing or accounting', async ({ offsetSeconds }) => {
|
||||||
|
jest.spyOn(client as any, 'write').mockResolvedValue(true);
|
||||||
|
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
|
||||||
|
|
||||||
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||||
|
const baseTime = parseInt(MockRecording1.TIME, 16);
|
||||||
|
submission.params[3] = (baseTime + offsetSeconds).toString(16).padStart(8, '0');
|
||||||
|
emitMessage(JSON.stringify(submission));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect((client as any).write).lastCalledWith(
|
||||||
|
`{"id":5,"result":null,"error":[20,"Invalid ntime",""]}\n`,
|
||||||
|
);
|
||||||
|
expect(calculateDifficultySpy).not.toHaveBeenCalled();
|
||||||
|
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects version rolling masks outside the negotiated BIP320 range', async () => {
|
it('rejects version rolling masks outside the negotiated BIP320 range', async () => {
|
||||||
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
@@ -1114,7 +1224,7 @@ describe('StratumV1Client', () => {
|
|||||||
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
|
|||||||
const DEFAULT_SUBMISSION_DEDUP_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_SUBMISSION_DEDUP_TTL_MS = 5 * 60 * 1000;
|
||||||
const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000;
|
const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000;
|
||||||
const DEFAULT_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
|
const DEFAULT_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
|
||||||
|
const DEFAULT_MAX_INBOUND_LINE_BYTES = 64 * 1024;
|
||||||
|
const MAX_NTIME_FUTURE_SECONDS = 2 * 60 * 60;
|
||||||
const VERSION_ROLLING_MASK = 0x1fffe000;
|
const VERSION_ROLLING_MASK = 0x1fffe000;
|
||||||
|
|
||||||
export interface MiningJobBroadcastResult {
|
export interface MiningJobBroadcastResult {
|
||||||
@@ -55,6 +57,11 @@ export interface MiningJobBroadcastResult {
|
|||||||
preStaged?: boolean;
|
preStaged?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubmissionDedupEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class StratumV1Client {
|
export class StratumV1Client {
|
||||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||||
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
||||||
@@ -90,8 +97,9 @@ export class StratumV1Client {
|
|||||||
private lastHashRatePersistedAt = 0;
|
private lastHashRatePersistedAt = 0;
|
||||||
private readonly network: bitcoinjs.Network;
|
private readonly network: bitcoinjs.Network;
|
||||||
private readonly maxSocketBufferBytes: number;
|
private readonly maxSocketBufferBytes: number;
|
||||||
|
private readonly maxInboundLineBytes: number;
|
||||||
|
|
||||||
private miningSubmissionHashes = new Map<string, number>();
|
private miningSubmissionHashes = new Map<string, SubmissionDedupEntry>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly socket: Socket,
|
public readonly socket: Socket,
|
||||||
@@ -115,6 +123,7 @@ export class StratumV1Client {
|
|||||||
this.socket.on('data', this.socketDataHandler);
|
this.socket.on('data', this.socketDataHandler);
|
||||||
this.network = this.getNetwork();
|
this.network = this.getNetwork();
|
||||||
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
|
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
|
||||||
|
this.maxInboundLineBytes = this.readMaxInboundLineBytes();
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -150,9 +159,15 @@ export class StratumV1Client {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.buffer += data.toString();
|
const lines = `${this.buffer}${data.toString()}`.split('\n');
|
||||||
const lines = this.buffer.split('\n');
|
const incompleteLine = lines.pop() || '';
|
||||||
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
|
if (Buffer.byteLength(incompleteLine) > this.maxInboundLineBytes
|
||||||
|
|| lines.some(line => Buffer.byteLength(line) > this.maxInboundLineBytes)) {
|
||||||
|
this.buffer = '';
|
||||||
|
this.closeSocket();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffer = incompleteLine;
|
||||||
|
|
||||||
for (const m of lines.filter(l => l.length > 0)) {
|
for (const m of lines.filter(l => l.length > 0)) {
|
||||||
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
||||||
@@ -449,8 +464,11 @@ export class StratumV1Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.backgroundWork.push(
|
this.backgroundWork.push(
|
||||||
setInterval(async () => {
|
setInterval(() => {
|
||||||
await this.checkDifficulty();
|
void this.checkDifficulty().catch((error) => {
|
||||||
|
console.error('Stratum difficulty check failed; closing client connection', error);
|
||||||
|
this.closeSocket();
|
||||||
|
});
|
||||||
}, 60 * 1000)
|
}, 60 * 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -679,6 +697,15 @@ export class StratumV1Client {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const maximumNtime = Math.floor(Date.now() / 1000) + MAX_NTIME_FUTURE_SECONDS;
|
||||||
|
if (timestamp < jobTemplate.block.timestamp || timestamp > maximumNtime) {
|
||||||
|
await this.writeSubmissionError(
|
||||||
|
submission,
|
||||||
|
eStratumErrorCode.OtherUnknown,
|
||||||
|
'Invalid ntime',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// The optional BIP310 field contains replacement bits, not an XOR
|
// The optional BIP310 field contains replacement bits, not an XOR
|
||||||
// delta. A legacy five-field submission leaves the advertised version
|
// delta. A legacy five-field submission leaves the advertised version
|
||||||
// unchanged, including any bits already set inside the rolling mask.
|
// unchanged, including any bits already set inside the rolling mask.
|
||||||
@@ -757,6 +784,15 @@ export class StratumV1Client {
|
|||||||
const creditedDifficulty = isBlockCandidate && !meetsSessionTarget
|
const creditedDifficulty = isBlockCandidate && !meetsSessionTarget
|
||||||
? Math.min(this.sessionDifficulty, submissionDifficulty)
|
? Math.min(this.sessionDifficulty, submissionDifficulty)
|
||||||
: this.sessionDifficulty;
|
: this.sessionDifficulty;
|
||||||
|
if (!this.rememberSubmission(submissionHash, job.jobId)) {
|
||||||
|
await this.writeSubmissionError(
|
||||||
|
submission,
|
||||||
|
eStratumErrorCode.OtherUnknown,
|
||||||
|
'Submission dedup capacity exceeded',
|
||||||
|
);
|
||||||
|
this.closeSocket();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let blockSubmissionResult: string = null;
|
let blockSubmissionResult: string = null;
|
||||||
if (status === 'stale') {
|
if (status === 'stale') {
|
||||||
@@ -1052,33 +1088,31 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
private isDuplicateSubmission(submissionHash: string): boolean {
|
private isDuplicateSubmission(submissionHash: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existingExpiry = this.miningSubmissionHashes.get(submissionHash);
|
this.pruneExpiredSubmissions(now);
|
||||||
if (existingExpiry != null && existingExpiry > now) {
|
return this.miningSubmissionHashes.has(submissionHash);
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
if (existingExpiry != null) {
|
|
||||||
this.miningSubmissionHashes.delete(submissionHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [hash, expiresAt] of this.miningSubmissionHashes) {
|
private rememberSubmission(submissionHash: string, jobId: string): boolean {
|
||||||
if (expiresAt <= now) {
|
const now = Date.now();
|
||||||
|
this.pruneExpiredSubmissions(now);
|
||||||
|
if (this.miningSubmissionHashes.has(submissionHash)
|
||||||
|
|| this.miningSubmissionHashes.size >= this.getSubmissionDedupMaxEntries()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.miningSubmissionHashes.set(submissionHash, {
|
||||||
|
expiresAt: now + this.getSubmissionDedupTtlMs(),
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredSubmissions(now: number): void {
|
||||||
|
for (const [hash, entry] of this.miningSubmissionHashes) {
|
||||||
|
if (entry.expiresAt <= now
|
||||||
|
&& this.stratumV1JobsService.getSubmissionContext(entry.jobId) == null) {
|
||||||
this.miningSubmissionHashes.delete(hash);
|
this.miningSubmissionHashes.delete(hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.miningSubmissionHashes.set(
|
|
||||||
submissionHash,
|
|
||||||
now + this.getSubmissionDedupTtlMs(),
|
|
||||||
);
|
|
||||||
const maxEntries = this.getSubmissionDedupMaxEntries();
|
|
||||||
while (this.miningSubmissionHashes.size > maxEntries) {
|
|
||||||
const oldestHash = this.miningSubmissionHashes.keys().next().value;
|
|
||||||
if (oldestHash == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.miningSubmissionHashes.delete(oldestHash);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSubmissionDedupTtlMs(): number {
|
private getSubmissionDedupTtlMs(): number {
|
||||||
@@ -1111,6 +1145,16 @@ export class StratumV1Client {
|
|||||||
: DEFAULT_MAX_SOCKET_BUFFER_BYTES;
|
: DEFAULT_MAX_SOCKET_BUFFER_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readMaxInboundLineBytes(): number {
|
||||||
|
const configured = Number(
|
||||||
|
this.configService.get<string>('STRATUM_MAX_INBOUND_LINE_BYTES')
|
||||||
|
?? process.env.STRATUM_MAX_INBOUND_LINE_BYTES,
|
||||||
|
);
|
||||||
|
return Number.isSafeInteger(configured) && configured > 0
|
||||||
|
? configured
|
||||||
|
: DEFAULT_MAX_INBOUND_LINE_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
private getValidationErrorSignature(errors: ValidationError[]): string {
|
private getValidationErrorSignature(errors: ValidationError[]): string {
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
|
|||||||
@@ -32,7 +32,30 @@ describe('StratumV1ClientStatistics', () => {
|
|||||||
await statistics.addShares(client, 64);
|
await statistics.addShares(client, 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(statistics.hashRate).toBeGreaterThan(0);
|
expect(statistics.hashRate).toBeCloseTo((64 * 4294967296) / 62);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps exactly the configured number of share samples', async () => {
|
||||||
|
for (let i = 0; i < 31; i++) {
|
||||||
|
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
|
||||||
|
await statistics.addShares(client, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((statistics as any).submissionCache).toHaveLength(30);
|
||||||
|
expect((statistics as any).submissionCacheDifficultySum).toBe(30 * 64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes pre-window work and corrects share-terminated sampling bias', async () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
|
||||||
|
await statistics.addShares(client, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = 29 * 31;
|
||||||
|
const expectedUnbiasedDifficulty = 28 * 64;
|
||||||
|
expect(statistics.hashRate).toBeCloseTo(
|
||||||
|
(expectedUnbiasedDifficulty * 4294967296) / elapsedSeconds,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not suggest a difficulty change before enough time or shares have passed', () => {
|
it('should not suggest a difficulty change before enough time or shares have passed', () => {
|
||||||
@@ -51,7 +74,23 @@ describe('StratumV1ClientStatistics', () => {
|
|||||||
await statistics.addShares(client, 64);
|
await statistics.addShares(client, 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
|
expect(statistics.getSuggestedDifficulty(64)).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retarget when accepted shares have a zero-duration sample window', async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await statistics.addShares(client, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => statistics.getSuggestedDifficulty(64)).not.toThrow();
|
||||||
|
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a finite power-of-two difficulty for values above 32-bit range', () => {
|
||||||
|
const result = (statistics as any).nearestPowerOfTwo(2 ** 40 + 1);
|
||||||
|
|
||||||
|
expect(result).toBe(2 ** 40);
|
||||||
|
expect(Number.isFinite(result)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decrease difficulty for slow submissions', async () => {
|
it('should decrease difficulty for slow submissions', async () => {
|
||||||
@@ -60,7 +99,7 @@ describe('StratumV1ClientStatistics', () => {
|
|||||||
await statistics.addShares(client, 64);
|
await statistics.addShares(client, 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
|
expect(statistics.getSuggestedDifficulty(128)).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not suggest a difficulty below the configured minimum', () => {
|
it('should not suggest a difficulty below the configured minimum', () => {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export class StratumV1ClientStatistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async addShares(_client: ClientEntity, targetDifficulty: number) {
|
public async addShares(_client: ClientEntity, targetDifficulty: number) {
|
||||||
var date = new Date();
|
const date = new Date();
|
||||||
|
|
||||||
if (this.submissionCache.length > CACHE_SIZE) {
|
if (this.submissionCache.length >= CACHE_SIZE) {
|
||||||
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
|
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
|
||||||
this.submissionCache.shift();
|
this.submissionCache.shift();
|
||||||
}
|
}
|
||||||
@@ -28,9 +28,12 @@ export class StratumV1ClientStatistics {
|
|||||||
});
|
});
|
||||||
this.submissionCacheDifficultySum += targetDifficulty;
|
this.submissionCacheDifficultySum += targetDifficulty;
|
||||||
|
|
||||||
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
|
const elapsedSeconds = (date.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
||||||
if(time > 60000 && this.submissionCache.length > 2) {
|
if (elapsedSeconds > 60) {
|
||||||
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
|
const difficultyPerSecond = this.getDifficultyPerSecond(elapsedSeconds);
|
||||||
|
if (difficultyPerSecond != null) {
|
||||||
|
this.hashRate = difficultyPerSecond * 4294967296;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +49,16 @@ export class StratumV1ClientStatistics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sum = this.submissionCache.reduce((pre, cur) => {
|
|
||||||
pre += cur.difficulty;
|
|
||||||
return pre;
|
|
||||||
}, 0);
|
|
||||||
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
||||||
|
const difficultyPerSecond = this.getDifficultyPerSecond(diffSeconds);
|
||||||
const difficultyPerSecond = sum / diffSeconds;
|
if (difficultyPerSecond == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
||||||
|
if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
||||||
return this.nearestPowerOfTwo(targetDifficulty)
|
return this.nearestPowerOfTwo(targetDifficulty)
|
||||||
@@ -63,27 +67,41 @@ export class StratumV1ClientStatistics {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private nearestPowerOfTwo(val): number {
|
/**
|
||||||
if (val === 0) {
|
* Estimate work rate from a share-terminated sample window.
|
||||||
|
*
|
||||||
|
* A cache of N shares contains N - 1 observed inter-share intervals. The
|
||||||
|
* first share's work predates the window and must not be counted. Because
|
||||||
|
* the window closes on a share arrival, the reciprocal elapsed time also
|
||||||
|
* has the usual finite-sample Poisson bias; multiplying by
|
||||||
|
* (intervalCount - 1) / intervalCount removes it.
|
||||||
|
*/
|
||||||
|
private getDifficultyPerSecond(elapsedSeconds: number): number | null {
|
||||||
|
const sampleCount = this.submissionCache.length;
|
||||||
|
if (sampleCount <= 2 || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (val < this.minDifficulty) {
|
|
||||||
|
const intervalCount = sampleCount - 1;
|
||||||
|
const observedDifficulty = this.submissionCacheDifficultySum
|
||||||
|
- this.submissionCache[0].difficulty;
|
||||||
|
const unbiasedDifficulty = observedDifficulty * (intervalCount - 1) / intervalCount;
|
||||||
|
const difficultyPerSecond = unbiasedDifficulty / elapsedSeconds;
|
||||||
|
|
||||||
|
return Number.isFinite(difficultyPerSecond) && difficultyPerSecond > 0
|
||||||
|
? difficultyPerSecond
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nearestPowerOfTwo(val: number): number {
|
||||||
|
if (!Number.isFinite(val) || val <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (val <= this.minDifficulty) {
|
||||||
return this.minDifficulty;
|
return this.minDifficulty;
|
||||||
}
|
}
|
||||||
let x = val | (val >> 1);
|
const result = 2 ** Math.floor(Math.log2(val));
|
||||||
x = x | (x >> 2);
|
return Number.isFinite(result) ? Math.max(this.minDifficulty, result) : null;
|
||||||
x = x | (x >> 4);
|
|
||||||
x = x | (x >> 8);
|
|
||||||
x = x | (x >> 16);
|
|
||||||
x = x | (x >> 32);
|
|
||||||
const res = x - (x >> 1);
|
|
||||||
if (res == 0 && val * 100 < this.minDifficulty) {
|
|
||||||
return this.minDifficulty;
|
|
||||||
}
|
|
||||||
if (res == 0) {
|
|
||||||
return this.nearestPowerOfTwo(val * 100) / 100;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Expose, Transform } from 'class-transformer';
|
import { Expose, Transform } from 'class-transformer';
|
||||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsPositive, IsString, Max, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||||
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
|
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
|
||||||
import { StratumBaseMessage } from './StratumBaseMessage';
|
import { StratumBaseMessage } from './StratumBaseMessage';
|
||||||
|
import { MAX_STRATUM_DIFFICULTY } from './SuggestDifficultyMessage';
|
||||||
|
|
||||||
export class AuthorizationMessage extends StratumBaseMessage {
|
export class AuthorizationMessage extends StratumBaseMessage {
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export class AuthorizationMessage extends StratumBaseMessage {
|
|||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
@Max(MAX_STRATUM_DIFFICULTY)
|
||||||
@Transform(({ value, key, obj, type }) => {
|
@Transform(({ value, key, obj, type }) => {
|
||||||
const password: string | null = obj.params[1];
|
const password: string | null = obj.params[1];
|
||||||
if (password?.includes('d=')) {
|
if (password?.includes('d=')) {
|
||||||
@@ -69,4 +72,4 @@ export class AuthorizationMessage extends StratumBaseMessage {
|
|||||||
result: true
|
result: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Expose, Transform } from 'class-transformer';
|
import { Expose, Transform } from 'class-transformer';
|
||||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber } from 'class-validator';
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsPositive, Max } from 'class-validator';
|
||||||
|
|
||||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||||
import { eResponseMethod } from '../enums/eResponseMethod';
|
import { eResponseMethod } from '../enums/eResponseMethod';
|
||||||
import { StratumBaseMessage } from './StratumBaseMessage';
|
import { StratumBaseMessage } from './StratumBaseMessage';
|
||||||
|
|
||||||
|
export const MAX_STRATUM_DIFFICULTY = 2 ** 32;
|
||||||
|
|
||||||
export class SuggestDifficulty extends StratumBaseMessage {
|
export class SuggestDifficulty extends StratumBaseMessage {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@@ -16,6 +18,8 @@ export class SuggestDifficulty extends StratumBaseMessage {
|
|||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
@Max(MAX_STRATUM_DIFFICULTY)
|
||||||
@Transform(({ value, key, obj, type }) => {
|
@Transform(({ value, key, obj, type }) => {
|
||||||
return Number(obj.params[0]);
|
return Number(obj.params[0]);
|
||||||
})
|
})
|
||||||
@@ -37,4 +41,3 @@ export class SuggestDifficulty extends StratumBaseMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user