38 Commits
Author SHA1 Message Date
Ben 96a9202c11 Sync package lock for Node 24 2026-06-05 11:48:35 -04:00
Ben 95565eeec4 Migrate postgresql improvements to sqlite 2026-06-05 10:48:42 -04:00
Benjamin Wilson b971e9ce4c remove logging 2025-04-27 13:13:36 -04:00
Ben e1b88207d3 Prevent duplicate share submissions - this is why we can't have nice things 2025-04-27 13:06:06 -04:00
Benjamin Wilson e7f7cb52af Merge branch 'master' of https://github.com/benjamin-wilson/public-pool 2025-04-19 13:51:22 -04:00
Benjamin Wilson 6cf1c225a4 Increase miner compatibility by allowing, but stripping unknown message values rather than rejecting as validation errors 2025-04-19 13:51:16 -04:00
KillerInk fd775965e5 cherry pick hashrate fix from pqsql branch (#102) 2025-04-04 13:40:02 -04:00
Luke Childs 554a8916a7 Fix incorrect default POOL_IDENTIFIER valie in .env.example (#103) 2025-04-04 13:37:45 -04:00
fda3f21b71 feat: external pool shares (#89)
* feat:external shares
---------

Co-authored-by: mrv777 <mrv777@users.noreply.github.com>
Co-authored-by: Benjamin Wilson <admin@opensourceminer.com>
2025-02-23 15:43:01 -05:00
Benjamin Wilson 0201703e35 delete old templates and jobs 2025-02-23 13:17:54 -05:00
wario_is_here ddb7a89615 update node.js to 22.11.0 (lts) (#75) 2024-11-10 10:35:57 -05:00
Remco Ros f9e49a8955 patch rpc-bitcoin to be compatible with bitcoin core 28.0 (#66) 2024-10-30 22:28:03 -04:00
Benjamin Wilson 2912dd5071 user agent cleanup 2024-10-16 13:53:37 -04:00
Benjamin Wilson 4282233d2f Ensure mintime is considered for jobs 2024-09-27 13:01:36 -04:00
Stijn 8ce057b3b5 Add pool identifier configuration and improve script size handling (#60)
* fixed a couple tests because of imports, missing newline chars and missing constructor parameters

* Add pool identifier configuration and improve script size handling
2024-08-30 08:57:43 -04:00
Stijn 7bb436bf1f fixed a couple tests because of imports, missing newline chars and missing constructor parameters (#58) 2024-08-28 19:46:01 -04:00
Benjamin Wilson 0917911eb0 validation handling 2024-08-22 15:18:11 -04:00
Benjamin Wilson de2b41a3c1 initialize hashrate to 0 2024-07-17 17:24:50 -04:00
Benjamin Wilson 1cd5bcc97d loadBlockTemplate 2024-07-05 20:20:51 -04:00
Benjamin Wilson 277aa10144 StratumBaseMessage validation 2024-07-05 08:33:19 -04:00
Benjamin Wilson f08103bd8c open stratum base message to accept number or string 2024-07-05 08:19:37 -04:00
Benjamin Wilson 811e7972f9 always clear jobs on diff change 2024-07-05 08:14:09 -04:00
Benjamin Wilson c654a706ae ensure block template is fresh 2024-07-04 20:21:53 -04:00
Benjamin Wilson e0239d2805 handle partial socket data 2024-07-04 20:21:41 -04:00
Benjamin Wilson fc96b9ea45 getBlockTemplate improvements 2024-07-04 16:41:15 -04:00
Benjamin Wilson ad1f32cc2c ensure getblocktemplate always returns a value 2024-07-03 14:31:33 -04:00
Benjamin Wilson 917d312211 remove dead code 2024-07-03 14:28:28 -04:00
Marcel Hernandez 76e2426c30 add option to conect to Bitcoin RPC with cookie file (#40) 2024-06-10 07:43:47 -04:00
Oscar Fawkes 098fe2867a read full lines of json-rpc from clients (#46)
Lgtm, thanks for your contribution
2024-06-10 07:41:18 -04:00
Michael Schmid 6e3902b732 nodejs requires curl for some dependencies, make sure they are part of the build container (#42) 2024-05-27 18:03:58 -04:00
Georges 8c62817f73 stratum: set_difficulty should always be a notification (#32) 2024-02-23 12:51:27 -05:00
Ben Wilson 9f723782ab optional password 2024-02-20 19:17:20 -05:00
Ben Wilson 2e296c00d5 client statistics share calculation 2024-02-18 14:56:08 -05:00
Ben Wilson 437b0c9bc4 updated at, hashrate calculation 2024-02-18 12:32:21 -05:00
Ben Wilson deed775d55 change coinbase tag 2024-02-17 20:31:39 -05:00
Ben Wilson ed537658ac misc 2024-02-17 20:12:09 -05:00
Ben Wilson 29e0d884e3 share updates 2024-02-17 18:40:14 -05:00
Ben Wilson e35bacc648 optimized share calculation and updating 2024-02-17 18:22:09 -05:00
52 changed files with 1291 additions and 9513 deletions
+6 -14
View File
@@ -10,17 +10,16 @@ BITCOIN_RPC_PASSWORD=
BITCOIN_RPC_PORT=8332 BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
# You can use this instead of BITCOIN_RPC_USER and BITCOIN_RPC_PASSWORD
BITCOIN_RPC_COOKIEFILE=
# Enable in bitcoin.conf with # Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000 # zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000" # BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334 API_PORT=3334
STRATUM_PORTS=3333,3332,3331,3330 STRATUM_PORT=3333
STRATUM_SECURE=true
SECURE_STRATUM_PORTS=4333,4332,4331,4330
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000 STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot #optional telegram bot
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
@@ -36,12 +35,5 @@ DEV_FEE_ADDRESS=
NETWORK=mainnet NETWORK=mainnet
API_SECURE=false API_SECURE=false
# Default is "Public-Pool", you can change it to any string it will be removed if it will make the block or coinbase script too big
#postgresql POOL_IDENTIFIER="Public-Pool"
DB_HOST=
DB_PORT=
DB_USERNAME=
DB_PASSWORD=
DB_DATABASE=
PRODUCTION=false
+2
View File
@@ -11,6 +11,8 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3 \ python3 \
build-essential \ build-essential \
cmake \ cmake \
curl \
ca-certificates \
&& apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /build WORKDIR /build
-25
View File
@@ -1,25 +0,0 @@
module.exports = {
apps: [
// Master instance
{
name: 'master',
script: './dist/main.js',
instances: 1,
env: {
MASTER: 'true',
},
time: true
},
// Worker instances
{
name: 'workers',
script: './dist/main.js',
instances: 2,
exec_mode: "cluster",
env: {
MASTER: 'false',
},
time: true
},
],
};
+21 -8298
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -33,7 +33,6 @@
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"axios": "^1.16.0", "axios": "^1.16.0",
"better-sqlite3": "^12.9.0",
"bitcoin-address-validation": "^2.2.3", "bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7", "bitcoinjs-lib": "^6.1.7",
"bs58": "^5.0.0", "bs58": "^5.0.0",
@@ -42,8 +41,7 @@
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
"discord.js": "^14.26.4", "discord.js": "^14.26.4",
"merkle-lib": "^2.0.10", "merkle-lib": "^2.0.10",
"pg": "^8.20.0", "sqlite3": "^5.1.6",
"pg-pubsub": "^0.8.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4", "tiny-secp256k1": "^2.2.4",
-15
View File
@@ -1,15 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UniqueNonceIndex implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NOT NULL`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_unique_nonce"`);
}
}
@@ -1,15 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserAgentReportService } from './user-agent-report.service';
import { UserAgentReportView } from './user-agent-report.view';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([UserAgentReportView])],
providers: [UserAgentReportService],
exports: [TypeOrmModule, UserAgentReportService],
})
export class UserAgentReportModule { }
@@ -1,35 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserAgentReportView } from './user-agent-report.view';
@Injectable()
export class UserAgentReportService {
constructor(
@InjectRepository(UserAgentReportView)
private userAgentReport: Repository<UserAgentReportView>,
) {
}
public async getReport() {
return await this.userAgentReport.find();
}
public async refreshReport() {
try {
return await this.userAgentReport.query(`
COMMIT;
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
REFRESH MATERIALIZED VIEW user_agent_report_view;
COMMIT;
`);
} catch (e) {
console.log(e)
}
}
}
@@ -1,27 +0,0 @@
import { DataSource, ViewColumn, ViewEntity } from 'typeorm';
import { ClientEntity } from '../../client/client.entity';
@ViewEntity({
materialized: true,
expression: (dataSource: DataSource) =>
dataSource
.createQueryBuilder()
.select('client.userAgent as "userAgent"')
.addSelect('COUNT(client.userAgent)', 'count')
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
.addSelect('SUM(client.hashRate)', 'totalHashRate')
.from(ClientEntity, 'client')
.groupBy('client.userAgent')
.orderBy('"totalHashRate"', 'DESC')
})
export class UserAgentReportView {
@ViewColumn()
userAgent: string;
@ViewColumn()
count: string;
@ViewColumn()
bestDifficulty: number;
@ViewColumn()
totalHashRate: string;
}
@@ -11,14 +11,14 @@ export class AddressSettingsEntity extends TrackedEntity {
@Column({ default: 0 }) @Column({ default: 0 })
shares: number; shares: number;
@Column({ type: 'decimal', default: 0 }) @Column({ type: 'real', default: 0 })
bestDifficulty: number; bestDifficulty: number;
@Column({ nullable: true })
bestDifficultyUserAgent: string;
@Column({ nullable: true }) @Column({ nullable: true })
miscCoinbaseScriptData: string; miscCoinbaseScriptData: string;
@Column({ nullable: true })
bestDifficultyUserAgent: string;
} }
@@ -49,19 +49,27 @@ export class AddressSettingsService {
.execute(); .execute();
} }
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
public async createNew(address: string) { public async createNew(address: string) {
return await this.addressSettingsRepository.save({ address }); return await this.addressSettingsRepository.save({ address });
} }
// public async addShares(address: string, shares: number) { public async addShares(address: string, shares: number) {
// return await this.addressSettingsRepository.createQueryBuilder() return await this.addressSettingsRepository.createQueryBuilder()
// .update(AddressSettingsEntity) .update(AddressSettingsEntity)
// .set({ .set({
// shares: () => `"shares" + ${shares}` // Use the actual value of shares here shares: () => `"shares" + ${shares}` // Use the actual value of shares here
// }) })
// .where('address = :address', { address }) .where('address = :address', { address })
// .execute(); .execute();
// } }
public async resetBestDifficultyAndShares() { public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository return await this.addressSettingsRepository
@@ -73,12 +81,4 @@ export class AddressSettingsService {
}) })
.execute(); .execute();
} }
}
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
}
+3 -4
View File
@@ -1,12 +1,11 @@
import { Column, Entity } from 'typeorm'; import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity() @Entity()
export class BlocksEntity extends TrackedEntity { export class BlocksEntity extends TrackedEntity {
@PrimaryGeneratedBigIntColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Column() @Column()
@@ -24,4 +23,4 @@ export class BlocksEntity extends TrackedEntity {
@Column() @Column()
blockData: string; blockData: string;
} }
@@ -1,15 +1,15 @@
import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { ClientEntity } from '../client/client.entity';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity() @Entity()
//Index for getHashRateForSession
@Index(["address", "clientName", "sessionId"])
//Index for statistics save //Index for statistics save
@Index(["clientId", "time"]) @Index(["address", "clientName", "sessionId", "time"])
export class ClientStatisticsEntity extends TrackedEntity { export class ClientStatisticsEntity extends TrackedEntity {
@PrimaryGeneratedBigIntColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Column({ length: 62, type: 'varchar' }) @Column({ length: 62, type: 'varchar' })
@@ -22,27 +22,14 @@ export class ClientStatisticsEntity extends TrackedEntity {
sessionId: string; sessionId: string;
@Index() @Index()
@Column({ type: 'bigint' }) @Column({ type: 'integer' })
time: number; time: number;
@Column({ type: 'decimal' }) @Column({ type: 'real' })
shares: number; shares: number;
@Column({ default: 0, type: 'bigint' }) @Column({ default: 0, type: 'integer' })
acceptedCount: number; acceptedCount: number;
}
@ManyToOne(
() => ClientEntity,
clientEntity => clientEntity.statistics,
{ nullable: false, }
)
client: ClientEntity;
@Index()
@Column({ name: 'clientId' })
public clientId: string;
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity'; import { ClientStatisticsEntity } from './client-statistics.entity';
@@ -8,89 +8,32 @@ import { ClientStatisticsEntity } from './client-statistics.entity';
@Injectable() @Injectable()
export class ClientStatisticsService { export class ClientStatisticsService {
private bulkAsyncUpdates: {
[key: string]: Partial<ClientStatisticsEntity>
} = {};
constructor( constructor(
@InjectDataSource()
private dataSource: DataSource,
@InjectRepository(ClientStatisticsEntity) @InjectRepository(ClientStatisticsEntity)
private clientStatisticsRepository: Repository<ClientStatisticsEntity>, private clientStatisticsRepository: Repository<ClientStatisticsEntity>,
) { ) {
} }
// public async update(clientStatistic: Partial<ClientStatisticsEntity>) { public async update(clientStatistic: Partial<ClientStatisticsEntity>) {
// await this.clientStatisticsRepository.update({ clientId: clientStatistic.clientId, time: clientStatistic.time }, await this.clientStatisticsRepository.update({
// { address: clientStatistic.address,
// shares: clientStatistic.shares, clientName: clientStatistic.clientName,
// acceptedCount: clientStatistic.acceptedCount, sessionId: clientStatistic.sessionId,
// updatedAt: new Date() time: clientStatistic.time
// }); },
// } {
shares: clientStatistic.shares,
acceptedCount: clientStatistic.acceptedCount,
updatedAt: new Date()
});
public updateBulkAsync(clientStatistic: Partial<ClientStatisticsEntity>) {
const key = clientStatistic.clientId + clientStatistic.time.toString();
if(this.bulkAsyncUpdates[key] != null){
this.bulkAsyncUpdates[key].shares = clientStatistic.shares;
this.bulkAsyncUpdates[key].acceptedCount = clientStatistic.acceptedCount;
return;
}
this.bulkAsyncUpdates[clientStatistic.clientId + clientStatistic.time.toString()] = clientStatistic;
} }
public async doBulkAsyncUpdate(){
if(Object.keys(this.bulkAsyncUpdates).length < 1){
console.log('No client stats to update.')
return;
}
// Step 1: Prepare data for bulk update
const values = Object.entries(this.bulkAsyncUpdates).map(([key, value]) => {
return `('${value.clientId}', ${value.time}, ${value.shares}, ${value.acceptedCount}, NOW())`
}).join(',');
const query = `
DO $$
BEGIN
CREATE TEMP TABLE temp_stats (
"clientId" UUID,
time BIGINT,
shares NUMERIC,
"acceptedCount" INT,
"updatedAt" TIMESTAMP
) ON COMMIT DROP;
INSERT INTO temp_stats ("clientId", time, shares, "acceptedCount", "updatedAt")
VALUES ${values};
UPDATE "client_statistics_entity" cse
SET shares = ts.shares,
"acceptedCount" = ts."acceptedCount",
"updatedAt" = ts."updatedAt"
FROM temp_stats ts
WHERE cse."clientId" = ts."clientId" AND cse.time = ts.time;
END;
$$;
`;
try {
await this.clientStatisticsRepository.query(query);
//console.log(`Bulk updated ${Object.keys(this.bulkAsyncUpdates).length} statistics`)
} catch (error) {
console.error('Bulk update failed:', error.message, query);
throw error;
}
this.bulkAsyncUpdates = {};
}
public async insert(clientStatistic: Partial<ClientStatisticsEntity>) { public async insert(clientStatistic: Partial<ClientStatisticsEntity>) {
// If no rows were updated, insert a new record
await this.clientStatisticsRepository.insert(clientStatistic); await this.clientStatisticsRepository.insert(clientStatistic);
} }
@@ -105,7 +48,57 @@ export class ClientStatisticsService {
.execute(); .execute();
} }
public async getChartDataForSite() {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = `
SELECT
time AS label,
ROUND(((SUM(shares) * 4294967296) / 600)) AS data
FROM
client_statistics_entity AS entry
WHERE
entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result: any[] = await this.clientStatisticsRepository.query(query);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1)
}
// public async getHashRateForAddress(address: string) {
// const oneHour = new Date(new Date().getTime() - (60 * 60 * 1000));
// const query = `
// SELECT
// SUM(entry.shares) AS difficultySum
// FROM
// client_statistics_entity AS entry
// WHERE
// entry.address = ? AND entry.time > ${oneHour}
// `;
// const result = await this.clientStatisticsRepository.query(query, [address]);
// const difficultySum = result[0].difficultySum;
// return (difficultySum * 4294967296) / (600);
// }
public async getChartDataForAddress(address: string) { public async getChartDataForAddress(address: string) {
@@ -113,12 +106,12 @@ export class ClientStatisticsService {
const query = ` const query = `
SELECT SELECT
time AS label, time label,
(SUM(shares) * 4294967296) / 600 AS data (SUM(shares) * 4294967296) / 600 AS data
FROM FROM
client_statistics_entity AS entry client_statistics_entity AS entry
WHERE WHERE
entry.address = $1 AND entry.time > $2 entry.address = ? AND entry.time > ${yesterday.getTime()}
GROUP BY GROUP BY
time time
ORDER BY ORDER BY
@@ -127,10 +120,10 @@ export class ClientStatisticsService {
`; `;
const result = await this.clientStatisticsRepository.query(query, [address, yesterday.getTime()]); const result = await this.clientStatisticsRepository.query(query, [address]);
return result.map(res => { return result.map(res => {
res.label = new Date(parseInt(res.label)).toISOString(); res.label = new Date(res.label).toISOString();
return res; return res;
}).slice(0, result.length - 1); }).slice(0, result.length - 1);
@@ -148,7 +141,7 @@ export class ClientStatisticsService {
FROM FROM
client_statistics_entity AS entry client_statistics_entity AS entry
WHERE WHERE
entry.address = $1 AND entry.clientName = $2 AND entry.time > ${oneHour.getTime()} entry.address = ? AND entry.clientName = ? AND entry.time > ${oneHour.getTime()}
`; `;
const result = await this.clientStatisticsRepository.query(query, [address, clientName]); const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
@@ -165,12 +158,12 @@ export class ClientStatisticsService {
const query = ` const query = `
SELECT SELECT
time AS label, time label,
(SUM(shares) * 4294967296) / 600 AS data (SUM(shares) * 4294967296) / 600 AS data
FROM FROM
client_statistics_entity AS entry client_statistics_entity AS entry
WHERE WHERE
entry.address = $1 AND entry."clientName" = $2 AND entry.time > ${yesterday.getTime()} entry.address = ? AND entry.clientName = ? AND entry.time > ${yesterday.getTime()}
GROUP BY GROUP BY
time time
ORDER BY ORDER BY
@@ -181,7 +174,7 @@ export class ClientStatisticsService {
const result = await this.clientStatisticsRepository.query(query, [address, clientName]); const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
return result.map(res => { return result.map(res => {
res.label = new Date(parseInt(res.label)).toISOString(); res.label = new Date(res.label).toISOString();
return res; return res;
}).slice(0, result.length - 1); }).slice(0, result.length - 1);
@@ -189,59 +182,59 @@ export class ClientStatisticsService {
} }
// public async getHashRateForSession(clientId: string) { public async getHashRateForSession(address: string, clientName: string, sessionId: string) {
// const query = ` const query = `
// SELECT SELECT
// "createdAt", createdAt,
// "updatedAt", updatedAt,
// shares shares
// FROM FROM
// client_statistics_entity AS entry client_statistics_entity AS entry
// WHERE WHERE
// entry."clientId" = $1 entry.address = ? AND entry.clientName = ? AND entry.sessionId = ?
// ORDER BY time DESC ORDER BY time DESC
// LIMIT 2; LIMIT 2;
// `; `;
// const result = await this.clientStatisticsRepository.query(query, [clientId]); const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
// if (result.length < 1) { if (result.length < 1) {
// return 0; return 0;
// } }
// const latestStat = result[0]; const latestStat = result[0];
// if (result.length < 2) { if (result.length < 2) {
// const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime(); const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime();
// // 1min // 1min
// if (time < 1000 * 60) { if (time < 1000 * 60) {
// return 0; return 0;
// } }
// return (parseFloat(latestStat.shares) * 4294967296) / (time / 1000); return (latestStat.shares * 4294967296) / (time / 1000);
// } else { } else {
// const secondLatestStat = result[1]; const secondLatestStat = result[1];
// const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime(); const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime();
// // 1min // 1min
// if (time < 1000 * 60) { if (time < 1000 * 60) {
// return 0; return 0;
// } }
// return ((parseFloat(latestStat.shares) + parseFloat(secondLatestStat.shares)) * 4294967296) / (time / 1000); return ((latestStat.shares + secondLatestStat.shares) * 4294967296) / (time / 1000);
// } }
// } }
public async getChartDataForSession(clientId: string) { public async getChartDataForSession(address: string, clientName: string, sessionId: string) {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = ` const query = `
SELECT SELECT
time AS label, time label,
(SUM(shares) * 4294967296) / 600 AS data (SUM(shares) * 4294967296) / 600 AS data
FROM FROM
client_statistics_entity AS entry client_statistics_entity AS entry
WHERE WHERE
entry."clientId" = $1 AND entry.time > ${yesterday.getTime()} entry.address = ? AND entry.clientName = ? AND entry.sessionId = ? AND entry.time > ${yesterday.getTime()}
GROUP BY GROUP BY
time time
ORDER BY ORDER BY
@@ -249,10 +242,10 @@ export class ClientStatisticsService {
LIMIT 144; LIMIT 144;
`; `;
const result = await this.clientStatisticsRepository.query(query, [clientId]); const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
return result.map(res => { return result.map(res => {
res.label = new Date(parseInt(res.label)).toISOString(); res.label = new Date(res.label).toISOString();
return res; return res;
}).slice(0, result.length - 1); }).slice(0, result.length - 1);
+16 -23
View File
@@ -1,28 +1,25 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { ClientStatisticsEntity } from '../client-statistics/client-statistics.entity'; import { DateTimeTransformer } from '../utils/DateTimeTransformer';
import { TrackedEntity } from '../utils/TrackedEntity.entity'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
//https://www.sqlite.org/withoutrowid.html
@Entity() //The WITHOUT ROWID optimization is likely to be helpful for tables that have non-integer
@Index("IDX_unique_nonce", { synchronize: false }) // or composite (multi-column) PRIMARY KEYs and that do not store large strings or BLOBs.
@Index('idx_client_cleanup', ['id'], { //WITHOUT ROWID tables work best when individual rows are not too large.
where: '"deletedAt" IS NULL', @Entity({ withoutRowid: true })
// This is a partial (filtered) index — only indexes active clients @Index(['address', 'clientName', 'sessionId'], { unique: true })
})
export class ClientEntity extends TrackedEntity { export class ClientEntity extends TrackedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index() @PrimaryColumn({ length: 62, type: 'varchar' })
@Column({ length: 62, type: 'varchar' })
address: string; address: string;
@Column({ length: 64, type: 'varchar' }) @PrimaryColumn({ length: 64, type: 'varchar' })
clientName: string; clientName: string;
@Column({ length: 8, type: 'varchar', }) @PrimaryColumn({ length: 8, type: 'varchar' })
sessionId: string; sessionId: string;
@@ -30,19 +27,15 @@ export class ClientEntity extends TrackedEntity {
userAgent: string; userAgent: string;
@Column()
@Column({ type: 'datetime', transformer: new DateTimeTransformer() })
startTime: Date; startTime: Date;
@Column({ type: 'decimal', default: 0 }) @Column({ type: 'real', default: 0 })
bestDifficulty: number bestDifficulty: number
@Column({ default: 0, type: 'decimal' }) @Column({ default: 0 })
hashRate: number; hashRate: number;
@OneToMany(
() => ClientStatisticsEntity,
clientStatisticsEntity => clientStatisticsEntity.client
)
statistics: ClientStatisticsEntity[]
} }
+67 -104
View File
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Interval } from '@nestjs/schedule';
import { DataSource, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { firstValueFrom, Subject } from 'rxjs';
import { ObjectLiteral, Repository } from 'typeorm';
import { ClientEntity } from './client.entity'; import { ClientEntity } from './client.entity';
@@ -9,119 +11,80 @@ import { ClientEntity } from './client.entity';
@Injectable() @Injectable()
export class ClientService { export class ClientService {
private heartbeatBulkUpdate: { [id: string]: { id: string, hashRate: number, updatedAt: Date } } = {};
public insertQueue: { result: Subject<ObjectLiteral>, partialClient: Partial<ClientEntity> }[] = [];
constructor( constructor(
@InjectDataSource()
private dataSource: DataSource,
@InjectRepository(ClientEntity) @InjectRepository(ClientEntity)
private clientRepository: Repository<ClientEntity> private clientRepository: Repository<ClientEntity>
) { ) {
} }
// client.service.ts @Interval(1000 * 5)
public async killDeadClients(): Promise<boolean> { public async insertClients() {
const BATCH_SIZE = 10000; const queueCopy = [...this.insertQueue];
this.insertQueue = [];
return this.dataSource.transaction('READ COMMITTED', async (manager) => { // ← CHANGE HERE if (queueCopy.length === 0) {
const deadClients: { id: string }[] = await manager.query(`
SELECT id
FROM client_entity
WHERE "deletedAt" IS NULL
AND "updatedAt" < NOW() - INTERVAL '5 minutes'
ORDER BY id
LIMIT $1
FOR UPDATE SKIP LOCKED
`, [BATCH_SIZE]);
if (deadClients.length === 0) {
return false;
}
await manager
.createQueryBuilder()
.update(ClientEntity)
.set({ deletedAt: () => 'NOW()' })
.whereInIds(deadClients.map(c => c.id))
.execute();
console.log(`Killed ${deadClients.length} dead clients`);
return true;
});
}
//public async heartbeat(id, hashRate: number, updatedAt: Date) {
// return await this.clientRepository.update({ id }, { hashRate, deletedAt: null, updatedAt });
// }
public heartbeatBulkAsync(id, hashRate: number, updatedAt: Date) {
if (this.heartbeatBulkUpdate[id] != null) {
this.heartbeatBulkUpdate[id].hashRate = hashRate;
this.heartbeatBulkUpdate[id].updatedAt = updatedAt;
return; return;
} }
this.heartbeatBulkUpdate[id] = { id, hashRate, updatedAt };
}
public async doBulkHeartbeatUpdate() {
if (Object.keys(this.heartbeatBulkUpdate).length < 1) {
console.log('No heartbeats to update.')
return;
}
const values = Object.entries(this.heartbeatBulkUpdate).map(([key, value]) => {
return `('${value.id}', ${value.hashRate}, NOW())`
}).join(',');
const query = `
DO $$
BEGIN
CREATE TEMP TABLE temp_heartbeats (
id UUID,
"hashRate" DECIMAL,
"updatedAt" TIMESTAMP
) ON COMMIT DROP;
INSERT INTO temp_heartbeats (id, "hashRate", "updatedAt")
VALUES ${values};
UPDATE "client_entity" ce
SET "hashRate" = th."hashRate",
"deletedAt" = NULL,
"updatedAt" = th."updatedAt"
FROM temp_heartbeats th
WHERE ce.id = th.id;
END;
$$;
`;
try { try {
await this.clientRepository.query(query); const results = await this.clientRepository.insert(queueCopy.map(c => c.partialClient));
console.log(`Bulk updated ${Object.keys(this.heartbeatBulkUpdate).length} client heartbeats`);
} catch (error) {
console.error('Bulk heartbeat failed:', error.message, 'Query:', query);
throw error;
}
this.heartbeatBulkUpdate = {}; queueCopy.forEach((c, index) => {
c.result.next(results.generatedMaps[index]);
c.result.complete();
});
} catch (e) {
queueCopy.forEach(c => c.result.error(e));
throw e;
}
} }
public async killDeadClients() {
var fiveMinutes = new Date(new Date().getTime() - (5 * 60 * 1000)).toISOString();
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ deletedAt: () => "DATETIME('now')" })
.where("deletedAt IS NULL AND updatedAt < DATETIME(:fiveMinutes)", { fiveMinutes })
.execute();
}
public async heartbeat(address: string, clientName: string, sessionId: string, hashRate: number, updatedAt: Date) {
return await this.clientRepository.update({ address, clientName, sessionId }, { hashRate, deletedAt: null, updatedAt });
}
// public async save(client: Partial<ClientEntity>) {
// return await this.clientRepository.save(client);
// }
public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> { public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> {
const insertResult = await this.clientRepository.insert(partialClient);
const result = new Subject<ObjectLiteral>();
this.insertQueue.push({ result, partialClient });
// const insertResult = await this.clientRepository.insert(partialClient);
const generatedMap = await firstValueFrom(result);
const client = { const client = {
...partialClient, ...partialClient,
...insertResult.generatedMaps[0] ...generatedMap
}; };
return client as ClientEntity; return client as ClientEntity;
} }
public async delete(id: string) { public async delete(sessionId: string) {
return await this.clientRepository.softDelete({ id }); return await this.clientRepository.softDelete({ sessionId });
} }
public async deleteOldClients() { public async deleteOldClients() {
@@ -137,16 +100,16 @@ export class ClientService {
} }
public async updateBestDifficulty(id: string, bestDifficulty: number) { public async updateBestDifficulty(sessionId: string, bestDifficulty: number) {
return await this.clientRepository.update({ id }, { bestDifficulty }); return await this.clientRepository.update({ sessionId }, { bestDifficulty });
} }
public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) { public async updateBestDifficultyIfHigher(sessionId: string, bestDifficulty: number) {
return await this.clientRepository return await this.clientRepository
.createQueryBuilder() .createQueryBuilder()
.update(ClientEntity) .update(ClientEntity)
.set({ bestDifficulty }) .set({ bestDifficulty })
.where('id = :id', { id }) .where('sessionId = :sessionId', { sessionId })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty }) .andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute(); .execute();
} }
@@ -190,16 +153,16 @@ export class ClientService {
.execute(); .execute();
} }
// public async getUserAgents() { public async getUserAgents() {
// const result = await this.clientRepository.createQueryBuilder('client') const result = await this.clientRepository.createQueryBuilder('client')
// .select('client.userAgent as "userAgent"') .select('client.userAgent as userAgent')
// .addSelect('COUNT(client.userAgent)', 'count') .addSelect('COUNT(client.userAgent)', 'count')
// .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty') .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
// .addSelect('SUM(client.hashRate)', 'totalHashRate') .addSelect('SUM(client.hashRate)', 'totalHashRate')
// .groupBy('client.userAgent') .groupBy('client.userAgent')
// .orderBy('"totalHashRate"', 'DESC') .orderBy('count', 'DESC')
// .getRawMany(); .getRawMany();
// return result; return result;
// } }
} }
@@ -0,0 +1,30 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
@Index(['address', 'time'])
export class ExternalSharesEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ type: 'integer' })
time: number;
@Column({ type: 'real' })
difficulty: number;
@Column({ length: 128, type: 'varchar', nullable: true })
userAgent: string;
@Column({ length: 128, type: 'varchar', nullable: true })
externalPoolName: string;
@Column()
header: string;
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
import { ExternalSharesService } from './external-shares.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ExternalSharesEntity])],
providers: [ExternalSharesService],
exports: [TypeOrmModule, ExternalSharesService],
})
export class ExternalSharesModule { }
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
@Injectable()
export class ExternalSharesService {
constructor(
@InjectRepository(ExternalSharesEntity)
private externalSharesRepository: Repository<ExternalSharesEntity>
) {}
public async insert(externalShare: Partial<ExternalSharesEntity>) {
return await this.externalSharesRepository.insert(externalShare);
}
public async getTopDifficulties(): Promise<Array<{userAgent: string, time: number, externalPoolName: string, difficulty: number}>> {
return await this.externalSharesRepository
.createQueryBuilder('share')
.select('share.userAgent', 'userAgent')
.addSelect('share.time', 'time')
.addSelect('share.externalPoolName', 'externalPoolName')
.addSelect('MAX(share.difficulty)', 'difficulty')
.groupBy('share.address')
.orderBy('MAX(share.difficulty)', 'DESC')
.limit(10)
.getRawMany();
}
public async getAddressBestDifficulty(address: string): Promise<number> {
const result = await this.externalSharesRepository
.createQueryBuilder()
.select('MAX(difficulty)', 'maxDifficulty')
.where('address = :address', { address })
.getRawOne();
return result?.maxDifficulty || 0;
}
public async deleteOldShares() {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return await this.externalSharesRepository
.createQueryBuilder()
.delete()
.from(ExternalSharesEntity)
.where('time < :time', { time: oneDayAgo.getTime() })
.execute();
}
public async deleteAll() {
return await this.externalSharesRepository.delete({});
}
}
-16
View File
@@ -1,16 +0,0 @@
import { Column, Entity } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
@Entity()
export class HomeGraphEntity {
@PrimaryGeneratedBigIntColumn()
id: number;
@Column({ type: 'bigint' })
label: number;
@Column({ type: 'bigint' })
data: number;
}
-15
View File
@@ -1,15 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HomeGraphEntity } from './home-graph.entity';
import { HomeGraphService } from './home-graph.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([HomeGraphEntity])],
providers: [HomeGraphService],
exports: [TypeOrmModule, HomeGraphService],
})
export class HomeGraphModule { }
-45
View File
@@ -1,45 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HomeGraphEntity } from './home-graph.entity';
@Injectable()
export class HomeGraphService {
constructor(
@InjectRepository(HomeGraphEntity) private homeGraphRepository: Repository<HomeGraphEntity>
) {
}
public async getLatestTime(): Promise<Date> {
const result = await this.homeGraphRepository.createQueryBuilder('entity')
.select('MAX(entity.label)', 'maxNumber')
.getRawOne();
if (result.maxNumber == null) {
return new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
}
return new Date(parseInt(result.maxNumber));
}
public async save(graph: { label: number, data: number }[]) {
await this.homeGraphRepository.insert(graph);
}
public async getChartDataForSite(limit: number = 144 * 7) {
const records = await this.homeGraphRepository
.createQueryBuilder('homeGraph')
.orderBy('homeGraph.id', 'DESC')
.limit(limit)
.getRawMany();
return records.map(res => {
return {
label: new Date(parseInt(res.homeGraph_label)).toISOString(),
data: res.homeGraph_data
}
});
}
}
+3
View File
@@ -6,6 +6,9 @@ export class RpcBlockEntity {
@PrimaryColumn() @PrimaryColumn()
blockHeight: number; blockHeight: number;
@Column({ nullable: true })
lockedBy?: string;
@Column({ nullable: true }) @Column({ nullable: true })
data?: string; data?: string;
} }
+8 -4
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { InsertResult, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RpcBlockEntity } from './rpc-block.entity'; import { RpcBlockEntity } from './rpc-block.entity';
@@ -12,14 +12,18 @@ export class RpcBlockService {
) { ) {
} }
public getSavedBlockTemplate(blockHeight: number) { public getBlock(blockHeight: number) {
return this.rpcBlockRepository.findOne({ return this.rpcBlockRepository.findOne({
where: { blockHeight } where: { blockHeight }
}); });
} }
public saveBlock(blockHeight: number, data: string): Promise<InsertResult> { public lockBlock(blockHeight: number, process: string) {
return this.rpcBlockRepository.upsert({ blockHeight, data }, ['blockHeight']); return this.rpcBlockRepository.save({ blockHeight, data: null, lockedBy: process });
}
public saveBlock(blockHeight: number, data: string) {
return this.rpcBlockRepository.update(blockHeight, { data })
} }
public async deleteOldBlocks() { public async deleteOldBlocks() {
@@ -1,12 +1,11 @@
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity() @Entity()
export class TelegramSubscriptionsEntity extends TrackedEntity { export class TelegramSubscriptionsEntity extends TrackedEntity {
@PrimaryGeneratedBigIntColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Index() @Index()
@@ -17,4 +16,4 @@ export class TelegramSubscriptionsEntity extends TrackedEntity {
telegramChatId: number; telegramChatId: number;
} }
@@ -1,5 +0,0 @@
import { PrimaryGeneratedColumn } from 'typeorm';
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
});
+6 -4
View File
@@ -1,12 +1,14 @@
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm'; import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { DateTimeTransformer } from './DateTimeTransformer';
export abstract class TrackedEntity { export abstract class TrackedEntity {
@DeleteDateColumn({ nullable: true }) @DeleteDateColumn({ nullable: true, type: 'datetime', transformer: new DateTimeTransformer() })
public deletedAt?: Date; public deletedAt?: Date;
@CreateDateColumn() @CreateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
public createdAt?: Date public createdAt?: Date
@UpdateDateColumn() @UpdateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
public updatedAt?: Date public updatedAt?: Date
} }
+9 -37
View File
@@ -3,14 +3,11 @@ import { Controller, Get, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { UserAgentReportService } from './ORM/_views/user-agent-report/user-agent-report.service';
import { AddressSettingsService } from './ORM/address-settings/address-settings.service'; import { AddressSettingsService } from './ORM/address-settings/address-settings.service';
import { BlocksService } from './ORM/blocks/blocks.service'; import { BlocksService } from './ORM/blocks/blocks.service';
import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service'; import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service';
import { ClientService } from './ORM/client/client.service'; import { ClientService } from './ORM/client/client.service';
import { HomeGraphService } from './ORM/home-graph/home-graph.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
@Controller() @Controller()
export class AppController { export class AppController {
@@ -23,9 +20,7 @@ export class AppController {
private readonly clientStatisticsService: ClientStatisticsService, private readonly clientStatisticsService: ClientStatisticsService,
private readonly blocksService: BlocksService, private readonly blocksService: BlocksService,
private readonly bitcoinRpcService: BitcoinRpcService, private readonly bitcoinRpcService: BitcoinRpcService,
private readonly homeGraphService: HomeGraphService,
private readonly addressSettingsService: AddressSettingsService, private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService
) { } ) { }
@Get('info') @Get('info')
@@ -41,33 +36,9 @@ export class AppController {
const blockData = await this.blocksService.getFoundBlocks(); const blockData = await this.blocksService.getFoundBlocks();
const userAgents = await this.clientService.getUserAgents();
const highScores = await this.addressSettingsService.getHighScores(); const highScores = await this.addressSettingsService.getHighScores();
const other: {
count: number,
bestDifficulty: number,
totalHashRate: number;
} = {
count: 0,
bestDifficulty: 0,
totalHashRate: 0
};
const userAgents: UserAgentReportView[] = (await this.userAgentReportService.getReport()).reduce((pre, cur, idx, arr) => {
// If less than 10Th/s and less than 100 devices, add to 'other'
if (parseInt(cur.totalHashRate) < 10000000000000 && parseInt(cur.count) < 200) {
other.totalHashRate += parseFloat(cur.totalHashRate);
other.count += parseInt(cur.count);
if (other.bestDifficulty < cur.bestDifficulty) {
other.bestDifficulty = cur.bestDifficulty;
}
} else {
pre.push(cur);
}
return pre;
}, []);
userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() })
const data = { const data = {
blockData, blockData,
userAgents, userAgents,
@@ -75,8 +46,8 @@ export class AppController {
uptime: this.uptime uptime: this.uptime
}; };
//5 min //1 min
await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000); await this.cacheManager.set(CACHE_KEY, data, 1 * 60 * 1000);
return data; return data;
@@ -92,11 +63,11 @@ export class AppController {
return cachedResult; return cachedResult;
} }
const userAgents = await this.userAgentReportService.getReport();
const userAgents = await this.clientService.getUserAgents();
const totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0); const totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0);
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0); const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseInt(userAgent.count), 0);
const blockHeight = this.bitcoinRpcService.miningInfo.blocks; const blockHeight = (await firstValueFrom(this.bitcoinRpcService.newBlock$)).blocks;
const blocksFound = await this.blocksService.getFoundBlocks(); const blocksFound = await this.blocksService.getFoundBlocks();
const data = { const data = {
@@ -115,7 +86,8 @@ export class AppController {
@Get('network') @Get('network')
public async network() { public async network() {
return this.bitcoinRpcService.miningInfo; const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$);
return miningInfo;
} }
@Get('info/chart') @Get('info/chart')
@@ -129,7 +101,7 @@ export class AppController {
return cachedResult; return cachedResult;
} }
const chartData = await this.homeGraphService.getChartDataForSite(); const chartData = await this.clientStatisticsService.getChartDataForSite();
//10 min //10 min
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000); await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000);
+18 -46
View File
@@ -1,7 +1,7 @@
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@@ -9,22 +9,11 @@ import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller'; import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller'; import { ClientController } from './controllers/client/client.controller';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator'; import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator';
import { UniqueNonceIndex } from './ORM/_migrations/UniqueNonceIndex';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
import { AddressSettingsModule } from './ORM/address-settings/address-settings.module'; import { AddressSettingsModule } from './ORM/address-settings/address-settings.module';
import { BlocksEntity } from './ORM/blocks/blocks.entity';
import { BlocksModule } from './ORM/blocks/blocks.module'; import { BlocksModule } from './ORM/blocks/blocks.module';
import { ClientStatisticsEntity } from './ORM/client-statistics/client-statistics.entity';
import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module'; import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module';
import { ClientEntity } from './ORM/client/client.entity';
import { ClientModule } from './ORM/client/client.module'; import { ClientModule } from './ORM/client/client.module';
import { HomeGraphEntity } from './ORM/home-graph/home-graph.entity';
import { HomeGraphModule } from './ORM/home-graph/home-graph.module';
import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module'; import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity';
import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module'; import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
import { AppService } from './services/app.service'; import { AppService } from './services/app.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service';
@@ -35,7 +24,9 @@ import { NotificationService } from './services/notification.service';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service'; import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service'; import { StratumV1Service } from './services/stratum-v1.service';
import { TelegramService } from './services/telegram.service'; import { TelegramService } from './services/telegram.service';
import { ExternalSharesService } from './services/external-shares.service';
import { ExternalShareController } from './controllers/external-share/external-share.controller';
import { ExternalSharesModule } from './ORM/external-shares/external-shares.module';
const ORMModules = [ const ORMModules = [
ClientStatisticsModule, ClientStatisticsModule,
@@ -44,42 +35,21 @@ const ORMModules = [
TelegramSubscriptionsModule, TelegramSubscriptionsModule,
BlocksModule, BlocksModule,
RpcBlocksModule, RpcBlocksModule,
HomeGraphModule, ExternalSharesModule
UserAgentReportModule
] ]
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot(), ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRoot({
useFactory: (configService: ConfigService) => { type: 'sqlite',
return { database: './DB/public-pool.sqlite',
type: 'postgres', synchronize: true,
host: configService.get('DB_HOST'), autoLoadEntities: true,
port: parseInt(configService.get('DB_PORT')), logging: false,
username: configService.get('DB_USERNAME'), enableWAL: true,
password: configService.get('DB_PASSWORD'), busyTimeout: 30 * 1000,
database: configService.get('DB_DATABASE'),
entities: [
ClientEntity,
AddressSettingsEntity,
BlocksEntity,
ClientStatisticsEntity,
RpcBlockEntity,
TelegramSubscriptionsEntity,
HomeGraphEntity,
UserAgentReportView
],
synchronize: configService.get('PRODUCTION') != 'true',
logging: false,
poolSize: 10,
migrations: [
UniqueNonceIndex
]
}
},
imports: [ConfigModule],
inject: [ConfigService]
}), }),
CacheModule.register(), CacheModule.register(),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -89,7 +59,8 @@ const ORMModules = [
controllers: [ controllers: [
AppController, AppController,
ClientController, ClientController,
AddressController AddressController,
ExternalShareController
], ],
providers: [ providers: [
DiscordService, DiscordService,
@@ -101,7 +72,8 @@ const ORMModules = [
BitcoinAddressValidator, BitcoinAddressValidator,
StratumV1JobsService, StratumV1JobsService,
BTCPayService, BTCPayService,
BraiinsService BraiinsService,
ExternalSharesService,
], ],
}) })
export class AppModule { export class AppModule {
@@ -13,7 +13,7 @@ describe('ClientController', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ imports: [
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
type: 'better-sqlite3', type: 'sqlite',
database: ':memory:', database: ':memory:',
synchronize: true, synchronize: true,
autoLoadEntities: true, autoLoadEntities: true,
+2 -2
View File
@@ -30,7 +30,7 @@ export class ClientController {
return { return {
sessionId: worker.sessionId, sessionId: worker.sessionId,
name: worker.clientName, name: worker.clientName,
bestDifficulty: parseFloat(worker.bestDifficulty as any).toFixed(2), bestDifficulty: worker.bestDifficulty.toFixed(2),
hashRate: worker.hashRate, hashRate: worker.hashRate,
startTime: worker.startTime, startTime: worker.startTime,
lastSeen: worker.updatedAt lastSeen: worker.updatedAt
@@ -75,7 +75,7 @@ export class ClientController {
if (worker == null) { if (worker == null) {
return new NotFoundException(); return new NotFoundException();
} }
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id); const chartData = await this.clientStatisticsService.getChartDataForSession(worker.address, worker.clientName, worker.sessionId);
return { return {
sessionId: worker.sessionId, sessionId: worker.sessionId,
@@ -0,0 +1,72 @@
import { Body, Controller, Post, Get, UnauthorizedException, Headers } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../../models/ExternalPoolShare';
import { ExternalSharesService } from '../../ORM/external-shares/external-shares.service';
import { DifficultyUtils } from '../../utils/difficulty.utils';
import * as bitcoinjs from 'bitcoinjs-lib';
@Controller('share')
export class ExternalShareController {
private readonly apiKey: string;
private readonly minimumDifficulty: number;
constructor(
private readonly configService: ConfigService,
private readonly externalSharesService: ExternalSharesService,
) {
this.apiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
this.minimumDifficulty = this.configService.get('MINIMUM_DIFFICULTY') || 1000000000000; // 1T
}
@Get('top-difficulties')
async getTopDifficulties() {
const topDifficulties = await this.externalSharesService.getTopDifficulties();
return topDifficulties;
}
@Post()
async submitExternalShare(
@Body() externalShare: ExternalPoolShare,
@Headers('x-api-key') apiKey: string,
) {
// Only validate API key if one is configured
if (this.apiKey && apiKey !== this.apiKey) {
throw new UnauthorizedException('Invalid API key');
}
// Validate the header hash matches claimed difficulty
const headerBuffer = Buffer.from(externalShare.header, 'hex');
const { submissionDifficulty: difficulty } = DifficultyUtils.calculateDifficulty(headerBuffer);
// Verify the calculated difficulty matches or exceeds minimum difficulty
if (difficulty < this.minimumDifficulty) {
throw new UnauthorizedException('Share difficulty too low');
}
const block = bitcoinjs.Block.fromBuffer(headerBuffer);
const tenMinutesAgo = Math.floor(Date.now() / 1000) - (10 * 60);
if (block.timestamp < tenMinutesAgo) {
throw new UnauthorizedException('Share timestamp too old - must be within last 10 minutes');
}
// Store share submission
await this.externalSharesService.insert({
address: externalShare.address,
clientName: externalShare.worker,
time: new Date().getTime(),
difficulty: difficulty,
userAgent: externalShare.userAgent,
externalPoolName: externalShare.externalPoolName,
header: externalShare.header
});
console.log(`Accepted external share. ${difficulty}`);
return {
success: true,
calculatedDifficulty: difficulty,
};
}
}
+9 -52
View File
@@ -3,35 +3,32 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { useContainer } from 'class-validator'; import { useContainer } from 'class-validator';
import { readFileSync, watch } from 'fs'; import { readFileSync } from 'fs';
import * as path from 'path';
import * as ecc from 'tiny-secp256k1'; import * as ecc from 'tiny-secp256k1';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
if (process.env.API_PORT == null) { if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.'); console.error('It appears your environment is not configured, create and populate an .env file.');
return; return;
} }
const secure = process.env.API_SECURE?.toLowerCase() === 'true'; let options = {};
const currentDirectory = process.cwd(); const secure = process.env.API_SECURE?.toLowerCase() == 'true';
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
let options: any = {};
if (secure) { if (secure) {
const currentDirectory = process.cwd();
options = { options = {
https: { https: {
key: readFileSync(keyPath), key: readFileSync(`${currentDirectory}/secrets/key.pem`),
cert: readFileSync(certPath), cert: readFileSync(`${currentDirectory}/secrets/cert.pem`),
} }
}; };
} }
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options)); const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
app.setGlobalPrefix('api'); app.setGlobalPrefix('api')
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
transform: true, transform: true,
@@ -54,53 +51,13 @@ async function bootstrap() {
app.enableCors(); app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true }); useContainer(app.select(AppModule), { fallbackOnErrors: true });
// Taproot //Taproot
bitcoinjs.initEccLib(ecc); bitcoinjs.initEccLib(ecc);
await app.listen(process.env.API_PORT, '0.0.0.0', (err, address) => { await app.listen(process.env.API_PORT, '0.0.0.0', (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`API listening on ${address}`); console.log(`API listening on ${address}`);
}); });
// --- Live-reload TLS certs/keys when they change on disk ---
if (secure) {
// Fastify's underlying Node https server
const server: any = app.getHttpServer();
// Guard: only HTTPS servers expose setSecureContext
if (typeof server?.setSecureContext === 'function') {
let reloadTimer: NodeJS.Timeout | null = null;
const scheduleReload = () => {
if (reloadTimer) clearTimeout(reloadTimer);
// Debounce multiple fs events during a single write/replace
reloadTimer = setTimeout(() => {
try {
const key = readFileSync(keyPath);
const cert = readFileSync(certPath);
server.setSecureContext({ key, cert });
console.log(`[TLS] Reloaded certificate @ ${new Date().toISOString()}`);
} catch (e) {
console.error('[TLS] Failed to reload certificate:', e);
}
}, 500);
};
// Watch both files; handle 'change' and 'rename' (rename often fired on atomic replace)
try {
watch(keyPath, { persistent: true }, scheduleReload);
watch(certPath, { persistent: true }, scheduleReload);
console.log('[TLS] Watching cert/key for changes');
} catch (e) {
console.error('[TLS] Failed to watch cert/key files:', e);
}
} else {
console.warn('[TLS] Dynamic cert reload not available (non-HTTPS server?)');
}
}
} }
bootstrap(); bootstrap();
+27
View File
@@ -0,0 +1,27 @@
import { IsString, Matches, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBitcoinAddress } from './validators/bitcoin-address.validator';
export class ExternalPoolShare {
@IsString()
@MaxLength(64)
worker: string;
@IsString()
@IsBitcoinAddress()
address: string;
@IsString()
@MaxLength(128)
userAgent: string;
@IsString()
@MaxLength(128)
externalPoolName: string;
@IsString()
@Matches(/^[0-9a-fA-F]+$/, {
message: 'Header must be a valid hex string'
})
header: string;
}
+271 -88
View File
@@ -1,112 +1,295 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1'; import { MockRecording1 } from '../../test/models/MockRecording1';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { MiningJob } from './MiningJob'; import { MiningJob } from './MiningJob';
function hexToAscii(hex: string): string {
let ascii = '';
for (let i = 0; i < hex.length; i += 2) {
const char = String.fromCharCode(parseInt(hex.substr(i, 2), 16));
if (char !== '\0') { // Ignore null characters to make sure string matching works
ascii += char;
}
}
return ascii;
}
function extractPoolIdentifierFromScript(coinbaseHex: string) {
let offset = 8 + 2 + 64 + 8; // Skip Version, Input Count, Previous Transaction Hash, Previous Output Index
// Coinbase Data Length (1 byte / 2 hex characters)
const coinbaseDataLengthHex = coinbaseHex.substr(offset, 2);
const coinbaseDataLength = parseInt(coinbaseDataLengthHex, 16);
offset += 2;
// Coinbase Data (coinbaseDataLength * 2 hex characters)
const coinbaseDataHex = coinbaseHex.substr(offset, coinbaseDataLength * 2);
const coinbaseDataAscii = hexToAscii(coinbaseDataHex);
return coinbaseDataAscii;
}
describe('MiningJob', () => { describe('MiningJob', () => {
let jobTemplate; let moduleRef: TestingModule;
let job: MiningJob; let configService: ConfigService;
let block: bitcoinjs.Block;
let jobTemplate: IJobTemplate;
let payoutInformation: any[];
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); beforeAll(async () => {
const bitcoinRpcService = { moduleRef = await Test.createTestingModule({
newBlockTemplate$: blockTemplate$.asObservable(), providers: [
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height } {
}; provide: ConfigService,
jest.spyOn(console, 'log').mockImplementation(() => undefined); useValue: {
get: jest.fn(() => null)
const jobsService = new StratumV1JobsService(bitcoinRpcService as any); }
jobTemplate = await firstValueFrom(jobsService.newMiningJob$); }
job = new MiningJob( ],
bitcoinjs.networks.testnet, }).compile();
'1', configService = moduleRef.get<ConfigService>(ConfigService);
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
}); });
afterEach(() => { describe('constructor', () => {
jest.restoreAllMocks(); beforeEach(() => {
jest.useRealTimers();
console.warn = jest.fn((message: string) => console.log('WARN:', message));
configService.get = jest.fn(() => null);
payoutInformation = [
{
address: 'tb1qr2ylpdgp9ejpt6v2uxlqrn9penp82rzz2grnns',
percent: 100,
}
];
block = new bitcoinjs.Block();
block.witnessCommit = Buffer.from('000');
block.prevHash = Buffer.from('000');
block.merkleRoot = Buffer.from('000');
block.transactions = [];
const maxTransactions = 7545; // this is only for block weight calculation, other metrics like operations are not considered because they are not affected by the size of the pool identifier
for (let i = 0; i < maxTransactions; i++) {
block.transactions.push(bitcoinjs.Transaction.fromHex("010000000001017405e391018c5e9dc79f324f9607c9c46d21b02f66dabaa870b4add871d6379f01000000171600148d7a0a3461e3891723e5fdf8129caa0075060cffffffffff01fcf60200000000001600148d7a0a3461e3891723e5fdf8129caa0075060cff0248304502210088025cffdaf69d310c6fed11832edd9c19b6a912c132262701ad0e6133227d9202207d73bbf777abd2aeae995d684e6bb1a048c5ac722e16de48bdd35643df7decf001210283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f44800000000"));
}
jobTemplate = {
block: block,
merkle_branch: [],
blockData: {
id: '1',
creation: Date.now(),
coinbasevalue: 0,
networkDifficulty: 0,
height: 0,
clearJobs: false,
}
} as IJobTemplate;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should create a new MiningJob if POOL_IDENTIFIER is not set and use the default', () => {
const expectedMiningIdentifier = 'Public-Pool';
expect(jobTemplate.block).toBeDefined();
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big', () => {
const expectedMiningIdentifier = 'My Mining Pool';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if block is too big with identifier', () => {
const expectedMiningIdentifier = '';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(84);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Block weight exceeds the maximum allowed weight, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big with identifier abcabc', () => {
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
const expectedMiningIdentifier = 'A'.repeat(84); // 84 chars fits after reserving 12 bytes for extranonce space
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if script is too big with identifier', () => {
const expectedMiningIdentifier = '';
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(85);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Pool identifier is too long, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
}); });
it('should split coinbase around 12 bytes of extranonce space', () => { describe('block updates', () => {
const notify = JSON.parse(job.response(jobTemplate)); let job: MiningJob;
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12); beforeEach(async () => {
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`); jest.useFakeTimers();
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
}); configService.get = jest.fn(() => null);
it('should update block nonce, timestamp, version mask, and coinbase script', () => { const miningInfo$ = new BehaviorSubject<IMiningInfo>({
const extraNonce1 = '57a6f098'; blocks: MockRecording1.BLOCK_TEMPLATE.height
const extraNonce2 = 'c708000000000000'; } as IMiningInfo);
const timestamp = parseInt(MockRecording1.TIME, 16); const bitcoinRpcService = {
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot); newBlock$: miningInfo$.asObservable(),
getBlockTemplate: jest.fn().mockResolvedValue(MockRecording1.BLOCK_TEMPLATE)
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const updatedBlock = job.copyAndUpdateBlock( const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate, jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
parseInt('00002000', 16), job = new MiningJob(
parseInt('ed460d91', 16), configService,
extraNonce1, bitcoinjs.networks.testnet,
extraNonce2, '1',
timestamp [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
); jobTemplate
);
});
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16)); afterEach(() => {
expect(updatedBlock.timestamp).toBe(timestamp); jest.restoreAllMocks();
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16)); jest.useRealTimers();
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); });
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => { it('should split coinbase around 12 bytes of extranonce space', () => {
const updatedBlock = job.copyAndUpdateBlock( const notify = JSON.parse(job.response(jobTemplate));
jobTemplate, const coinbasePart1 = notify.params[2];
0, const coinbasePart2 = notify.params[3];
parseInt('ed460d91', 16), const extraNonce1 = '57a6f098';
'57a6f098', const extraNonce2 = 'c708000000000000';
'c708000000000000', const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version); expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
}); expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should build the same header as the full block update path', () => { it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const versionMask = parseInt('00002000', 16); const extraNonce1 = '57a6f098';
const nonce = parseInt('ed460d91', 16); const extraNonce2 = 'c708000000000000';
const extraNonce1 = '57a6f098'; const timestamp = parseInt(MockRecording1.TIME, 16);
const extraNonce2 = 'c708000000000000'; const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock( const updatedBlock = job.copyAndUpdateBlock(
jobTemplate, jobTemplate,
versionMask, parseInt('00002000', 16),
nonce, parseInt('ed460d91', 16),
extraNonce1, extraNonce1,
extraNonce2, extraNonce2,
timestamp timestamp
); );
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true); expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(updatedBlock.timestamp).toBe(timestamp);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
it('should build the same header as the full block update path', () => {
const versionMask = parseInt('00002000', 16);
const nonce = parseInt('ed460d91', 16);
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
}); });
}); });
+23 -5
View File
@@ -4,9 +4,11 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service'; import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod'; import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify'; import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { ConfigService } from '@nestjs/config';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants'; import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
const MAX_BLOCK_WEIGHT = 4000000;
const MAX_SCRIPT_SIZE = 100; // https://github.com/bitcoin/bitcoin/blob/ffdc3d6060f6e65e69cf115a13b83e6eb4a0a0a8/src/consensus/tx_check.cpp#L49
interface AddressObject { interface AddressObject {
address: string; address: string;
percent: number; percent: number;
@@ -25,6 +27,7 @@ export class MiningJob {
public creation: number; public creation: number;
constructor( constructor(
configService: ConfigService,
private network: bitcoinjs.networks.Network, private network: bitcoinjs.networks.Network,
public jobId: string, public jobId: string,
payoutInformation: AddressObject[], payoutInformation: AddressObject[],
@@ -45,7 +48,9 @@ export class MiningJob {
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value) // 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
// 39th byte onwards: Optional data with no consensus meaning // 39th byte onwards: Optional data with no consensus meaning
const extra = Buffer.from('Public-Pool'); // Initial pool identifier
let poolIdentifier = configService.get('POOL_IDENTIFIER') || 'Public-Pool';
let extra = Buffer.from(poolIdentifier);
// Encode the block height // Encode the block height
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki // https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
@@ -54,14 +59,27 @@ export class MiningJob {
// Get the length of the block height encoding // Get the length of the block height encoding
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]); const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// generate padding and take length of encode blockHeight into account // Generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0) const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// build the script // Build the script
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]) let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]);
// Check if the pool identifier is too long
if (script.length > MAX_SCRIPT_SIZE) {
console.warn('Pool identifier is too long, removing the pool identifier');
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
}
this.coinbaseTransaction.ins[0].script = script;
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0); this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
// Check if the pool identifier is too long
if ((this.coinbaseTransaction.weight() + jobTemplate.block.weight()) > MAX_BLOCK_WEIGHT) {
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
this.coinbaseTransaction.ins[0].script = script;
}
// get the non-witness coinbase tx // get the non-witness coinbase tx
//@ts-ignore //@ts-ignore
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex'); const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
+34 -13
View File
@@ -20,6 +20,7 @@ import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { MiningJob } from './MiningJob'; import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client'; import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage'; import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -48,6 +49,7 @@ describe('StratumV1Client', () => {
let clientStatisticsService: ClientStatisticsService; let clientStatisticsService: ClientStatisticsService;
let notificationService: NotificationService; let notificationService: NotificationService;
let blocksService: BlocksService; let blocksService: BlocksService;
let externalSharesService;
let configService: ConfigService; let configService: ConfigService;
let client: StratumV1Client; let client: StratumV1Client;
@@ -58,7 +60,9 @@ describe('StratumV1Client', () => {
let consoleErrorSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
let moduleRef: TestingModule; let moduleRef: TestingModule;
@@ -66,7 +70,7 @@ describe('StratumV1Client', () => {
moduleRef = await Test.createTestingModule({ moduleRef = await Test.createTestingModule({
imports: [ imports: [
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
type: 'better-sqlite3', type: 'sqlite',
database: ':memory:', database: ':memory:',
synchronize: true, synchronize: true,
autoLoadEntities: true, autoLoadEntities: true,
@@ -109,6 +113,7 @@ describe('StratumV1Client', () => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
clientService = moduleRef.get<ClientService>(ClientService); clientService = moduleRef.get<ClientService>(ClientService);
clientService.insertQueue = [];
const dataSource = moduleRef.get<DataSource>(DataSource); const dataSource = moduleRef.get<DataSource>(DataSource);
@@ -132,11 +137,9 @@ describe('StratumV1Client', () => {
(StratumV1Client as any).blockedUserAgentLogState.clear(); (StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear(); (StratumV1Client as any).validationErrorLogState.clear();
bitcoinRpcService = { bitcoinRpcService = new MockBitcoinRpcService(configService,null);
newBlockTemplate$: newBlockEmitter.asObservable(), jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }, bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable();
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
} as any;
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService); stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
@@ -161,6 +164,9 @@ describe('StratumV1Client', () => {
blocksService = { blocksService = {
save: jest.fn().mockResolvedValue(undefined) save: jest.fn().mockResolvedValue(undefined)
} as any; } as any;
externalSharesService = {
submitShare: jest.fn().mockResolvedValue(undefined)
};
client = new StratumV1Client( client = new StratumV1Client(
@@ -172,7 +178,8 @@ describe('StratumV1Client', () => {
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
addressSettings addressSettings,
externalSharesService
); );
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE; client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
@@ -267,7 +274,8 @@ describe('StratumV1Client', () => {
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService) moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
); );
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`)); socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
@@ -328,6 +336,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount(); const clientCount = await clientService.connectedClientCount();
expect(clientCount).toBe(1); expect(clientCount).toBe(1);
@@ -357,10 +367,16 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
const notifyCall = (client as any).write.mock.calls
.map(call => call[0])
.find((message: string) => message.includes('"method":"mining.notify"'));
const notify = JSON.parse(notifyCall);
expect(notify.params[0]).toBe('1');
expect(notify.params[5]).toBe('20000000');
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`); expect(notify.params[6]).toBe('192495f8');
expect(notify.params[7]).toBe(MockRecording1.TIME);
expect(notify.params[8]).toBe(true);
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
@@ -384,6 +400,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers(); jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -434,6 +452,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers(); jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -550,7 +570,8 @@ describe('StratumV1Client', () => {
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService) moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
); );
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE); jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
+76 -58
View File
@@ -27,6 +27,7 @@ import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants'; import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage'; import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { ExternalSharesService } from '../services/external-shares.service';
const TRUE_DIFF_ONE = 2.695953529101131e67; const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000; const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
@@ -36,7 +37,7 @@ 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 }>();
public clientSubscription: SubscriptionMessage; private clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage; private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage; private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty; private clientSuggestedDifficulty: SuggestDifficulty;
@@ -48,16 +49,17 @@ export class StratumV1Client {
private usedSuggestedDifficulty = false; private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 100000; private sessionDifficulty: number = 100000;
private clientEntity: ClientEntity; private entity: ClientEntity;
private creatingEntity: Promise<void>; private creatingEntity: Promise<void>;
public extraNonceAndSessionId: string; public extraNonceAndSessionId: string;
public sessionStart: Date; public sessionStart: Date;
//public noFee: boolean; public noFee: boolean;
//public hashRate: number = 0; public hashRate: number = 0;
private buffer: string = ''; private buffer: string = '';
private connectionClosed = false; private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private miningSubmissionHashes = new Set<string>() private miningSubmissionHashes = new Set<string>()
@@ -70,7 +72,8 @@ export class StratumV1Client {
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService, private readonly blocksService: BlocksService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService private readonly addressSettingsService: AddressSettingsService,
private readonly externalSharesService: ExternalSharesService
) { ) {
this.socket.on('data', (data: Buffer) => { this.socket.on('data', (data: Buffer) => {
@@ -98,8 +101,8 @@ export class StratumV1Client {
public async destroy() { public async destroy() {
if (this.clientEntity?.id) { if (this.extraNonceAndSessionId) {
await this.clientService.delete(this.clientEntity.id); await this.clientService.delete(this.extraNonceAndSessionId);
} }
if (this.stratumSubscription != null) { if (this.stratumSubscription != null) {
@@ -157,7 +160,7 @@ export class StratumV1Client {
this.sessionStart = new Date(); this.sessionStart = new Date();
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService); this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService);
this.extraNonceAndSessionId = this.getRandomHexString(); this.extraNonceAndSessionId = this.getRandomHexString();
//console.log(`New client ID: : ${this.extraNonceAndSessionId}, ${this.socket.remoteAddress}:${this.socket.remotePort}`); console.log(`New client ID: : ${this.extraNonceAndSessionId}, ${this.socket.remoteAddress}:${this.socket.remotePort}`);
} }
this.clientSubscription = subscriptionMessage; this.clientSubscription = subscriptionMessage;
@@ -204,7 +207,7 @@ export class StratumV1Client {
} }
} else { } else {
console.log('Configuration validation error'); console.error('Configuration validation error');
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
configurationMessage.id, configurationMessage.id,
eStratumErrorCode.OtherUnknown, eStratumErrorCode.OtherUnknown,
@@ -243,12 +246,13 @@ export class StratumV1Client {
return; return;
} }
} else { } else {
console.error('Authorization validation error');
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
authorizationMessage.id, authorizationMessage.id,
eStratumErrorCode.OtherUnknown, eStratumErrorCode.OtherUnknown,
'Authorization validation error', 'Authorization validation error',
errors).response(); errors).response();
//console.log(err); console.error(err);
const success = await this.write(err); const success = await this.write(err);
if (!success) { if (!success) {
return; return;
@@ -301,7 +305,7 @@ export class StratumV1Client {
case eRequestMethod.SUBMIT: { case eRequestMethod.SUBMIT: {
if (this.stratumInitialized == false) { if (this.stratumInitialized == false) {
//console.log('Submit before initalized'); console.log('Submit before initalized');
await this.socket.end(); await this.socket.end();
return; return;
} }
@@ -358,7 +362,7 @@ export class StratumV1Client {
&& this.clientAuthorization != null && this.clientAuthorization != null
&& this.stratumInitialized == false) { && this.stratumInitialized == false) {
this.initStratum(); await this.initStratum();
} }
} }
@@ -405,40 +409,29 @@ export class StratumV1Client {
}, 60 * 1000) }, 60 * 1000)
); );
// this.backgroundWork.push(
// setInterval(async () => {
// await this.statistics.saveShares(this.clientEntity);
// }, 60 * 1000)
// );
} }
private async sendNewMiningJob(jobTemplate: IJobTemplate) { private async sendNewMiningJob(jobTemplate: IJobTemplate) {
let payoutInformation= [ let payoutInformation;
{ address: this.clientAuthorization.address, percent: 100 } const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
]; //50Th/s
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS'); this.noFee = false;
// //50Th/s if (this.entity) {
// this.noFee = false; this.hashRate = this.statistics.hashRate;
// if (this.clientEntity) { this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
// this.hashRate = await this.clientStatisticsService.getHashRateForSession(this.clientEntity.id); }
// // 250Gh/s if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
// if(this.hashRate < 250000000000){ payoutInformation = [
// this.statistics.targetSubmitShareEveryNSeconds = 10; { address: this.clientAuthorization.address, percent: 100 }
// } ];
// this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
// }
// if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
// payoutInformation = [
// { address: this.clientAuthorization.address, percent: 100 }
// ];
// } else { } else {
// payoutInformation = [ payoutInformation = [
// { address: devFeeAddress, percent: 1.5 }, { address: devFeeAddress, percent: 1.5 },
// { address: this.clientAuthorization.address, percent: 98.5 } { address: this.clientAuthorization.address, percent: 98.5 }
// ]; ];
// } }
const networkConfig = this.configService.get('NETWORK'); const networkConfig = this.configService.get('NETWORK');
let network; let network;
@@ -454,6 +447,7 @@ export class StratumV1Client {
} }
const job = new MiningJob( const job = new MiningJob(
this.configService,
network, network,
this.stratumV1JobsService.getNextId(), this.stratumV1JobsService.getNextId(),
payoutInformation, payoutInformation,
@@ -467,6 +461,7 @@ export class StratumV1Client {
if (!success) { if (!success) {
return; return;
} }
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`) //console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
@@ -475,13 +470,13 @@ export class StratumV1Client {
private async ensureClientEntity() { private async ensureClientEntity() {
if (this.clientEntity != null) { if (this.entity != null) {
return; return;
} }
if (this.creatingEntity == null) { if (this.creatingEntity == null) {
this.creatingEntity = (async () => { this.creatingEntity = (async () => {
this.clientEntity = await this.clientService.insert({ this.entity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId, sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address, address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker, clientName: this.clientAuthorization.worker,
@@ -512,7 +507,6 @@ export class StratumV1Client {
} }
return false; return false;
} }
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId); const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
if (jobTemplate == null) { if (jobTemplate == null) {
@@ -600,25 +594,37 @@ export class StratumV1Client {
} }
await this.ensureClientEntity(); await this.ensureClientEntity();
try { try {
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty); await this.statistics.addShares(this.entity, this.sessionDifficulty);
const now = new Date(); const now = new Date();
// only update every minute // only update every minute
//if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) { if (this.entity.updatedAt == null || now.getTime() - this.entity.updatedAt.getTime() > 1000 * 60) {
this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now); await this.clientService.heartbeat(this.entity.address, this.entity.clientName, this.entity.sessionId, this.hashRate, now);
this.clientEntity.updatedAt = now; this.entity.updatedAt = now;
//} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
if (submissionDifficulty > this.clientEntity.bestDifficulty) { if (submissionDifficulty > this.entity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty); await this.clientService.updateBestDifficultyIfHigher(this.extraNonceAndSessionId, submissionDifficulty);
this.clientEntity.bestDifficulty = submissionDifficulty; this.entity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent); await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.entity.userAgent);
} }
const externalShareSubmissionEnabled: boolean = this.configService.get('EXTERNAL_SHARE_SUBMISSION_ENABLED')?.toLowerCase() == 'true';
const minimumDifficulty: number = parseFloat(this.configService.get('MINIMUM_DIFFICULTY')) || 1000000000000.0; // 1T
if (externalShareSubmissionEnabled && submissionDifficulty >= minimumDifficulty) {
// Submit share to API if enabled
this.externalSharesService.submitShare({
worker: this.clientAuthorization.worker,
address: this.clientAuthorization.address,
userAgent: this.clientSubscription.userAgent,
header: header.toString('hex'),
externalPoolName: this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
});
}
} else { } else {
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
@@ -659,9 +665,21 @@ export class StratumV1Client {
await this.socket.write(data); await this.socket.write(data);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
// we need to clear the jobs so that the difficulty set takes effect. Otherwise the different miner implementations can cause issues const nextTimestamp = Math.max(
jobTemplate.blockData.clearJobs = true; jobTemplate.block.timestamp,
await this.sendNewMiningJob(jobTemplate); Math.floor(Date.now() / 1000),
(this.lastSentMiningJobTimestamp ?? 0) + 1
);
// We need to clear jobs so the difficulty takes effect, but avoid mutating or
// re-sending the shared cached template with byte-identical work.
const refreshedJobTemplate: IJobTemplate = {
...jobTemplate,
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
timestamp: nextTimestamp
}),
blockData: { ...jobTemplate.blockData, clearJobs: true }
};
await this.sendNewMiningJob(refreshedJobTemplate);
} }
} }
@@ -787,7 +805,7 @@ export class StratumV1Client {
return true; return true;
} else { } else {
//console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`); console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`);
this.destroy(); this.destroy();
if (!this.socket.destroyed) { if (!this.socket.destroyed) {
this.socket.destroy(); this.socket.destroy();
@@ -801,7 +819,7 @@ export class StratumV1Client {
} else if (!this.socket.destroyed) { } else if (!this.socket.destroyed) {
this.socket.destroy(); this.socket.destroy();
} }
//console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error); console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error);
return false; return false;
} }
} }
+15 -12
View File
@@ -10,7 +10,7 @@ describe('StratumV1ClientStatistics', () => {
let clientStatisticsService: { let clientStatisticsService: {
insert: jest.Mock, insert: jest.Mock,
updateBulkAsync: jest.Mock update: jest.Mock
}; };
let statistics: StratumV1ClientStatistics; let statistics: StratumV1ClientStatistics;
@@ -19,7 +19,7 @@ describe('StratumV1ClientStatistics', () => {
jest.setSystemTime(new Date('2026-05-06T12:00:00Z')); jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
clientStatisticsService = { clientStatisticsService = {
insert: jest.fn().mockResolvedValue(undefined), insert: jest.fn().mockResolvedValue(undefined),
updateBulkAsync: jest.fn().mockResolvedValue(undefined) update: jest.fn().mockResolvedValue(undefined)
}; };
statistics = new StratumV1ClientStatistics(clientStatisticsService as any); statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
}); });
@@ -33,7 +33,6 @@ describe('StratumV1ClientStatistics', () => {
expect(clientStatisticsService.insert).toHaveBeenCalledWith({ expect(clientStatisticsService.insert).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(), time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 64, shares: 64,
acceptedCount: 1, acceptedCount: 1,
address: client.address, address: client.address,
@@ -44,13 +43,16 @@ describe('StratumV1ClientStatistics', () => {
it('should update the current share bucket for additional shares', async () => { it('should update the current share bucket for additional shares', async () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
await statistics.addShares(client, 32); await statistics.addShares(client, 32);
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ expect(clientStatisticsService.update).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(), time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 96, shares: 96,
acceptedCount: 2 acceptedCount: 2,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
}); });
}); });
@@ -60,15 +62,16 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 32); await statistics.addShares(client, 32);
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ expect(clientStatisticsService.update).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(), time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 64, shares: 64,
acceptedCount: 1 acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
}); });
expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({ expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({
time: new Date('2026-05-06T12:10:00Z').getTime(), time: new Date('2026-05-06T12:10:00Z').getTime(),
clientId: client.id,
shares: 32, shares: 32,
acceptedCount: 1, acceptedCount: 1,
address: client.address, address: client.address,
@@ -93,7 +96,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(64)).toBe(2048); expect(statistics.getSuggestedDifficulty(64)).toBe(512);
}); });
it('should decrease difficulty for slow submissions', async () => { it('should decrease difficulty for slow submissions', async () => {
@@ -102,6 +105,6 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(128)).toBe(16); expect(statistics.getSuggestedDifficulty(128)).toBe(4);
}); });
}); });
+42 -26
View File
@@ -2,29 +2,33 @@ import { ClientStatisticsService } from '../ORM/client-statistics/client-statist
import { ClientEntity } from '../ORM/client/client.entity'; import { ClientEntity } from '../ORM/client/client.entity';
const CACHE_SIZE = 30; const CACHE_SIZE = 30;
const MIN_DIFF = 0.001; const TARGET_SUBMISSION_PER_SECOND = 10;
const MIN_DIFF = 0.00001;
export class StratumV1ClientStatistics { export class StratumV1ClientStatistics {
public targetSubmitShareEveryNSeconds: number = 30;
public hashRate = 0;
private shares: number = 0; private shares: number = 0;
private acceptedCount: number = 0; private acceptedCount: number = 0;
private submissionCacheStart: Date; private submissionCacheStart: Date;
private submissionCache: { time: Date, difficulty: number }[] = []; private submissionCache: { time: Date, difficulty: number }[] = [];
private submissionCacheDifficultySum = 0;
private currentTimeSlot: number = null; private currentTimeSlot: number = null;
private lastSave: number = null;
public hashRate = 0;
private previousTimeSlotTime: Date;
private currentTimeSlotTime: Date;
private previousShares: number = 0;
constructor( constructor(
private readonly clientStatisticsService: ClientStatisticsService, private readonly clientStatisticsService: ClientStatisticsService
) { ) {
this.submissionCacheStart = new Date(); this.submissionCacheStart = new Date();
} }
// We don't want to save them here because it can be DB intensive, instead do it every once in // We don't want to save them here because it can be DB intensive, instead do it every once in
// awhile with saveShares() // awhile with saveShares()
public async addShares(client: ClientEntity, targetDifficulty: number) { public async addShares(client: ClientEntity, targetDifficulty: number) {
@@ -35,76 +39,88 @@ export class StratumV1ClientStatistics {
var timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime(); var timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime();
if (this.submissionCache.length > CACHE_SIZE) { if (this.submissionCache.length > CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift(); this.submissionCache.shift();
} }
this.submissionCache.push({ this.submissionCache.push({
time: date, time: date,
difficulty: targetDifficulty, difficulty: targetDifficulty,
}); });
this.submissionCacheDifficultySum += targetDifficulty;
if (this.currentTimeSlot == null) { if (this.currentTimeSlot == null) {
// First record, insert it // First record, insert it
this.previousTimeSlotTime = new Date();
this.currentTimeSlotTime = new Date();
this.currentTimeSlot = timeSlot; this.currentTimeSlot = timeSlot;
this.shares += targetDifficulty; this.shares += targetDifficulty;
this.acceptedCount++; this.acceptedCount++;
await this.clientStatisticsService.insert({ await this.clientStatisticsService.insert({
time: this.currentTimeSlot, time: this.currentTimeSlot,
clientId: client.id,
shares: this.shares, shares: this.shares,
acceptedCount: this.acceptedCount, acceptedCount: this.acceptedCount,
address: client.address, address: client.address,
clientName: client.clientName, clientName: client.clientName,
sessionId: client.sessionId sessionId: client.sessionId
}); });
this.lastSave = new Date().getTime();
} else if (this.currentTimeSlot != timeSlot) { } else if (this.currentTimeSlot != timeSlot) {
// Transitioning to a new time slot, // Transitioning to a new time slot,
// First update the old time slot with the latest data // First update the old time slot with the latest data
this.clientStatisticsService.updateBulkAsync({ await this.clientStatisticsService.update({
time: this.currentTimeSlot, time: this.currentTimeSlot,
clientId: client.id,
shares: this.shares, shares: this.shares,
acceptedCount: this.acceptedCount, acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
}); });
this.previousShares = this.shares;
this.previousTimeSlotTime = this.currentTimeSlotTime;
this.currentTimeSlotTime = new Date();
// Set the new time slot and add incoming shares then insert it // Set the new time slot and add incoming shares then insert it
this.currentTimeSlot = timeSlot; this.currentTimeSlot = timeSlot;
this.shares = targetDifficulty; this.shares = targetDifficulty;
this.acceptedCount = 1 this.acceptedCount = 1
await this.clientStatisticsService.insert({ await this.clientStatisticsService.insert({
time: this.currentTimeSlot, time: this.currentTimeSlot,
clientId: client.id,
shares: this.shares, shares: this.shares,
acceptedCount: this.acceptedCount, acceptedCount: this.acceptedCount,
address: client.address, address: client.address,
clientName: client.clientName, clientName: client.clientName,
sessionId: client.sessionId sessionId: client.sessionId
}); });
this.lastSave = new Date().getTime();
} else if ((date.getTime() - 60 * 1000) > this.lastSave) {
// If we haven't saved for a minute, update the table
this.shares += targetDifficulty;
this.acceptedCount++;
await this.clientStatisticsService.update({
time: this.currentTimeSlot,
shares: this.shares,
acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.lastSave = new Date().getTime();
} else { } else {
// Accept the shares if none of the prior conditions are met, // Accept the shares if none of the prior conditions are met,
// saving to memory for storing later // saving to memory for storing later
this.shares += targetDifficulty; this.shares += targetDifficulty;
this.acceptedCount++; this.acceptedCount++;
this.clientStatisticsService.updateBulkAsync({ if(this.shares > 0) {
time: this.currentTimeSlot, const time = new Date().getTime() - this.previousTimeSlotTime.getTime();
clientId: client.id, this.hashRate = ((this.previousShares + this.shares) * 4294967296) / (time / 1000);
shares: this.shares, }
acceptedCount: this.acceptedCount,
});
} }
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
if(time > 60000 && this.submissionCache.length > 2) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
}
} }
public getSuggestedDifficulty(clientDifficulty: number) { public getSuggestedDifficulty(clientDifficulty: number) {
// miner hasn't submitted shares in one minute // miner hasn't submitted shares in one minute
if (this.submissionCache.length < 5) { if (this.submissionCache.length < 5) {
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 5000 > 60) { if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
return this.nearestPowerOfTwo(clientDifficulty / 6); return this.nearestPowerOfTwo(clientDifficulty / 6);
} else { } else {
return null; return null;
@@ -119,7 +135,7 @@ export class StratumV1ClientStatistics {
const difficultyPerSecond = sum / diffSeconds; const difficultyPerSecond = sum / diffSeconds;
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds; const targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND;
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) { if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty) return this.nearestPowerOfTwo(targetDifficulty)
@@ -25,6 +25,15 @@ describe('AuthorizationMessage', () => {
expect(message.startingDiff).toBeNull(); expect(message.startingDiff).toBeNull();
}); });
it('should ignore malformed starting difficulty hints', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=abc"]}')
);
expect(message.startingDiff).toBeNull();
});
it('should build successful authorization responses', () => { it('should build successful authorization responses', () => {
const message = plainToInstance( const message = plainToInstance(
AuthorizationMessage, AuthorizationMessage,
@@ -24,27 +24,23 @@ export class AuthorizationMessage extends StratumBaseMessage {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
@Transform(({ value, key, obj, type }) => { @Transform(({ value, key, obj, type }) => {
const workerName = obj.params[0].split('.')[1]; return obj.params[0].split('.')[1] == null ? 'worker' : obj.params[0].split('.')[1];
if (workerName == null) {
return 'worker';
}
return workerName;
}) })
public worker: string; public worker: string;
@Expose() @Expose()
@IsNumber() @IsNumber()
@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=')) { const difficultyMatch = password?.match(/(?:^|,)d=(\d+)(?:,|$)/);
return parseInt(password.split('d=')[1]); if (difficultyMatch != null) {
const startingDiff = Number.parseInt(difficultyMatch[1], 10);
return Number.isFinite(startingDiff) ? startingDiff : null;
} }
return null; return null;
}) })
@IsOptional() @IsOptional()
public startingDiff: number; public startingDiff?: number;
@Expose() @Expose()
@IsString() @IsString()
@@ -69,4 +65,4 @@ export class AuthorizationMessage extends StratumBaseMessage {
result: true result: true
}; };
} }
} }
+15 -61
View File
@@ -1,10 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../ORM/client/client.service'; import { ClientService } from '../ORM/client/client.service';
import { HomeGraphService } from '../ORM/home-graph/home-graph.service';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service'; import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
@Injectable() @Injectable()
@@ -13,16 +11,26 @@ export class AppService implements OnModuleInit {
constructor( constructor(
private readonly clientStatisticsService: ClientStatisticsService, private readonly clientStatisticsService: ClientStatisticsService,
private readonly clientService: ClientService, private readonly clientService: ClientService,
private readonly rpcBlockService: RpcBlockService,
private readonly homeGraphService: HomeGraphService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly userAgentReportService: UserAgentReportService private readonly rpcBlockService: RpcBlockService,
) { ) {
} }
async onModuleInit() { async onModuleInit() {
if (process.env.MASTER == 'true') { // if (process.env.NODE_APP_INSTANCE == '0') {
// await this.dataSource.query(`VACUUM;`);
// }
//https://phiresky.github.io/blog/2020/sqlite-performance-tuning/
// //500 MB DB cache
// await this.dataSource.query(`PRAGMA cache_size = -500000;`);
//Normal is still completely corruption safe in WAL mode, and means only WAL checkpoints have to wait for FSYNC.
await this.dataSource.query(`PRAGMA synchronous = off;`);
// //6Gb
// await this.dataSource.query(`PRAGMA mmap_size = 6000000000;`);
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
setInterval(async () => { setInterval(async () => {
await this.deleteOldStatistics(); await this.deleteOldStatistics();
@@ -30,10 +38,7 @@ export class AppService implements OnModuleInit {
setInterval(async () => { setInterval(async () => {
console.log('Killing dead clients'); console.log('Killing dead clients');
while (await this.clientService.killDeadClients()) { await this.clientService.killDeadClients();
}
console.log('Finished killing clients');
}, 1000 * 60 * 5); }, 1000 * 60 * 5);
setInterval(async () => { setInterval(async () => {
@@ -41,29 +46,10 @@ export class AppService implements OnModuleInit {
await this.rpcBlockService.deleteOldBlocks(); await this.rpcBlockService.deleteOldBlocks();
}, 1000 * 60 * 60 * 24); }, 1000 * 60 * 60 * 24);
setInterval(async () => {
await this.updateChart();
}, 1000 * 60 * 10);
setInterval(async () => {
console.log('Refreshing user agent report view')
await this.userAgentReportService.refreshReport();
console.log('Finished Refreshing user agent report view')
}, 1000 * 60 * 5);
} }
setInterval(async () => {
//console.log('Bulk update client stats');
await this.clientStatisticsService.doBulkAsyncUpdate();
}, 1000 * 30);
setInterval(async () => {
//console.log('Bulk update client stats');
await this.clientService.doBulkHeartbeatUpdate();
}, 1000 * 30);
} }
private async deleteOldStatistics() { private async deleteOldStatistics() {
@@ -77,36 +63,4 @@ export class AppService implements OnModuleInit {
} }
private async updateChart() {
console.log('Updating Chart');
const latestGraphUpdate = await this.homeGraphService.getLatestTime();
const data = await this.dataSource.query(`
SELECT
time AS label,
ROUND(((SUM(shares) * 4294967296) / 600)) AS data
FROM
client_statistics_entity AS entry
WHERE
entry.time > ${latestGraphUpdate.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`)
console.log(`Fetched ${data.length} rows`);
if (data.length < 2) {
return;
}
const result = data.slice(0, data.length - 1);
this.homeGraphService.save(result);
}
} }
+81 -57
View File
@@ -1,45 +1,45 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs'; import * as fs from 'node:fs';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service'; import { BehaviorSubject, filter, shareReplay } from 'rxjs';
import * as zmq from 'zeromq'; import * as zmq from 'zeromq';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo'; import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import * as PGPubsub from 'pg-pubsub';
@Injectable() @Injectable()
export class BitcoinRpcService implements OnModuleInit { export class BitcoinRpcService implements OnModuleInit {
private blockHeight = 0;
private client: AxiosInstance; private client: AxiosInstance;
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
private pubsubInstance: PGPubsub;
private resetTemplateInterval$ = new Subject<void>();
private rpcRequestId = 0; private rpcRequestId = 0;
private _newBlock$: BehaviorSubject<IMiningInfo> = new BehaviorSubject(undefined);
public miningInfo: IMiningInfo; public newBlock$ = this._newBlock$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private rpcBlockService: RpcBlockService private rpcBlockService: RpcBlockService
) { ) {
} }
async onModuleInit() { async onModuleInit() {
this.pubsubInstance = new PGPubsub('postgres://' + this.configService.get('DB_USERNAME') + ':' + this.configService.get('DB_PASSWORD') + '@' + this.configService.get('DB_HOST') + ':' + this.configService.get('DB_PORT') + '/' + this.configService.get('DB_DATABASE'))
const url = this.configService.get('BITCOIN_RPC_URL'); const url = this.configService.get('BITCOIN_RPC_URL');
const user = this.configService.get('BITCOIN_RPC_USER'); let user = this.configService.get('BITCOIN_RPC_USER');
const pass = this.configService.get('BITCOIN_RPC_PASSWORD'); let pass = this.configService.get('BITCOIN_RPC_PASSWORD');
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT')); const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT')); const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
if (cookiefile != undefined && cookiefile != '') {
const cookie = fs.readFileSync(cookiefile).toString().trim().split(':');
user = cookie[0];
pass = cookie[1];
}
const baseURL = this.buildRpcUrl(url, port); const baseURL = this.buildRpcUrl(url, port);
this.client = axios.create({ this.client = axios.create({
baseURL, baseURL,
@@ -50,72 +50,101 @@ export class BitcoinRpcService implements OnModuleInit {
} }
}); });
this.callRpc('getrpcinfo').then((res) => { this.callRpc('getrpcinfo').then(() => {
console.log('Bitcoin RPC connected'); console.log('Bitcoin RPC connected');
}, () => { }, () => {
console.error('Could not reach RPC host'); console.error('Could not reach RPC host');
}); });
this.miningInfo = await this.getMiningInfo();
console.log(`MASTER? ${process.env.MASTER}`) if (this.configService.get('BITCOIN_ZMQ_HOST')) {
if (process.env.MASTER != 'true') {
this.pubsubInstance.addChannel('miningInfo', async (miningInfo: IMiningInfo) => {
//console.log('PG Sub. new template');
this.miningInfo = miningInfo;
const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(miningInfo.blocks);
this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data));
});
} else {
console.log('Using ZMQ'); console.log('Using ZMQ');
const sock = new zmq.Subscriber; const sock = new zmq.Subscriber;
sock.connectTimeout = 1000; sock.connectTimeout = 1000;
sock.events.on('connect', () => { sock.events.on('connect', () => {
console.log('ZMQ Connected'); console.log('ZMQ Connected');
}); });
sock.events.on('connect:retry', () => { sock.events.on('connect:retry', () => {
console.error('ZMQ Unable to connect, Retrying'); console.log('ZMQ Unable to connect, Retrying');
}); });
sock.connect(this.configService.get('BITCOIN_ZMQ_HOST')); sock.connect(this.configService.get('BITCOIN_ZMQ_HOST'));
sock.subscribe('rawblock'); sock.subscribe('rawblock');
// Don't await this, otherwise it will block the rest of the program // Don't await this, otherwise it will block the rest of the program
this.listenForNewBlocks(sock); this.listenForNewBlocks(sock);
await this.pollMiningInfo();
// Between new blocks we want refresh jobs with the latest transactions } else {
this.resetTemplateInterval$.pipe( setInterval(this.pollMiningInfo.bind(this), 500);
startWith(null),
switchMap(() =>interval(60000))
).subscribe(async () =>{
await this.getAndBroadcastLatestTemplate();
});
} }
} }
private async listenForNewBlocks(sock: zmq.Subscriber) { private async listenForNewBlocks(sock: zmq.Subscriber) {
for await (const [topic, msg] of sock) { for await (const [topic, msg] of sock) {
console.log("New Block"); console.log("New Block");
this.miningInfo = await this.getMiningInfo(); await this.pollMiningInfo();
await this.getAndBroadcastLatestTemplate();
//Reset the block update interval
this.resetTemplateInterval$.next();
} }
} }
public async getAndBroadcastLatestTemplate() { public async pollMiningInfo() {
const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks); const miningInfo = await this.getMiningInfo();
this._newBlockTemplate$.next(blockTemplate); if (miningInfo != null && miningInfo.blocks > this.blockHeight) {
await this.pubsubInstance.publish('miningInfo', this.miningInfo); console.log("block height change");
this._newBlock$.next(miningInfo);
this.blockHeight = miningInfo.blocks;
}
}
private async waitForBlock(blockHeight: number): Promise<IBlockTemplate> {
while (true) {
await new Promise(r => setTimeout(r, 100));
const block = await this.rpcBlockService.getBlock(blockHeight);
if (block != null && block.data != null) {
console.log(`promise loop resolved, block height ${blockHeight}`);
return Promise.resolve(JSON.parse(block.data));
}
console.log(`promise loop, block height ${blockHeight}`);
}
}
public async getBlockTemplate(blockHeight: number): Promise<IBlockTemplate> {
let result: IBlockTemplate;
try {
const block = await this.rpcBlockService.getBlock(blockHeight);
const completeBlock = block?.data != null;
// If the block has already been loaded, and the same instance is fetching the template again, we just need to refresh it.
if (completeBlock && block.lockedBy == process.env.NODE_APP_INSTANCE) {
result = await this.loadBlockTemplate(blockHeight);
}
else if (completeBlock) {
return Promise.resolve(JSON.parse(block.data));
} else if (!completeBlock) {
if (process.env.NODE_APP_INSTANCE != null) {
// There is a unique constraint on the block height so if another process tries to lock, it'll throw
try {
await this.rpcBlockService.lockBlock(blockHeight, process.env.NODE_APP_INSTANCE);
} catch (e) {
result = await this.waitForBlock(blockHeight);
}
}
result = await this.loadBlockTemplate(blockHeight);
} else {
//wait for block
result = await this.waitForBlock(blockHeight);
}
} catch (e) {
console.error('Error getblocktemplate:', e.message);
throw new Error('Error getblocktemplate');
}
console.log(`getblocktemplate tx count: ${result.transactions.length}`);
return result;
} }
private async loadBlockTemplate(blockHeight: number) { private async loadBlockTemplate(blockHeight: number) {
console.log(`Master fetching block template ${blockHeight}`);
let blockTemplate: IBlockTemplate; let blockTemplate: IBlockTemplate;
while (blockTemplate == null) { while (blockTemplate == null) {
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [ blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
@@ -127,13 +156,8 @@ export class BitcoinRpcService implements OnModuleInit {
]); ]);
} }
try {
console.log(`Saving block ${blockHeight}`); await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
console.log('block saved');
} catch (e) {
console.error('Error saving block', e);
}
return blockTemplate; return blockTemplate;
} }
+5 -5
View File
@@ -34,7 +34,7 @@ export class DiscordService implements OnModuleInit {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
this.token = this.configService.get('DISCORD_BOT_TOKEN'); this.token = this.configService.get('DISCORD_BOT_TOKEN');
this.clientId = this.configService.get('DISCORD_BOT_CLIENTID'); this.clientId = this.configService.get('DISCORD_BOT_CLIENTID');
this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID'); this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID');
@@ -61,7 +61,7 @@ export class DiscordService implements OnModuleInit {
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
@@ -93,7 +93,7 @@ export class DiscordService implements OnModuleInit {
} }
private async registerCommands() { private async registerCommands() {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
const rest = new REST().setToken(this.token); const rest = new REST().setToken(this.token);
try { try {
console.log(`Started refreshing ${commands.length} application (/) commands.`); console.log(`Started refreshing ${commands.length} application (/) commands.`);
@@ -113,7 +113,7 @@ export class DiscordService implements OnModuleInit {
} }
public async notifyRestarted() { public async notifyRestarted() {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
@@ -125,7 +125,7 @@ export class DiscordService implements OnModuleInit {
} }
public async notifySubscribersBlockFound(height: number, block: Block, message: string) { public async notifySubscribersBlockFound(height: number, block: Block, message: string) {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
+31
View File
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../models/ExternalPoolShare';
@Injectable()
export class ExternalSharesService {
private readonly shareApiUrl: string;
private readonly shareApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService
) {
this.shareApiUrl = this.configService.get('SHARE_SUBMISSION_URL') || 'https://web.public-pool.io';
this.shareApiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
}
public submitShare(share: ExternalPoolShare): void {
this.httpService.post(`${this.shareApiUrl}/api/share`, share, {
headers: {
'x-api-key': this.shareApiKey
}
}).subscribe({
next: () =>{
console.log('External share accepted');
},
error: (error) => console.error('Failed to submit share to API:', error.message)
});
}
}
+58 -12
View File
@@ -1,17 +1,19 @@
import { BehaviorSubject, firstValueFrom, skip } from 'rxjs'; import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1'; import { MockRecording1 } from '../../test/models/MockRecording1';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { StratumV1JobsService } from './stratum-v1-jobs.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service';
describe('StratumV1JobsService', () => { describe('StratumV1JobsService', () => {
let blockTemplate$: BehaviorSubject<IBlockTemplate>; let miningInfo$: BehaviorSubject<IMiningInfo>;
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } }; let bitcoinRpcService: { newBlock$: any, getBlockTemplate: jest.Mock };
let service: StratumV1JobsService; let service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance; let consoleLogSpy: jest.SpyInstance;
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({ const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
...MockRecording1.BLOCK_TEMPLATE, ...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
height height
}); });
@@ -20,10 +22,12 @@ describe('StratumV1JobsService', () => {
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000)); jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
blockTemplate$ = new BehaviorSubject(createTemplate()); miningInfo$ = new BehaviorSubject({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
bitcoinRpcService = { bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(), newBlock$: miningInfo$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height } getBlockTemplate: jest.fn((height: number) => Promise.resolve(createTemplate(height)))
}; };
service = new StratumV1JobsService(bitcoinRpcService as any); service = new StratumV1JobsService(bitcoinRpcService as any);
}); });
@@ -51,9 +55,10 @@ describe('StratumV1JobsService', () => {
const firstTemplate = await firstValueFrom(service.newMiningJob$); const firstTemplate = await firstValueFrom(service.newMiningJob$);
service.addJob({ jobId: 'old-job', creation: Date.now() } as any); service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1; const nextTemplate = firstValueFrom(service.newMiningJob$);
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1))); miningInfo$.next({
blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1)); blocks: MockRecording1.BLOCK_TEMPLATE.height + 1
} as IMiningInfo);
const jobTemplate = await nextTemplate; const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(true); expect(jobTemplate.blockData.clearJobs).toBe(true);
@@ -62,6 +67,45 @@ describe('StratumV1JobsService', () => {
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate); expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
}); });
it('should skip identical non-clean template refreshes', async () => {
await firstValueFrom(service.newMiningJob$);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
expect(service.latestJobTemplateId).toBe(2);
expect(Object.keys(service.blocks)).toHaveLength(1);
});
it('should emit when transaction identity changes without changing transaction count', async () => {
await firstValueFrom(service.newMiningJob$);
const emissions = [];
const subscription = service.newMiningJob$.subscribe(jobTemplate => {
emissions.push(jobTemplate);
});
await Promise.resolve();
const changedTemplate = createTemplate();
changedTemplate.transactions[0] = {
...changedTemplate.transactions[0],
hash: `${changedTemplate.transactions[0].hash}-changed`
};
bitcoinRpcService.getBlockTemplate.mockResolvedValueOnce(changedTemplate);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
await Promise.resolve();
await Promise.resolve();
subscription.unsubscribe();
const jobTemplate = emissions[emissions.length - 1];
expect(jobTemplate.blockData.clearJobs).toBe(false);
expect(jobTemplate.blockData.id).toBe('2');
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should age old jobs and templates after five minutes', async () => { it('should age old jobs and templates after five minutes', async () => {
await firstValueFrom(service.newMiningJob$); await firstValueFrom(service.newMiningJob$);
const oldCreation = Date.now() - (1000 * 60 * 11); const oldCreation = Date.now() - (1000 * 60 * 11);
@@ -70,9 +114,11 @@ describe('StratumV1JobsService', () => {
blockData: { creation: oldCreation } blockData: { creation: oldCreation }
} as any; } as any;
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height; jest.setSystemTime(new Date(Date.now() + (1000 * 60 * 11)));
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1))); const nextTemplate = firstValueFrom(service.newMiningJob$);
blockTemplate$.next(createTemplate()); miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
const jobTemplate = await nextTemplate; const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(false); expect(jobTemplate.blockData.clearJobs).toBe(false);
+39 -16
View File
@@ -25,41 +25,71 @@ export interface IJobTemplate {
export class StratumV1JobsService { export class StratumV1JobsService {
public newMiningJob$: Observable<IJobTemplate>; public newMiningJob$: Observable<IJobTemplate>;
public latestJobId: number = 1; public latestJobId: number = 1;
public latestJobTemplateId: number = 1; public latestJobTemplateId: number = 1;
public jobs: { [jobId: string]: MiningJob } = {}; public jobs: { [jobId: string]: MiningJob } = {};
public blocks: { [id: number]: IJobTemplate } = {}; public blocks: { [id: number]: IJobTemplate } = {};
// offset the interval so that all the cluster processes don't try and refresh at the same time.
private delay = process.env.NODE_APP_INSTANCE == null ? 0 : parseInt(process.env.NODE_APP_INSTANCE) * 5000;
private lastBlockHeight = 0; private lastBlockHeight = 0;
private lastWorkSignature: string;
constructor( constructor(
private readonly bitcoinRpcService: BitcoinRpcService private readonly bitcoinRpcService: BitcoinRpcService
) { ) {
this.newMiningJob$ = this.bitcoinRpcService.newBlockTemplate$.pipe( const refreshInterval$ = this.delay > 0
map((blockTemplate) => { ? interval(60000).pipe(delay(this.delay), startWith(-1))
: interval(60000).pipe(startWith(-1));
if (process.env.MASTER == 'true') { this.newMiningJob$ = combineLatest([this.bitcoinRpcService.newBlock$, refreshInterval$]).pipe(
console.log('Updating block template'); switchMap(([miningInfo, interval]) => {
} return from(this.bitcoinRpcService.getBlockTemplate(miningInfo.blocks)).pipe(map((blockTemplate) => {
return {
blockTemplate,
miningInfo
}
}))
}),
map(({ blockTemplate, miningInfo }) => {
let clearJobs = false; let clearJobs = false;
const currentBlockHeight = this.bitcoinRpcService.miningInfo.blocks; const currentBlockHeight = miningInfo.blocks;
if(this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight){ if (this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight) {
console.log('New template is new block, clearing jobs');
clearJobs = true; clearJobs = true;
this.lastBlockHeight = currentBlockHeight; this.lastBlockHeight = currentBlockHeight;
console.log('new block');
} }
const currentTime = Math.floor(new Date().getTime() / 1000); const currentTime = Math.floor(new Date().getTime() / 1000);
const timestamp = blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime;
const workSignature = [
blockTemplate.previousblockhash,
blockTemplate.version,
blockTemplate.bits,
timestamp,
blockTemplate.height,
blockTemplate.coinbasevalue,
...blockTemplate.transactions.map(tx => tx.hash ?? tx.txid ?? tx.data)
].join('|');
if (!clearJobs && workSignature === this.lastWorkSignature) {
return null;
}
this.lastWorkSignature = workSignature;
return { return {
version: blockTemplate.version, version: blockTemplate.version,
bits: parseInt(blockTemplate.bits, 16), bits: parseInt(blockTemplate.bits, 16),
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash), prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)), transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
coinbasevalue: blockTemplate.coinbasevalue, coinbasevalue: blockTemplate.coinbasevalue,
timestamp: blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime, timestamp,
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)), networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
clearJobs, clearJobs,
height: blockTemplate.height height: blockTemplate.height
@@ -113,31 +143,24 @@ export class StratumV1JobsService {
this.blocks = {}; this.blocks = {};
this.jobs = {}; this.jobs = {};
}else{ }else{
let templatesDeleted = 0;
let jobsDeleted = 0;
const now = new Date().getTime(); const now = new Date().getTime();
// Delete old templates (5 minutes) // Delete old templates (5 minutes)
for(const templateId in this.blocks){ for(const templateId in this.blocks){
if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){ if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){
delete this.blocks[templateId]; delete this.blocks[templateId];
templatesDeleted++;
} }
} }
// Delete old jobs (5 minutes) // Delete old jobs (5 minutes)
for (const jobId in this.jobs) { for (const jobId in this.jobs) {
if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){ if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){
delete this.jobs[jobId]; delete this.jobs[jobId];
jobsDeleted++;
} }
} }
//console.log(`Deleted ${templatesDeleted} templates and ${jobsDeleted} jobs.`)
} }
this.blocks[data.blockData.id] = data; this.blocks[data.blockData.id] = data;
}), }),
shareReplay({ refCount: true, bufferSize: 1 }) shareReplay({ refCount: true, bufferSize: 1 })
) )
this.newMiningJob$.subscribe();
} }
private calculateNetworkDifficulty(nBits: number) { private calculateNetworkDifficulty(nBits: number) {
+7 -25
View File
@@ -2,12 +2,10 @@ import { StratumV1Service } from './stratum-v1.service';
describe('StratumV1Service', () => { describe('StratumV1Service', () => {
const originalMaster = process.env.MASTER; const originalMaster = process.env.MASTER;
const originalStratumPorts = process.env.STRATUM_PORTS; const originalNodeAppInstance = process.env.NODE_APP_INSTANCE;
const originalStratumSecure = process.env.STRATUM_SECURE; const originalStratumPort = process.env.STRATUM_PORT;
const originalSecureStratumPorts = process.env.SECURE_STRATUM_PORTS;
const originalBackpressureEnabled = process.env.STRATUM_BACKPRESSURE_ENABLED; const originalBackpressureEnabled = process.env.STRATUM_BACKPRESSURE_ENABLED;
const originalMaxConnectionsPerListener = process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER; const originalMaxConnectionsPerListener = process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
const originalTlsHandshakeTimeoutMs = process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS;
let service: StratumV1Service; let service: StratumV1Service;
let clientService; let clientService;
@@ -27,6 +25,7 @@ describe('StratumV1Service', () => {
{} as any, {} as any,
{} as any, {} as any,
{} as any, {} as any,
{} as any,
{} as any {} as any
); );
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
@@ -35,12 +34,10 @@ describe('StratumV1Service', () => {
afterEach(() => { afterEach(() => {
restoreEnv('MASTER', originalMaster); restoreEnv('MASTER', originalMaster);
restoreEnv('STRATUM_PORTS', originalStratumPorts); restoreEnv('NODE_APP_INSTANCE', originalNodeAppInstance);
restoreEnv('STRATUM_SECURE', originalStratumSecure); restoreEnv('STRATUM_PORT', originalStratumPort);
restoreEnv('SECURE_STRATUM_PORTS', originalSecureStratumPorts);
restoreEnv('STRATUM_BACKPRESSURE_ENABLED', originalBackpressureEnabled); restoreEnv('STRATUM_BACKPRESSURE_ENABLED', originalBackpressureEnabled);
restoreEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', originalMaxConnectionsPerListener); restoreEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', originalMaxConnectionsPerListener);
restoreEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', originalTlsHandshakeTimeoutMs);
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore(); consoleWarnSpy.mockRestore();
jest.useRealTimers(); jest.useRealTimers();
@@ -49,39 +46,31 @@ describe('StratumV1Service', () => {
it('should skip Stratum listeners in the master process', async () => { it('should skip Stratum listeners in the master process', async () => {
process.env.MASTER = 'true'; process.env.MASTER = 'true';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer'); const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer');
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer');
await service.onModuleInit(); await service.onModuleInit();
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
expect(clientService.deleteAll).toHaveBeenCalled(); expect(clientService.deleteAll).toHaveBeenCalled();
expect(startSocketServerSpy).not.toHaveBeenCalled(); expect(startSocketServerSpy).not.toHaveBeenCalled();
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners'); expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners');
}); });
it('should start Stratum listeners in worker processes', async () => { it('should start the Stratum listener in worker processes', async () => {
process.env.MASTER = 'false'; process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '3333,3334'; process.env.STRATUM_PORT = '3334';
process.env.STRATUM_SECURE = 'true';
process.env.SECURE_STRATUM_PORTS = '4333';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined); const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer').mockImplementation(() => undefined);
await service.onModuleInit(); await service.onModuleInit();
jest.advanceTimersByTime(10000); jest.advanceTimersByTime(10000);
expect(clientService.deleteAll).not.toHaveBeenCalled(); expect(clientService.deleteAll).not.toHaveBeenCalled();
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3334); expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
}); });
it('should pause listeners when worker backpressure is high', () => { it('should pause listeners when worker backpressure is high', () => {
const close = jest.fn((callback?: (error?: Error) => void) => callback?.()); const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
(service as any).listeners.push({ (service as any).listeners.push({
port: 3333, port: 3333,
secure: false,
server: { close }, server: { close },
paused: false paused: false
}); });
@@ -99,7 +88,6 @@ describe('StratumV1Service', () => {
it('should resume listeners after consecutive healthy backpressure checks', () => { it('should resume listeners after consecutive healthy backpressure checks', () => {
(service as any).listeners.push({ (service as any).listeners.push({
port: 3333, port: 3333,
secure: false,
server: null, server: null,
paused: true paused: true
}); });
@@ -139,12 +127,6 @@ describe('StratumV1Service', () => {
expect((service as any).getMaxConnectionsPerListener()).toBe(10000); expect((service as any).getMaxConnectionsPerListener()).toBe(10000);
}); });
it('should use an explicit TLS handshake timeout', () => {
process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS = '5000';
expect((service as any).getTlsHandshakeTimeoutMs()).toBe(5000);
});
function restoreEnv(key: string, value: string | undefined) { function restoreEnv(key: string, value: string | undefined) {
if (value == null) { if (value == null) {
delete process.env[key]; delete process.env[key];
+36 -161
View File
@@ -9,16 +9,12 @@ import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../ORM/client/client.service'; import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService } from './bitcoin-rpc.service'; import { BitcoinRpcService } from './bitcoin-rpc.service';
import { ExternalSharesService } from './external-shares.service';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { readFileSync } from 'fs';
import { TlsOptions, TLSSocket, createServer } from 'tls';
import * as path from 'path';
interface StratumListenerState { interface StratumListenerState {
port: number; port: number;
secure: boolean;
server: Server | null; server: Server | null;
paused: boolean; paused: boolean;
} }
@@ -30,17 +26,11 @@ const DEFAULT_BACKPRESSURE_RSS_MB = 2500;
const DEFAULT_BACKPRESSURE_RESUME_RSS_MB = 2000; const DEFAULT_BACKPRESSURE_RESUME_RSS_MB = 2000;
const DEFAULT_BACKPRESSURE_HEALTHY_CHECKS = 3; const DEFAULT_BACKPRESSURE_HEALTHY_CHECKS = 3;
const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000; const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000;
const DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS = 10000;
@Injectable() @Injectable()
export class StratumV1Service implements OnModuleInit { export class StratumV1Service implements OnModuleInit {
private socketTimeout = 0;
private emptySocket = 0;
private normalClosure = 0;
private errorClosure = 0;
private readonly listeners: StratumListenerState[] = []; private readonly listeners: StratumListenerState[] = [];
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 }); private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
private backpressureMonitor: NodeJS.Timeout | null = null; private backpressureMonitor: NodeJS.Timeout | null = null;
@@ -54,38 +44,26 @@ export class StratumV1Service implements OnModuleInit {
private readonly blocksService: BlocksService, private readonly blocksService: BlocksService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly stratumV1JobsService: StratumV1JobsService, private readonly stratumV1JobsService: StratumV1JobsService,
private readonly addressSettingsService: AddressSettingsService private readonly addressSettingsService: AddressSettingsService,
private readonly externalSharesService: ExternalSharesService
) { ) {
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
if (process.env.MASTER == 'true') { if (process.env.NODE_APP_INSTANCE == '0' || process.env.MASTER == 'true') {
await this.clientService.deleteAll(); await this.clientService.deleteAll();
}
if (process.env.MASTER == 'true') {
console.log('Master process skipping Stratum socket listeners'); console.log('Master process skipping Stratum socket listeners');
return; return;
} }
// wait for all the other processes to init for an even connection distribution
setTimeout(() => { setTimeout(() => {
process.env.STRATUM_PORTS.split(',').forEach(port => { this.startSocketServer(this.getStratumPort());
this.startSocketServer(parseInt(port)); }, 1000 * 10);
});
if (process.env.STRATUM_SECURE?.toLowerCase() === 'true') {
process.env.SECURE_STRATUM_PORTS.split(',').forEach(port => {
this.startSecureSocketServer(parseInt(port));
});
}
}, (10000));
setInterval(() => {
console.log(`Socket stats: ${this.emptySocket} empty, ${this.socketTimeout} timeouts, ${this.normalClosure} normal closure, ${this.errorClosure} error closure`);
this.emptySocket = 0;
this.socketTimeout = 0;
this.normalClosure = 0;
this.errorClosure = 0;
}, 1000 * 60);
this.startBackpressureMonitor(); this.startBackpressureMonitor();
@@ -94,7 +72,6 @@ export class StratumV1Service implements OnModuleInit {
private startSocketServer(port: number) { private startSocketServer(port: number) {
const listener: StratumListenerState = { const listener: StratumListenerState = {
port, port,
secure: false,
server: null, server: null,
paused: false paused: false
}; };
@@ -104,8 +81,9 @@ export class StratumV1Service implements OnModuleInit {
private createSocketServer(): Server { private createSocketServer(): Server {
const server = new Server(async (socket: Socket) => { const server = new Server(async (socket: Socket) => {
// Set 15-minute timeout
socket.setTimeout(1000 * 60 * 15); //5 min
socket.setTimeout(1000 * 60 * 5);
const client = new StratumV1Client( const client = new StratumV1Client(
socket, socket,
@@ -116,131 +94,30 @@ export class StratumV1Service implements OnModuleInit {
this.notificationService, this.notificationService,
this.blocksService, this.blocksService,
this.configService, this.configService,
this.addressSettingsService this.addressSettingsService,
this.externalSharesService
); );
// Unified cleanup function
const cleanup = async (reason: string) => {
if (client.extraNonceAndSessionId != null) {
await client.destroy();
if (reason == 'Error') {
this.errorClosure++;
} else {
this.normalClosure++;
}
}
if (!socket.destroyed) {
socket.end();
socket.destroy();
}
};
// Handle client disconnection
socket.on('close', async (hadError: boolean) => {
await cleanup(hadError ? "Error" : "Normal Closure");
});
// Handle socket timeouts
socket.on('timeout', async () => {
if (socket.bytesRead == 0 || socket.bytesWritten == 0) {
this.emptySocket++;
} else {
this.socketTimeout++;
}
await cleanup("Timeout");
});
// Handle errors properly
socket.on('error', async (error: Error) => {
await cleanup("Error");
});
//
});
// Ensure server itself handles errors
server.on('error', (err) => {
console.error(`Server error: ${err.message}`);
});
this.configureConnectionLimit(server);
return server;
}
private startSecureSocketServer(port: number) {
const listener: StratumListenerState = {
port,
secure: true,
server: null,
paused: false
};
this.listeners.push(listener);
this.listen(listener);
}
private createSecureSocketServer(): Server {
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
const tlsOptions: TlsOptions = {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
handshakeTimeout: this.getTlsHandshakeTimeoutMs()
};
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
// Set 15-minute timeout
socket.setTimeout(1000 * 60 * 15);
const client = new StratumV1Client(
socket,
this.stratumV1JobsService,
this.bitcoinRpcService,
this.clientService,
this.clientStatisticsService,
this.notificationService,
this.blocksService,
this.configService,
this.addressSettingsService
);
const cleanup = async (reason: string) => {
if (client.extraNonceAndSessionId != null) {
await client.destroy();
if (reason === 'Error') {
this.errorClosure++;
} else {
this.normalClosure++;
}
}
if (!socket.destroyed) {
socket.end();
socket.destroy();
}
};
socket.on('close', async (hadError: boolean) => { socket.on('close', async (hadError: boolean) => {
await cleanup(hadError ? 'Error' : 'Normal Closure'); if (client.extraNonceAndSessionId != null) {
}); // Handle socket disconnection
await client.destroy();
socket.on('timeout', async () => { console.log(`Client ${client.extraNonceAndSessionId} disconnected, hadError?:${hadError}`);
if (socket.bytesRead === 0 || socket.bytesWritten === 0) {
this.emptySocket++;
} else {
this.socketTimeout++;
} }
await cleanup('Timeout');
}); });
socket.on('error', async (error: Error) => { socket.on('timeout', () => {
await cleanup('Error'); console.log('socket timeout');
socket.end();
socket.destroy();
}); });
// your protocol handling stays the same socket.on('error', async (error: Error) => { });
// //console.log(`Client disconnected, socket error, ${client.extraNonceAndSessionId}`);
}); });
server.on('error', (err) => { server.on('error', (err) => {
@@ -249,7 +126,6 @@ export class StratumV1Service implements OnModuleInit {
this.configureConnectionLimit(server); this.configureConnectionLimit(server);
return server; return server;
} }
private listen(listener: StratumListenerState) { private listen(listener: StratumListenerState) {
@@ -257,12 +133,12 @@ export class StratumV1Service implements OnModuleInit {
return; return;
} }
const server = listener.secure ? this.createSecureSocketServer() : this.createSocketServer(); const server = this.createSocketServer();
listener.server = server; listener.server = server;
listener.paused = false; listener.paused = false;
server.listen(listener.port, () => { server.listen(listener.port, () => {
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} server is listening on port ${listener.port}`); console.log(`Stratum server is listening on port ${listener.port}`);
}); });
} }
@@ -344,6 +220,11 @@ export class StratumV1Service implements OnModuleInit {
} }
} }
private configureConnectionLimit(server: Server) {
server.maxConnections = this.getMaxConnectionsPerListener();
(server as Server & { dropMaxConnection: boolean }).dropMaxConnection = true;
}
private getEventLoopP95Ms() { private getEventLoopP95Ms() {
return Math.round(this.eventLoopDelay.percentile(95) / 1e6); return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
} }
@@ -376,17 +257,12 @@ export class StratumV1Service implements OnModuleInit {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_HEALTHY_CHECKS', DEFAULT_BACKPRESSURE_HEALTHY_CHECKS); return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_HEALTHY_CHECKS', DEFAULT_BACKPRESSURE_HEALTHY_CHECKS);
} }
private configureConnectionLimit(server: Server) {
server.maxConnections = this.getMaxConnectionsPerListener();
(server as Server & { dropMaxConnection: boolean }).dropMaxConnection = true;
}
private getMaxConnectionsPerListener() { private getMaxConnectionsPerListener() {
return this.getPositiveIntegerEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', DEFAULT_MAX_CONNECTIONS_PER_LISTENER); return this.getPositiveIntegerEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', DEFAULT_MAX_CONNECTIONS_PER_LISTENER);
} }
private getTlsHandshakeTimeoutMs() { private getStratumPort() {
return this.getPositiveIntegerEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS); return this.getPositiveIntegerEnv('STRATUM_PORT', 3333);
} }
private getPositiveIntegerEnv(key: string, fallback: number) { private getPositiveIntegerEnv(key: string, fallback: number) {
@@ -398,5 +274,4 @@ export class StratumV1Service implements OnModuleInit {
} }
} }
+24
View File
@@ -0,0 +1,24 @@
import * as bitcoinjs from 'bitcoinjs-lib';
const TRUE_DIFF_ONE = 2.695953529101131e67;
export class DifficultyUtils {
static calculateDifficulty(header: Buffer): { submissionDifficulty: number; submissionHash: string } {
const hashResult = bitcoinjs.crypto.hash256(Buffer.isBuffer(header) ? header : Buffer.from(header, 'hex'));
const target = DifficultyUtils.le256todouble(hashResult);
const difficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return {
submissionDifficulty: difficulty,
submissionHash: hashResult.toString('hex')
};
}
private static le256todouble(target: Buffer): number {
let number = 0;
for (let i = target.length - 1; i >= 0; i--) {
number = number * 256 + target[i];
}
return number;
}
}
+4 -4
View File
@@ -2,10 +2,10 @@ import { IBlockTemplate } from '../../src/models/bitcoin-rpc/IBlockTemplate';
export class MockRecording1 { export class MockRecording1 {
public static EXTRA_NONCE = `57a6f098`; public static EXTRA_NONCE = `57a6f098`;
public static MINING_SUBSCRIBE = `{"id": 1, "method": "mining.subscribe", "params": ["bitaxe v2.2"]}`; public static MINING_SUBSCRIBE = `{"id": 1, "method": "mining.subscribe", "params": ["bitaxe v2.2"]}\n`;
public static MINING_CONFIGURE = `{"id": 2, "method": "mining.configure", "params": [["version-rolling"], {"version-rolling.mask": "ffffffff"}]}`; public static MINING_CONFIGURE = `{"id": 2, "method": "mining.configure", "params": [["version-rolling"], {"version-rolling.mask": "ffffffff"}]}\n`;
public static MINING_AUTHORIZE = `{"id": 3, "method": "mining.authorize", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "x"]}`; public static MINING_AUTHORIZE = `{"id": 3, "method": "mining.authorize", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "x"]}\n`;
public static MINING_SUGGEST_DIFFICULTY = `{"id": 4, "method": "mining.suggest_difficulty", "params": [512]}`; public static MINING_SUGGEST_DIFFICULTY = `{"id": 4, "method": "mining.suggest_difficulty", "params": [512]}\n`;
public static BLOCK_TEMPLATE: IBlockTemplate = { "capabilities": ["proposal"], "version": 536870912, "rules": ["csv", "!segwit", "taproot"], "vbavailable": {}, "vbrequired": 0, "previousblockhash": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f2", "transactions": [{ "data": "02000000000101c3efa41c94ee24dd6c6aaccab0f27e9d2baf92e5112bff15cc463b600e4e3a3c0100000000fdffffff024ea319000000000017a914a5b8d32a6388f02204a1445bd623150ee3b851d18765d67b000000000017a914a316e3a4eaeee9cbee9c254c3292830281a8c9c38702473044022065fbcd2918e8b52c3af8c4ae21b3646d7a12f66ddcaa73a68273f5806527c0da0220291f555dd120bb9eadb144ccf4203db659b78d79bffcd37a29016da068f3abb9012102baab4d464b57771f5108f40ffc31b7242c5b430a61d4bfead024d715c441f3f3c7432500", "txid": "a174b99a87c899e66854ba6f101799ac852ef588ec69299846875e9d64355317", "hash": "748f4db5e45dab6a9903f50e7e760470ce80ac219c637d61418667a0ccb6c536", "depends": [], "fee": 14300, "sigops": 1, "weight": 569 }, { "data": "01000000018985719caa78472c8fad7efa88fc109c6faf6df3dc3a690656479e53c6b10fe9030000006b483045022100e7abb2f2f7e6cfad680efe10a7fc218d685a9cce929b1a4e62fcb726228ac9b50220538f3d2eb36d277bcadf00f3ac2abbaf80395c389e1f8048dc95fdf6204cf1840121037435c194e9b01b3d7f7a2802d6684a3af68d05bbf4ec8f17021980d777691f1dfdffffff040000000000000000536a4c5054325b0b15aaff5c59aab47ce39829055c3007922e2934d3b7c9f51238f8d4a98718da4f9c99a6692adacd0e324b7775eebe151c4f3ad37fcc5f9303eed491a02638b8002543c70002002543a7000a4c10270000000000001976a914000000000000000000000000000000000000000088ac10270000000000001976a914000000000000000000000000000000000000000088aca99f6412000000001976a914ba27f99e007c7f605a8305e318c1abde3cd220ac88ac00000000", "txid": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "hash": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "depends": [], "fee": 9153, "sigops": 12, "weight": 1408 }, { "data": "020000000001013f8e5ebf5620f2c62c9c4e4f77bb4eb903699620da372ad44f666a552e5b9fc801000000171600148442ee1fe3742153a6b40623d363a22ceed677c3ffffffff02bc0200000000000017a91470b93cf7ead31ef24b71159388c2e8e147f755b987aa0415000000000017a9145506e665bcf63e1225100d7c8613a640699fd5d087024730440220215cd74141ebcde3aee383f232dfd2c6b011645012f35714781d28d4a6959829022017ea40a15f4116bbf0a6f86dc0af08758d9a74673498a61164ed13e9d1c712e501210294184e8093958861740c4ce32e724a24b7df7eeb04702cae274652e86b01f83a00000000", "txid": "5aaf2fa707da25c24374b87d89c420c9e4c51022a4bbed4cc91614831f359366", "hash": "2c19268e65cc223c5fac9666883ecc1d10e680c54a4fdbbb3e4775f886b0feec", "depends": [], "fee": 491, "sigops": 1, "weight": 661 }, { "data": "020000000001012c53f5b0a12cadb57602466e703e60272183b75f3366073f4841dac8e616f2740000000000feffffff02e80300000000000016001496e381bd3fe4bae631adc5a5a420fb9124e5480af0a8100000000000160014cd06fc96fea44c6c5e074ba16dd20b222a0a9b32024730440220440ba2252ea6f70f1b8106897984330a997ef504f7f0dbb89679e44309026f2b02207074ad7dbd9a2bd37a41352527b7f3e68612956fe2085a0eb0b41f97f6a3599d012103b3ecf0f44126019a94a818245e35ecff1582c2900a38487a306fcd91bdb28003c8432500", "txid": "69770cf296b64329e784a5666fb0eab034fbb1780bf067f4e1606874c214f01d", "hash": "fadcaa8de64274e63c093592ed6d8c6b7584bb92c0e8d9c49f02a4df11f6f69d", "depends": [], "fee": 141, "sigops": 1, "weight": 561 }, { "data": "01000000019e03cd78895f6876932501b66eab1e44611d48b4c5029d250b7d2d6073b44b68000000006b48304502210088dfbeb842d54893987e5afb87b70fe70e7b91ebad740782515c44464b19a74202205a6bc47ad6c925f85aa29b2be1a174efcc30a4ad8ed6cfe6236b66d7a4f8d96f01210259530ee281106e237ac3aa801d1f7699ada05501186401da78b7d88849bbda8bffffffff0204ce3900000000001976a91419803f0d83a1b58348a4617c743638bbf9ec235588ac44ce5c01000000001976a9149e38aebe02be7cecacdb521f514cffe445c5533788ac00000000", "txid": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "hash": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "depends": [], "fee": 226, "sigops": 8, "weight": 904 }], "coinbaseaux": {}, "coinbasevalue": 2465717, "longpollid": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f29370", "target": "000000000000002495f800000000000000000000000000000000000000000000", "mintime": 1689512405, "mutable": ["time", "transactions", "prevblock"], "noncerange": "00000000ffffffff", "sigoplimit": 80000, "sizelimit": 4000000, "weightlimit": 4000000, "curtime": 1689514989, "bits": "192495f8", "height": 2442185, "default_witness_commitment": "6a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c" }; public static BLOCK_TEMPLATE: IBlockTemplate = { "capabilities": ["proposal"], "version": 536870912, "rules": ["csv", "!segwit", "taproot"], "vbavailable": {}, "vbrequired": 0, "previousblockhash": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f2", "transactions": [{ "data": "02000000000101c3efa41c94ee24dd6c6aaccab0f27e9d2baf92e5112bff15cc463b600e4e3a3c0100000000fdffffff024ea319000000000017a914a5b8d32a6388f02204a1445bd623150ee3b851d18765d67b000000000017a914a316e3a4eaeee9cbee9c254c3292830281a8c9c38702473044022065fbcd2918e8b52c3af8c4ae21b3646d7a12f66ddcaa73a68273f5806527c0da0220291f555dd120bb9eadb144ccf4203db659b78d79bffcd37a29016da068f3abb9012102baab4d464b57771f5108f40ffc31b7242c5b430a61d4bfead024d715c441f3f3c7432500", "txid": "a174b99a87c899e66854ba6f101799ac852ef588ec69299846875e9d64355317", "hash": "748f4db5e45dab6a9903f50e7e760470ce80ac219c637d61418667a0ccb6c536", "depends": [], "fee": 14300, "sigops": 1, "weight": 569 }, { "data": "01000000018985719caa78472c8fad7efa88fc109c6faf6df3dc3a690656479e53c6b10fe9030000006b483045022100e7abb2f2f7e6cfad680efe10a7fc218d685a9cce929b1a4e62fcb726228ac9b50220538f3d2eb36d277bcadf00f3ac2abbaf80395c389e1f8048dc95fdf6204cf1840121037435c194e9b01b3d7f7a2802d6684a3af68d05bbf4ec8f17021980d777691f1dfdffffff040000000000000000536a4c5054325b0b15aaff5c59aab47ce39829055c3007922e2934d3b7c9f51238f8d4a98718da4f9c99a6692adacd0e324b7775eebe151c4f3ad37fcc5f9303eed491a02638b8002543c70002002543a7000a4c10270000000000001976a914000000000000000000000000000000000000000088ac10270000000000001976a914000000000000000000000000000000000000000088aca99f6412000000001976a914ba27f99e007c7f605a8305e318c1abde3cd220ac88ac00000000", "txid": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "hash": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "depends": [], "fee": 9153, "sigops": 12, "weight": 1408 }, { "data": "020000000001013f8e5ebf5620f2c62c9c4e4f77bb4eb903699620da372ad44f666a552e5b9fc801000000171600148442ee1fe3742153a6b40623d363a22ceed677c3ffffffff02bc0200000000000017a91470b93cf7ead31ef24b71159388c2e8e147f755b987aa0415000000000017a9145506e665bcf63e1225100d7c8613a640699fd5d087024730440220215cd74141ebcde3aee383f232dfd2c6b011645012f35714781d28d4a6959829022017ea40a15f4116bbf0a6f86dc0af08758d9a74673498a61164ed13e9d1c712e501210294184e8093958861740c4ce32e724a24b7df7eeb04702cae274652e86b01f83a00000000", "txid": "5aaf2fa707da25c24374b87d89c420c9e4c51022a4bbed4cc91614831f359366", "hash": "2c19268e65cc223c5fac9666883ecc1d10e680c54a4fdbbb3e4775f886b0feec", "depends": [], "fee": 491, "sigops": 1, "weight": 661 }, { "data": "020000000001012c53f5b0a12cadb57602466e703e60272183b75f3366073f4841dac8e616f2740000000000feffffff02e80300000000000016001496e381bd3fe4bae631adc5a5a420fb9124e5480af0a8100000000000160014cd06fc96fea44c6c5e074ba16dd20b222a0a9b32024730440220440ba2252ea6f70f1b8106897984330a997ef504f7f0dbb89679e44309026f2b02207074ad7dbd9a2bd37a41352527b7f3e68612956fe2085a0eb0b41f97f6a3599d012103b3ecf0f44126019a94a818245e35ecff1582c2900a38487a306fcd91bdb28003c8432500", "txid": "69770cf296b64329e784a5666fb0eab034fbb1780bf067f4e1606874c214f01d", "hash": "fadcaa8de64274e63c093592ed6d8c6b7584bb92c0e8d9c49f02a4df11f6f69d", "depends": [], "fee": 141, "sigops": 1, "weight": 561 }, { "data": "01000000019e03cd78895f6876932501b66eab1e44611d48b4c5029d250b7d2d6073b44b68000000006b48304502210088dfbeb842d54893987e5afb87b70fe70e7b91ebad740782515c44464b19a74202205a6bc47ad6c925f85aa29b2be1a174efcc30a4ad8ed6cfe6236b66d7a4f8d96f01210259530ee281106e237ac3aa801d1f7699ada05501186401da78b7d88849bbda8bffffffff0204ce3900000000001976a91419803f0d83a1b58348a4617c743638bbf9ec235588ac44ce5c01000000001976a9149e38aebe02be7cecacdb521f514cffe445c5533788ac00000000", "txid": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "hash": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "depends": [], "fee": 226, "sigops": 8, "weight": 904 }], "coinbaseaux": {}, "coinbasevalue": 2465717, "longpollid": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f29370", "target": "000000000000002495f800000000000000000000000000000000000000000000", "mintime": 1689512405, "mutable": ["time", "transactions", "prevblock"], "noncerange": "00000000ffffffff", "sigoplimit": 80000, "sizelimit": 4000000, "weightlimit": 4000000, "curtime": 1689514989, "bits": "192495f8", "height": 2442185, "default_witness_commitment": "6a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c" };
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`; public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`;
public static TIME = '64b3f3ec'; public static TIME = '64b3f3ec';