mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Compare commits
38
Commits
9878fa42b9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a9202c11 | ||
|
|
95565eeec4 | ||
|
|
b971e9ce4c | ||
|
|
e1b88207d3 | ||
|
|
e7f7cb52af | ||
|
|
6cf1c225a4 | ||
|
|
fd775965e5 | ||
|
|
554a8916a7 | ||
|
|
fda3f21b71 | ||
|
|
0201703e35 | ||
|
|
ddb7a89615 | ||
|
|
f9e49a8955 | ||
|
|
2912dd5071 | ||
|
|
4282233d2f | ||
|
|
8ce057b3b5 | ||
|
|
7bb436bf1f | ||
|
|
0917911eb0 | ||
|
|
de2b41a3c1 | ||
|
|
1cd5bcc97d | ||
|
|
277aa10144 | ||
|
|
f08103bd8c | ||
|
|
811e7972f9 | ||
|
|
c654a706ae | ||
|
|
e0239d2805 | ||
|
|
fc96b9ea45 | ||
|
|
ad1f32cc2c | ||
|
|
917d312211 | ||
|
|
76e2426c30 | ||
|
|
098fe2867a | ||
|
|
6e3902b732 | ||
|
|
8c62817f73 | ||
|
|
9f723782ab | ||
|
|
2e296c00d5 | ||
|
|
437b0c9bc4 | ||
|
|
deed775d55 | ||
|
|
ed537658ac | ||
|
|
29e0d884e3 | ||
|
|
e35bacc648 |
+7
-13
@@ -10,15 +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_MAX_CONNECTIONS_PER_LISTENER=10000
|
||||||
STRATUM_SECURE=true
|
|
||||||
SECURE_STRATUM_PORTS=4333,4332,4331,4330
|
|
||||||
|
|
||||||
#optional telegram bot
|
#optional telegram bot
|
||||||
#TELEGRAM_BOT_TOKEN=
|
#TELEGRAM_BOT_TOKEN=
|
||||||
@@ -34,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
|
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
# Docker build environment #
|
# Docker build environment #
|
||||||
############################
|
############################
|
||||||
|
|
||||||
FROM node:18.16.1-bookworm-slim AS build
|
FROM node:24.16.0-bookworm-slim AS build
|
||||||
|
|
||||||
# Upgrade all packages and install dependencies
|
# Upgrade all packages and install dependencies
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -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
|
||||||
@@ -24,7 +26,7 @@ RUN npm i && npm run build
|
|||||||
# Docker final environment #
|
# Docker final environment #
|
||||||
############################
|
############################
|
||||||
|
|
||||||
FROM node:18.16.1-bookworm-slim
|
FROM node:24.16.0-bookworm-slim
|
||||||
|
|
||||||
# Expose ports for Stratum and Bitcoin RPC
|
# Expose ports for Stratum and Bitcoin RPC
|
||||||
EXPOSE 3333 3334 8332
|
EXPOSE 3333 3334 8332
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ Install pm2 (https://pm2.keymetrics.io/)
|
|||||||
$ pm2 start dist/main.js
|
$ pm2 start dist/main.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When running the worker app in PM2 cluster mode, start the PM2 daemon with OS-level
|
||||||
|
connection scheduling. The environment variable must be present when the PM2 daemon
|
||||||
|
starts, not only in the worker configuration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ NODE_CLUSTER_SCHED_POLICY=none pm2 start ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
|
||||||
|
|
||||||
|
`STRATUM_MAX_CONNECTIONS_PER_LISTENER` is enforced per worker and Stratum port.
|
||||||
|
Size it using the busiest port: `worker count * limit`. For example, 28 workers
|
||||||
|
with the default limit of `10000` allow up to `280000` connections on one port.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build container:
|
Build container:
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Generated
+21
-8295
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -5,6 +5,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
@@ -30,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",
|
||||||
@@ -39,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,17 +15,14 @@ export class AddressSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSettings(address: string, createIfNotFound: boolean) {
|
public async getSettings(address: string, createIfNotFound: boolean) {
|
||||||
let settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
const settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
||||||
if (createIfNotFound === true && settings == null) {
|
if (createIfNotFound == true && settings == null) {
|
||||||
await this.addressSettingsRepository
|
// It's possible to have a race condition here so if we get a PK violation, fetch it
|
||||||
.createQueryBuilder()
|
try {
|
||||||
.insert()
|
return await this.createNew(address);
|
||||||
.into(AddressSettingsEntity)
|
} catch (e) {
|
||||||
.values({ address })
|
return await this.addressSettingsRepository.findOne({ where: { address } });
|
||||||
.orIgnore()
|
}
|
||||||
.execute();
|
|
||||||
|
|
||||||
settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@@ -52,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
|
||||||
@@ -76,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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 { }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { DateTimeTransformer } from './DateTimeTransformer';
|
|
||||||
|
|
||||||
describe('DateTimeTransformer', () => {
|
|
||||||
const transformer = new DateTimeTransformer();
|
|
||||||
|
|
||||||
it('should pass Date values through as Date instances', () => {
|
|
||||||
const date = new Date('2026-05-06T12:00:00.000Z');
|
|
||||||
|
|
||||||
expect(transformer.to(date)).toBe(date);
|
|
||||||
expect(transformer.from(date)).toBe(date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse ISO strings from the database', () => {
|
|
||||||
const date = transformer.from('2026-05-06T12:00:00.000Z');
|
|
||||||
|
|
||||||
expect(date).toBeInstanceOf(Date);
|
|
||||||
expect((date as Date).toISOString()).toBe('2026-05-06T12:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse legacy locale strings', () => {
|
|
||||||
const usDate = transformer.from('5/6/2026, 1:02:03 PM') as Date;
|
|
||||||
const europeanDate = transformer.from('6.5.2026, 13:02:03') as Date;
|
|
||||||
|
|
||||||
expect(usDate.getFullYear()).toBe(2026);
|
|
||||||
expect(usDate.getMonth()).toBe(4);
|
|
||||||
expect(usDate.getDate()).toBe(6);
|
|
||||||
expect(usDate.getHours()).toBe(13);
|
|
||||||
expect(europeanDate.getFullYear()).toBe(2026);
|
|
||||||
expect(europeanDate.getMonth()).toBe(4);
|
|
||||||
expect(europeanDate.getDate()).toBe(6);
|
|
||||||
expect(europeanDate.getHours()).toBe(13);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve null and undefined values', () => {
|
|
||||||
expect(transformer.to(null)).toBeNull();
|
|
||||||
expect(transformer.from(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,113 +1,14 @@
|
|||||||
import { ValueTransformer } from 'typeorm';
|
import { ValueTransformer } from 'typeorm';
|
||||||
|
|
||||||
const EUROPEAN_LOCALE_REGEX =
|
export class DateTimeTransformer implements ValueTransformer {
|
||||||
/^(\d{1,2})\.(\d{1,2})\.(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i;
|
to(value: Date): any {
|
||||||
const US_LOCALE_REGEX =
|
// Convert the local time to UTC before saving to the database
|
||||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i;
|
const utcTime = value?.toLocaleString();
|
||||||
|
return utcTime;
|
||||||
function parseLegacyLocaleString(value: string): Date | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
const matchEuropean = trimmed.match(EUROPEAN_LOCALE_REGEX);
|
|
||||||
if (matchEuropean) {
|
|
||||||
const [, day, month, year, hours, minutes, seconds = '0', meridiem] = matchEuropean;
|
|
||||||
return buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchUs = trimmed.match(US_LOCALE_REGEX);
|
from(value: any): Date {
|
||||||
if (matchUs) {
|
// Convert the UTC time from the database to the local time zone
|
||||||
const [, month, day, year, hours, minutes, seconds = '0', meridiem] = matchUs;
|
|
||||||
return buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
}: {
|
|
||||||
year: string;
|
|
||||||
month: string;
|
|
||||||
day: string;
|
|
||||||
hours: string;
|
|
||||||
minutes: string;
|
|
||||||
seconds: string;
|
|
||||||
meridiem?: string;
|
|
||||||
}): Date {
|
|
||||||
let hourValue = Number(hours);
|
|
||||||
if (meridiem) {
|
|
||||||
const normalized = meridiem.toUpperCase();
|
|
||||||
if (normalized === 'PM' && hourValue < 12) {
|
|
||||||
hourValue += 12;
|
|
||||||
} else if (normalized === 'AM' && hourValue === 12) {
|
|
||||||
hourValue = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(
|
|
||||||
Number(year),
|
|
||||||
Number(month) - 1,
|
|
||||||
Number(day),
|
|
||||||
hourValue,
|
|
||||||
Number(minutes),
|
|
||||||
Number(seconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDate(value: Date | string): Date {
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyDate = parseLegacyLocaleString(value);
|
|
||||||
if (legacyDate) {
|
|
||||||
return legacyDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(value);
|
|
||||||
if (!Number.isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Invalid date value received: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DateTimeTransformer implements ValueTransformer {
|
|
||||||
to(value: Date | string | null | undefined): Date | null | undefined {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value === undefined ? undefined : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeDate(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
from(value: Date | string | null | undefined): Date | null | undefined {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value === undefined ? undefined : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeDate(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
|
|
||||||
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
|
|
||||||
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+267
-112
@@ -1,140 +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 expose coinbase helpers for downstream validation', () => {
|
it('should leave block version unchanged without a version mask', () => {
|
||||||
const notify = JSON.parse(job.response(jobTemplate));
|
const updatedBlock = job.copyAndUpdateBlock(
|
||||||
|
jobTemplate,
|
||||||
|
0,
|
||||||
|
parseInt('ed460d91', 16),
|
||||||
|
'57a6f098',
|
||||||
|
'c708000000000000',
|
||||||
|
parseInt(MockRecording1.TIME, 16)
|
||||||
|
);
|
||||||
|
|
||||||
expect(job.getCoinbaseTxHex()).toContain(notify.params[2]);
|
expect(updatedBlock.version).toBe(jobTemplate.block.version);
|
||||||
expect(job.getCoinbasePrefixBuffer().toString('hex')).toBe(notify.params[2]);
|
});
|
||||||
expect(job.getCoinbaseSuffixBuffer().toString('hex')).toBe(notify.params[3]);
|
|
||||||
const clone = job.cloneCoinbaseTransaction();
|
|
||||||
const nonWitnessCoinbase = bitcoinjs.Transaction.fromHex(job.getCoinbaseTxHex());
|
|
||||||
expect(clone.ins[0].script.toString('hex')).toBe(nonWitnessCoinbase.ins[0].script.toString('hex'));
|
|
||||||
expect(clone.outs).toHaveLength(nonWitnessCoinbase.outs.length);
|
|
||||||
expect(clone.ins[0].witness[0]).toHaveLength(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should omit oversized pool identifiers from the coinbase script', () => {
|
it('should build the same header as the full block update path', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const versionMask = parseInt('00002000', 16);
|
||||||
const oversizedIdentifier = 'x'.repeat(120);
|
const nonce = parseInt('ed460d91', 16);
|
||||||
const oversizedJob = new MiningJob(
|
const extraNonce1 = '57a6f098';
|
||||||
bitcoinjs.networks.testnet,
|
const extraNonce2 = 'c708000000000000';
|
||||||
'2',
|
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
|
||||||
jobTemplate,
|
|
||||||
oversizedIdentifier
|
|
||||||
);
|
|
||||||
const coinbase = bitcoinjs.Transaction.fromHex(oversizedJob.getCoinbaseTxHex());
|
|
||||||
|
|
||||||
expect(coinbase.ins[0].script.toString()).not.toContain(oversizedIdentifier);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-33
@@ -3,11 +3,12 @@ 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 { 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_BLOCK_WEIGHT = 4000000;
|
||||||
const MAX_SCRIPT_SIZE = 100;
|
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;
|
||||||
@@ -20,18 +21,17 @@ export class MiningJob {
|
|||||||
private coinbasePart1Buffer: Buffer;
|
private coinbasePart1Buffer: Buffer;
|
||||||
private coinbasePart2Buffer: Buffer;
|
private coinbasePart2Buffer: Buffer;
|
||||||
private merkleBranchBuffers: Buffer[];
|
private merkleBranchBuffers: Buffer[];
|
||||||
private notifyStaticParams: string;
|
|
||||||
|
|
||||||
public jobTemplateId: string;
|
public jobTemplateId: string;
|
||||||
public networkDifficulty: number;
|
public networkDifficulty: number;
|
||||||
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[],
|
||||||
jobTemplate: IJobTemplate,
|
jobTemplate: IJobTemplate
|
||||||
poolIdentifier: string = process.env.POOL_IDENTIFIER || 'Public-Pool'
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
this.creation = new Date().getTime();
|
this.creation = new Date().getTime();
|
||||||
@@ -48,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(poolIdentifier);
|
// 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
|
||||||
@@ -57,23 +59,25 @@ 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
|
||||||
let 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) {
|
if (script.length > MAX_SCRIPT_SIZE) {
|
||||||
console.warn('Pool identifier is too long, removing the pool identifier');
|
console.warn('Pool identifier is too long, removing the pool identifier');
|
||||||
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.coinbaseTransaction.ins[0].script = script;
|
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) {
|
if ((this.coinbaseTransaction.weight() + jobTemplate.block.weight()) > MAX_BLOCK_WEIGHT) {
|
||||||
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
|
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
|
||||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
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
|
||||||
@@ -88,30 +92,8 @@ export class MiningJob {
|
|||||||
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
|
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
|
||||||
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
|
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
|
||||||
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
|
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
|
||||||
this.notifyStaticParams = JSON.stringify([
|
|
||||||
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
|
|
||||||
this.coinbasePart1,
|
|
||||||
this.coinbasePart2,
|
|
||||||
jobTemplate.merkle_branch,
|
|
||||||
jobTemplate.block.version.toString(16),
|
|
||||||
jobTemplate.block.bits.toString(16),
|
|
||||||
jobTemplate.block.timestamp.toString(16),
|
|
||||||
jobTemplate.blockData.clearJobs
|
|
||||||
]).slice(1);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCoinbaseTxHex(): string {
|
|
||||||
//@ts-ignore
|
|
||||||
return this.coinbaseTransaction.__toBuffer().toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCoinbasePrefixBuffer(): Buffer {
|
|
||||||
return Buffer.from(this.coinbasePart1Buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCoinbaseSuffixBuffer(): Buffer {
|
|
||||||
return Buffer.from(this.coinbasePart2Buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
|
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
|
||||||
@@ -245,7 +227,24 @@ export class MiningJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public response(jobTemplate: IJobTemplate): string {
|
public response(jobTemplate: IJobTemplate): string {
|
||||||
return `{"id":null,"method":"${eResponseMethod.MINING_NOTIFY}","params":[${JSON.stringify(this.jobId)},${this.notifyStaticParams}}\n`;
|
|
||||||
|
const job: IMiningNotify = {
|
||||||
|
id: null,
|
||||||
|
method: eResponseMethod.MINING_NOTIFY,
|
||||||
|
params: [
|
||||||
|
this.jobId,
|
||||||
|
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
|
||||||
|
this.coinbasePart1,
|
||||||
|
this.coinbasePart2,
|
||||||
|
jobTemplate.merkle_branch,
|
||||||
|
jobTemplate.block.version.toString(16),
|
||||||
|
jobTemplate.block.bits.toString(16),
|
||||||
|
jobTemplate.block.timestamp.toString(16),
|
||||||
|
jobTemplate.blockData.clearJobs
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(job) + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ 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 { effectiveJobDifficulty, 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;
|
||||||
@@ -199,27 +206,6 @@ describe('StratumV1Client', () => {
|
|||||||
expect(socket.on).toHaveBeenCalled();
|
expect(socket.on).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process batched socket messages sequentially', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
jest.spyOn(client as any, 'handleMessage').mockImplementation(async (message: string) => {
|
|
||||||
calls.push(`start:${message}`);
|
|
||||||
await Promise.resolve();
|
|
||||||
calls.push(`end:${message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketEmitter(Buffer.from('first\nsecond\n'));
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(calls).toEqual([
|
|
||||||
'start:first',
|
|
||||||
'end:first',
|
|
||||||
'start:second',
|
|
||||||
'end:second',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respond to mining.subscribe', async () => {
|
it('should respond to mining.subscribe', async () => {
|
||||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||||
|
|
||||||
@@ -288,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`));
|
||||||
@@ -349,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);
|
||||||
@@ -378,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);
|
||||||
@@ -405,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));
|
||||||
|
|
||||||
@@ -455,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));
|
||||||
|
|
||||||
@@ -571,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);
|
||||||
@@ -597,14 +597,12 @@ describe('StratumV1Client', () => {
|
|||||||
|
|
||||||
it('should submit and persist found blocks', async () => {
|
it('should submit and persist found blocks', async () => {
|
||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('SUCCESS!');
|
|
||||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
||||||
submissionHash: 'block-share'
|
submissionHash: 'block-share'
|
||||||
});
|
});
|
||||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
||||||
const resetSpy = jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
||||||
resetSpy.mockClear();
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||||
@@ -628,43 +626,6 @@ describe('StratumV1Client', () => {
|
|||||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not persist or notify rejected block submissions', async () => {
|
|
||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
|
||||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('bad-prevblk');
|
|
||||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
|
||||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
|
||||||
submissionHash: 'block-share'
|
|
||||||
});
|
|
||||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
|
||||||
const resetSpy = jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
|
||||||
resetSpy.mockClear();
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
|
||||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
|
||||||
jest.useRealTimers();
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
|
|
||||||
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
|
||||||
expect(blocksService.save).not.toHaveBeenCalled();
|
|
||||||
expect(notificationService.notifySubscribersBlockFound).not.toHaveBeenCalled();
|
|
||||||
expect(resetSpy).not.toHaveBeenCalled();
|
|
||||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('effectiveJobDifficulty', () => {
|
|
||||||
it('should use the old difficulty for jobs issued before a difficulty change', () => {
|
|
||||||
expect(effectiveJobDifficulty(9, 128, 32, 10)).toBe(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use the current difficulty for jobs issued after a difficulty change', () => {
|
|
||||||
expect(effectiveJobDifficulty(10, 128, 32, 10)).toBe(128);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+89
-108
@@ -27,32 +27,17 @@ 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;
|
||||||
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
export function effectiveJobDifficulty(
|
|
||||||
jobIdInt: number,
|
|
||||||
currentDiff: number,
|
|
||||||
oldDiff: number,
|
|
||||||
diffChangeJobId: number | null,
|
|
||||||
): number {
|
|
||||||
if (diffChangeJobId == null || !Number.isFinite(jobIdInt)) {
|
|
||||||
return currentDiff;
|
|
||||||
}
|
|
||||||
if (jobIdInt < diffChangeJobId) {
|
|
||||||
return Math.min(currentDiff, oldDiff);
|
|
||||||
}
|
|
||||||
return currentDiff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class StratumV1Client {
|
export class StratumV1Client {
|
||||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||||
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
||||||
|
|
||||||
public clientSubscription: SubscriptionMessage;
|
private clientSubscription: SubscriptionMessage;
|
||||||
private clientConfiguration: ConfigurationMessage;
|
private clientConfiguration: ConfigurationMessage;
|
||||||
private clientAuthorization: AuthorizationMessage;
|
private clientAuthorization: AuthorizationMessage;
|
||||||
private clientSuggestedDifficulty: SuggestDifficulty;
|
private clientSuggestedDifficulty: SuggestDifficulty;
|
||||||
@@ -63,19 +48,18 @@ export class StratumV1Client {
|
|||||||
private stratumInitialized = false;
|
private stratumInitialized = false;
|
||||||
private usedSuggestedDifficulty = false;
|
private usedSuggestedDifficulty = false;
|
||||||
private sessionDifficulty: number = 100000;
|
private sessionDifficulty: number = 100000;
|
||||||
private oldSessionDifficulty: number = 100000;
|
|
||||||
private diffChangeJobId: number | null = null;
|
|
||||||
|
|
||||||
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>()
|
||||||
|
|
||||||
@@ -88,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) => {
|
||||||
@@ -116,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) {
|
||||||
@@ -175,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;
|
||||||
@@ -222,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,
|
||||||
@@ -255,19 +240,19 @@ export class StratumV1Client {
|
|||||||
this.clientAuthorization = authorizationMessage;
|
this.clientAuthorization = authorizationMessage;
|
||||||
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
||||||
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
}
|
}
|
||||||
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
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;
|
||||||
@@ -297,7 +282,6 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
||||||
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return;
|
return;
|
||||||
@@ -321,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;
|
||||||
}
|
}
|
||||||
@@ -378,7 +362,7 @@ export class StratumV1Client {
|
|||||||
&& this.clientAuthorization != null
|
&& this.clientAuthorization != null
|
||||||
&& this.stratumInitialized == false) {
|
&& this.stratumInitialized == false) {
|
||||||
|
|
||||||
this.initStratum();
|
await this.initStratum();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +379,6 @@ export class StratumV1Client {
|
|||||||
switch (this.clientSubscription.userAgent) {
|
switch (this.clientSubscription.userAgent) {
|
||||||
case 'cpuminer': {
|
case 'cpuminer': {
|
||||||
this.sessionDifficulty = 0.1;
|
this.sessionDifficulty = 0.1;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,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;
|
||||||
@@ -475,11 +447,11 @@ export class StratumV1Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const job = new MiningJob(
|
const job = new MiningJob(
|
||||||
|
this.configService,
|
||||||
network,
|
network,
|
||||||
this.stratumV1JobsService.getNextId(),
|
this.stratumV1JobsService.getNextId(),
|
||||||
payoutInformation,
|
payoutInformation,
|
||||||
jobTemplate,
|
jobTemplate
|
||||||
this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.stratumV1JobsService.addJob(job);
|
this.stratumV1JobsService.addJob(job);
|
||||||
@@ -489,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})`)
|
||||||
@@ -497,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,
|
||||||
@@ -534,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) {
|
||||||
@@ -586,15 +558,9 @@ export class StratumV1Client {
|
|||||||
const { submissionDifficulty } = this.calculateDifficulty(header);
|
const { submissionDifficulty } = this.calculateDifficulty(header);
|
||||||
|
|
||||||
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
||||||
const effectiveDiff = effectiveJobDifficulty(
|
|
||||||
parseInt(submission.jobId, 16),
|
|
||||||
this.sessionDifficulty,
|
|
||||||
this.oldSessionDifficulty,
|
|
||||||
this.diffChangeJobId,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
if (submissionDifficulty >= effectiveDiff) {
|
if (submissionDifficulty >= this.sessionDifficulty) {
|
||||||
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return false;
|
return false;
|
||||||
@@ -612,42 +578,53 @@ export class StratumV1Client {
|
|||||||
);
|
);
|
||||||
const blockHex = updatedJobBlock.toHex(false);
|
const blockHex = updatedJobBlock.toHex(false);
|
||||||
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||||
if (result === 'SUCCESS!') {
|
await this.blocksService.save({
|
||||||
await this.blocksService.save({
|
height: jobTemplate.blockData.height,
|
||||||
height: jobTemplate.blockData.height,
|
minerAddress: this.clientAuthorization.address,
|
||||||
minerAddress: this.clientAuthorization.address,
|
worker: this.clientAuthorization.worker,
|
||||||
worker: this.clientAuthorization.worker,
|
sessionId: this.extraNonceAndSessionId,
|
||||||
sessionId: this.extraNonceAndSessionId,
|
blockData: blockHex
|
||||||
blockData: blockHex
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
|
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
|
||||||
|
//success
|
||||||
|
if (result == null) {
|
||||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||||
} else {
|
|
||||||
console.warn(`[Block submit rejected at height ${jobTemplate.blockData.height}]: ${result}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.ensureClientEntity();
|
await this.ensureClientEntity();
|
||||||
try {
|
try {
|
||||||
await this.statistics.addShares(this.clientEntity, effectiveDiff);
|
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(
|
||||||
@@ -676,11 +653,6 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
if (targetDiff != this.sessionDifficulty) {
|
if (targetDiff != this.sessionDifficulty) {
|
||||||
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
|
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
|
||||||
if (!Number.isFinite(targetDiff)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
this.diffChangeJobId = parseInt(this.stratumV1JobsService.getNextId(), 16);
|
|
||||||
this.sessionDifficulty = targetDiff;
|
this.sessionDifficulty = targetDiff;
|
||||||
|
|
||||||
const data = JSON.stringify({
|
const data = JSON.stringify({
|
||||||
@@ -690,15 +662,24 @@ export class StratumV1Client {
|
|||||||
}) + '\n';
|
}) + '\n';
|
||||||
|
|
||||||
|
|
||||||
const success = await this.write(data);
|
await this.socket.write(data);
|
||||||
if (!success) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,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();
|
||||||
@@ -838,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -81,10 +84,10 @@ describe('StratumV1ClientStatistics', () => {
|
|||||||
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should lower difficulty when a miner has not submitted shares for more than a minute', () => {
|
it('should lower difficulty when a miner has not submitted shares for several minutes', () => {
|
||||||
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
|
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
|
||||||
|
|
||||||
expect(statistics.getSuggestedDifficulty(64)).toBe(12);
|
expect(statistics.getSuggestedDifficulty(64)).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should increase difficulty for rapid submissions', async () => {
|
it('should increase difficulty for rapid submissions', async () => {
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,77 +39,89 @@ 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()) / 1000 > 60) {
|
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
|
||||||
return this.nearestDifficultyStep(clientDifficulty / 6);
|
return this.nearestPowerOfTwo(clientDifficulty / 6);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -116,45 +132,39 @@ export class StratumV1ClientStatistics {
|
|||||||
return pre;
|
return pre;
|
||||||
}, 0);
|
}, 0);
|
||||||
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
||||||
if (diffSeconds <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const difficultyPerSecond = sum / diffSeconds;
|
const difficultyPerSecond = sum / diffSeconds;
|
||||||
|
|
||||||
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
const targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND;
|
||||||
if (!Number.isFinite(difficultyPerSecond) || !Number.isFinite(targetDifficulty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
||||||
return this.nearestDifficultyStep(targetDifficulty)
|
return this.nearestPowerOfTwo(targetDifficulty)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private nearestDifficultyStep(val: number): number {
|
private nearestPowerOfTwo(val): number {
|
||||||
if (val === 0) {
|
if (val === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (val < MIN_DIFF) {
|
if (val < MIN_DIFF) {
|
||||||
return MIN_DIFF;
|
return MIN_DIFF;
|
||||||
}
|
}
|
||||||
|
let x = val | (val >> 1);
|
||||||
const exponent = Math.floor(Math.log2(val));
|
x = x | (x >> 2);
|
||||||
const lower = 2 ** exponent;
|
x = x | (x >> 4);
|
||||||
const middle = lower + lower / 2;
|
x = x | (x >> 8);
|
||||||
const upper = lower * 2;
|
x = x | (x >> 16);
|
||||||
|
x = x | (x >> 32);
|
||||||
const distances = [
|
const res = x - (x >> 1);
|
||||||
{ value: lower, diff: Math.abs(val - lower) },
|
if (res == 0 && val * 100 < MIN_DIFF) {
|
||||||
{ value: middle, diff: Math.abs(val - middle) },
|
return MIN_DIFF;
|
||||||
{ value: upper, diff: Math.abs(val - upper) },
|
}
|
||||||
];
|
if (res == 0) {
|
||||||
|
return this.nearestPowerOfTwo(val * 100) / 100;
|
||||||
distances.sort((a, b) => a.diff - b.diff);
|
}
|
||||||
return distances[0].value;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -58,29 +58,6 @@ describe('MiningSubmitMessage', () => {
|
|||||||
|
|
||||||
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hash submit fields without concatenation ambiguity', () => {
|
|
||||||
const first = plainToInstance(
|
|
||||||
MiningSubmitMessage,
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
method: 'mining.submit',
|
|
||||||
params: ['worker', 'e', '9902000000000000', 'd', 'bc', 'a']
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const second = plainToInstance(
|
|
||||||
MiningSubmitMessage,
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
method: 'mining.submit',
|
|
||||||
params: ['worker', 'e', '9902000000000000', 'd', 'c', 'ab']
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.versionMask + first.nonce + first.extraNonce2 + first.ntime + first.jobId)
|
|
||||||
.toBe(second.versionMask + second.nonce + second.extraNonce2 + second.ntime + second.jobId);
|
|
||||||
expect(first.hash()).not.toBe(second.hash());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,14 +68,8 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hash(): string{
|
public hash(): string{
|
||||||
const canonical = JSON.stringify({
|
const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId);
|
||||||
versionMask: this.versionMask ?? '',
|
return bitcoinjs.crypto.hash256(buffer).toString('base64');
|
||||||
nonce: this.nonce,
|
|
||||||
extraNonce2: this.extraNonce2,
|
|
||||||
ntime: this.ntime,
|
|
||||||
jobId: this.jobId,
|
|
||||||
});
|
|
||||||
return bitcoinjs.crypto.hash256(Buffer.from(canonical)).toString('base64');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-61
@@ -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);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,43 @@
|
|||||||
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';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
@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');
|
||||||
let user = this.configService.get('BITCOIN_RPC_USER');
|
let user = this.configService.get('BITCOIN_RPC_USER');
|
||||||
let 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');
|
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
|
||||||
|
|
||||||
if (cookiefile != null && cookiefile !== '') {
|
if (cookiefile != undefined && cookiefile != '') {
|
||||||
const [cookieUser, cookiePass] = fs.readFileSync(cookiefile).toString().trim().split(':');
|
const cookie = fs.readFileSync(cookiefile).toString().trim().split(':');
|
||||||
user = cookieUser;
|
|
||||||
pass = cookiePass;
|
user = cookie[0];
|
||||||
|
pass = cookie[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseURL = this.buildRpcUrl(url, port);
|
const baseURL = this.buildRpcUrl(url, port);
|
||||||
@@ -58,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();
|
if (this.configService.get('BITCOIN_ZMQ_HOST')) {
|
||||||
|
|
||||||
console.log(`MASTER? ${process.env.MASTER}`)
|
|
||||||
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', [
|
||||||
@@ -135,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -109,13 +139,28 @@ export class StratumV1JobsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
tap((data) => {
|
tap((data) => {
|
||||||
this.cleanup(data.blockData.clearJobs);
|
if (data.blockData.clearJobs) {
|
||||||
|
this.blocks = {};
|
||||||
|
this.jobs = {};
|
||||||
|
}else{
|
||||||
|
const now = new Date().getTime();
|
||||||
|
// Delete old templates (5 minutes)
|
||||||
|
for(const templateId in this.blocks){
|
||||||
|
if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){
|
||||||
|
delete this.blocks[templateId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete old jobs (5 minutes)
|
||||||
|
for (const jobId in this.jobs) {
|
||||||
|
if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){
|
||||||
|
delete this.jobs[jobId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
||||||
@@ -140,32 +185,6 @@ export class StratumV1JobsService {
|
|||||||
return this.blocks[jobTemplateId];
|
return this.blocks[jobTemplateId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public cleanup(clearJobs: boolean, now: number = Date.now()) {
|
|
||||||
if (clearJobs) {
|
|
||||||
this.blocks = {};
|
|
||||||
this.jobs = {};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let templatesDeleted = 0;
|
|
||||||
let jobsDeleted = 0;
|
|
||||||
|
|
||||||
for (const templateId in this.blocks) {
|
|
||||||
if (now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)) {
|
|
||||||
delete this.blocks[templateId];
|
|
||||||
templatesDeleted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const jobId in this.jobs) {
|
|
||||||
if (now - this.jobs[jobId].creation > (1000 * 60 * 5)) {
|
|
||||||
delete this.jobs[jobId];
|
|
||||||
jobsDeleted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//console.log(`Deleted ${templatesDeleted} templates and ${jobsDeleted} jobs.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
public addJob(job: MiningJob) {
|
public addJob(job: MiningJob) {
|
||||||
this.jobs[job.jobId] = job;
|
this.jobs[job.jobId] = job;
|
||||||
this.latestJobId++;
|
this.latestJobId++;
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ 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 originalMaxConnectionsPerListener = process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
|
||||||
|
|
||||||
let service: StratumV1Service;
|
let service: StratumV1Service;
|
||||||
let clientService;
|
let clientService;
|
||||||
let consoleLogSpy: jest.SpyInstance;
|
let consoleLogSpy: jest.SpyInstance;
|
||||||
|
let consoleWarnSpy: jest.SpyInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
@@ -23,49 +25,106 @@ 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);
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
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_MAX_CONNECTIONS_PER_LISTENER', originalMaxConnectionsPerListener);
|
||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
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', () => {
|
||||||
|
const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
|
||||||
|
(service as any).listeners.push({
|
||||||
|
port: 3333,
|
||||||
|
server: { close },
|
||||||
|
paused: false
|
||||||
|
});
|
||||||
|
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(5000);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect((service as any).listeners[0].paused).toBe(true);
|
||||||
|
expect((service as any).listeners[0].server).toBeNull();
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Pausing Stratum accepts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resume listeners after consecutive healthy backpressure checks', () => {
|
||||||
|
(service as any).listeners.push({
|
||||||
|
port: 3333,
|
||||||
|
server: null,
|
||||||
|
paused: true
|
||||||
|
});
|
||||||
|
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(50);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureResumeEventLoopP95Ms').mockReturnValue(250);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureResumeRssMb').mockReturnValue(Number.MAX_SAFE_INTEGER);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureHealthyChecks').mockReturnValue(2);
|
||||||
|
const listenSpy = jest.spyOn(service as any, 'listen').mockImplementation((listener: any) => {
|
||||||
|
listener.server = {};
|
||||||
|
listener.paused = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
expect(listenSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
|
||||||
|
expect(listenSpy).toHaveBeenCalledWith((service as any).listeners[0]);
|
||||||
|
expect((service as any).listeners[0].paused).toBe(false);
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Resuming Stratum accepts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap listener connections and drop excess cluster connections', () => {
|
||||||
|
process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER = '250';
|
||||||
|
const server = {} as any;
|
||||||
|
|
||||||
|
(service as any).configureConnectionLimit(server);
|
||||||
|
|
||||||
|
expect(server.maxConnections).toBe(250);
|
||||||
|
expect(server.dropMaxConnection).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow high-volume pools by default', () => {
|
||||||
|
delete process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
|
||||||
|
|
||||||
|
expect((service as any).getMaxConnectionsPerListener()).toBe(10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
function restoreEnv(key: string, value: string | undefined) {
|
function restoreEnv(key: string, value: string | undefined) {
|
||||||
|
|||||||
+201
-137
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Server, Socket } from 'net';
|
import { Server, Socket } from 'net';
|
||||||
|
import { monitorEventLoopDelay } from 'perf_hooks';
|
||||||
|
|
||||||
import { StratumV1Client } from '../models/StratumV1Client';
|
import { StratumV1Client } from '../models/StratumV1Client';
|
||||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||||
@@ -8,22 +9,32 @@ 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';
|
interface StratumListenerState {
|
||||||
import { TlsOptions, TLSSocket, createServer } from 'tls';
|
port: number;
|
||||||
import * as path from 'path';
|
server: Server | null;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS = 5000;
|
||||||
|
const DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS = 2000;
|
||||||
|
const DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS = 250;
|
||||||
|
const DEFAULT_BACKPRESSURE_RSS_MB = 2500;
|
||||||
|
const DEFAULT_BACKPRESSURE_RESUME_RSS_MB = 2000;
|
||||||
|
const DEFAULT_BACKPRESSURE_HEALTHY_CHECKS = 3;
|
||||||
|
const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000;
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StratumV1Service implements OnModuleInit {
|
export class StratumV1Service implements OnModuleInit {
|
||||||
|
|
||||||
private socketTimeout = 0;
|
private readonly listeners: StratumListenerState[] = [];
|
||||||
private emptySocket = 0;
|
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||||
private normalClosure = 0;
|
private backpressureMonitor: NodeJS.Timeout | null = null;
|
||||||
private errorClosure = 0;
|
private healthyBackpressureChecks = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||||
@@ -33,124 +44,46 @@ 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(() => {
|
this.startBackpressureMonitor();
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startSocketServer(port: number) {
|
private startSocketServer(port: number) {
|
||||||
const server = new Server(async (socket: Socket) => {
|
const listener: StratumListenerState = {
|
||||||
// Set 15-minute timeout
|
port,
|
||||||
socket.setTimeout(1000 * 60 * 15);
|
server: null,
|
||||||
|
paused: false
|
||||||
const client = new StratumV1Client(
|
|
||||||
socket,
|
|
||||||
this.stratumV1JobsService,
|
|
||||||
this.bitcoinRpcService,
|
|
||||||
this.clientService,
|
|
||||||
this.clientStatisticsService,
|
|
||||||
this.notificationService,
|
|
||||||
this.blocksService,
|
|
||||||
this.configService,
|
|
||||||
this.addressSettingsService
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`Stratum server is listening on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private startSecureSocketServer(port: number) {
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
|
this.listeners.push(listener);
|
||||||
|
this.listen(listener);
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
|
private createSocketServer(): Server {
|
||||||
// Set 15-minute timeout
|
const server = new Server(async (socket: Socket) => {
|
||||||
socket.setTimeout(1000 * 60 * 15);
|
|
||||||
|
//5 min
|
||||||
|
socket.setTimeout(1000 * 60 * 5);
|
||||||
|
|
||||||
const client = new StratumV1Client(
|
const client = new StratumV1Client(
|
||||||
socket,
|
socket,
|
||||||
@@ -161,53 +94,184 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
this.notificationService,
|
this.notificationService,
|
||||||
this.blocksService,
|
this.blocksService,
|
||||||
this.configService,
|
this.configService,
|
||||||
this.addressSettingsService
|
this.addressSettingsService,
|
||||||
|
this.externalSharesService
|
||||||
);
|
);
|
||||||
|
|
||||||
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) => {
|
||||||
console.error(`Server error: ${err.message}`);
|
console.error(`Server error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
this.configureConnectionLimit(server);
|
||||||
|
|
||||||
server.listen(port, () => {
|
return server;
|
||||||
console.log(`Stratum TLS server is listening on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private listen(listener: StratumListenerState) {
|
||||||
|
if (listener.server != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = this.createSocketServer();
|
||||||
|
listener.server = server;
|
||||||
|
listener.paused = false;
|
||||||
|
|
||||||
|
server.listen(listener.port, () => {
|
||||||
|
console.log(`Stratum server is listening on port ${listener.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBackpressureMonitor() {
|
||||||
|
if (this.isBackpressureDisabled() || this.backpressureMonitor != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventLoopDelay.enable();
|
||||||
|
this.backpressureMonitor = setInterval(() => {
|
||||||
|
this.checkBackpressure();
|
||||||
|
}, this.getBackpressureCheckIntervalMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkBackpressure() {
|
||||||
|
const eventLoopP95Ms = this.getEventLoopP95Ms();
|
||||||
|
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
||||||
|
const overloaded = eventLoopP95Ms >= this.getBackpressureEventLoopP95Ms()
|
||||||
|
|| rssMb >= this.getBackpressureRssMb();
|
||||||
|
const paused = this.listeners.some(listener => listener.paused);
|
||||||
|
|
||||||
|
if (overloaded) {
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
if (!paused) {
|
||||||
|
this.pauseAccepting(eventLoopP95Ms, rssMb);
|
||||||
|
}
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = eventLoopP95Ms <= this.getBackpressureResumeEventLoopP95Ms()
|
||||||
|
&& rssMb <= this.getBackpressureResumeRssMb();
|
||||||
|
if (!healthy) {
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthyBackpressureChecks++;
|
||||||
|
if (this.healthyBackpressureChecks >= this.getBackpressureHealthyChecks()) {
|
||||||
|
this.resumeAccepting(eventLoopP95Ms, rssMb);
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pauseAccepting(eventLoopP95Ms: number, rssMb: number) {
|
||||||
|
console.warn(`Pausing Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
if (listener.paused || listener.server == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = listener.server;
|
||||||
|
listener.server = null;
|
||||||
|
listener.paused = true;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error != null) {
|
||||||
|
console.error(`Error while pausing Stratum listener on port ${listener.port}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resumeAccepting(eventLoopP95Ms: number, rssMb: number) {
|
||||||
|
console.warn(`Resuming Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
if (!listener.paused || listener.server != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listen(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private configureConnectionLimit(server: Server) {
|
||||||
|
server.maxConnections = this.getMaxConnectionsPerListener();
|
||||||
|
(server as Server & { dropMaxConnection: boolean }).dropMaxConnection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEventLoopP95Ms() {
|
||||||
|
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBackpressureDisabled() {
|
||||||
|
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureCheckIntervalMs() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_CHECK_INTERVAL_MS', DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureEventLoopP95Ms() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureResumeEventLoopP95Ms() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureRssMb() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RSS_MB', DEFAULT_BACKPRESSURE_RSS_MB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureResumeRssMb() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RESUME_RSS_MB', DEFAULT_BACKPRESSURE_RESUME_RSS_MB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureHealthyChecks() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_HEALTHY_CHECKS', DEFAULT_BACKPRESSURE_HEALTHY_CHECKS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxConnectionsPerListener() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', DEFAULT_MAX_CONNECTIONS_PER_LISTENER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStratumPort() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_PORT', 3333);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPositiveIntegerEnv(key: string, fallback: number) {
|
||||||
|
const configured = parseInt(process.env[key], 10);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user