mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
143
Commits
master
...
8424638081
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8424638081 | ||
|
|
8584ab7efb | ||
|
|
703546719c | ||
|
|
d9fa933825 | ||
|
|
d6d3727119 | ||
|
|
8709de3d83 | ||
|
|
0d15d4fb70 | ||
|
|
5f66d082ea | ||
|
|
93f09ae889 | ||
|
|
216fa74d99 | ||
|
|
355437df3c | ||
|
|
cc0ca3979e | ||
|
|
9bf3dde862 | ||
|
|
8b2bea702a | ||
|
|
63a1618f26 | ||
|
|
1b0f3f52d8 | ||
|
|
78c00fd25c | ||
|
|
dd45c60eff | ||
|
|
0481dfda6e | ||
|
|
6f6abe5e2d | ||
|
|
ff84d8284f | ||
|
|
cdf6eb807b | ||
|
|
d9c89347e1 | ||
|
|
c1d8341c22 | ||
|
|
f39c0dc059 | ||
|
|
3dab14e3b7 | ||
|
|
5060345809 | ||
|
|
b44f09848e | ||
|
|
9b5402f1b0 | ||
|
|
17881f70d6 | ||
|
|
e9949f4038 | ||
|
|
7c3b31f7b3 | ||
|
|
78deb3a375 | ||
|
|
c5c8810da6 | ||
|
|
8584c442dc | ||
|
|
fd24807c55 | ||
|
|
bd5333a725 | ||
|
|
419eb09853 | ||
|
|
74f5783dc3 | ||
|
|
34a27d0f2f | ||
|
|
d8e087f720 | ||
|
|
34eb0ca0f2 | ||
|
|
a33d7e07b3 | ||
|
|
f8d1c61c1c | ||
|
|
326a5a7b33 | ||
|
|
903aa2628c | ||
|
|
19f49b74e6 | ||
|
|
864867ffd4 | ||
|
|
f465b837f5 | ||
|
|
637d30f1a5 | ||
|
|
df87214de9 | ||
|
|
66ce822faa | ||
|
|
78ef48b805 | ||
|
|
b545e74d4f | ||
|
|
5702e20dc0 | ||
|
|
74dd5ba66f | ||
|
|
674b02ca9d | ||
|
|
40ee610600 | ||
|
|
7e0de263f2 | ||
|
|
65232da901 | ||
|
|
220a1d1558 | ||
|
|
ae7ed021a3 | ||
|
|
056fe7af4f | ||
|
|
912ae9fe85 | ||
|
|
d513dab932 | ||
|
|
3a1f3bba10 | ||
|
|
8fdf1e839e | ||
|
|
76d92d84b4 | ||
|
|
4928f34daf | ||
|
|
88e7f5f3fb | ||
|
|
d97843af3d | ||
|
|
c1ab6a152e | ||
|
|
717a5c7878 | ||
|
|
6038af8448 | ||
|
|
d6863fad28 | ||
|
|
b9b5fe2cf5 | ||
|
|
9db0afd9df | ||
|
|
fb8715c23d | ||
|
|
ff6d446c12 | ||
|
|
02d718934d | ||
|
|
d40742ef71 | ||
|
|
38ecbdc7ec | ||
|
|
8905409880 | ||
|
|
2bfbc2d79a | ||
|
|
2ee82a269c | ||
|
|
fe4d58a60d | ||
|
|
b858a594b8 | ||
|
|
3e4cb4ff5d | ||
|
|
14f95a7fa4 | ||
|
|
522ef126d9 | ||
|
|
0c34f23284 | ||
|
|
e52d14b0a9 | ||
|
|
3727773a2e | ||
|
|
ad3035b908 | ||
|
|
3e279ee662 | ||
|
|
deeb40967e | ||
|
|
f1221651f6 | ||
|
|
32a604e745 | ||
|
|
6442bb45e0 | ||
|
|
f2930681d3 | ||
|
|
3d3d1a55a8 | ||
|
|
01adb0a997 | ||
|
|
5ead6c605b | ||
|
|
4fd8d9ceb6 | ||
|
|
9d4c3ad35e | ||
|
|
aab69b65fc | ||
|
|
9895e80a01 | ||
|
|
9617ec5685 | ||
|
|
aefb180fb4 | ||
|
|
87508bfb69 | ||
|
|
39e537a00c | ||
|
|
9ecfc68155 | ||
|
|
3e2de62259 | ||
|
|
8e977c21a1 | ||
|
|
5fd9f98807 | ||
|
|
6bc42defef | ||
|
|
bbc14e2272 | ||
|
|
fc767fa770 | ||
|
|
40e32d6e1a | ||
|
|
df6ec07c58 | ||
|
|
df245f396e | ||
|
|
42fc863c7c | ||
|
|
e46bb24155 | ||
|
|
cb4a173733 | ||
|
|
83c769849d | ||
|
|
ee5fea1df5 | ||
|
|
06acad6770 | ||
|
|
f7b22bb310 | ||
|
|
6cf6a007d9 | ||
|
|
5b71632f8a | ||
|
|
c5ff8521ac | ||
|
|
53ee510a1e | ||
|
|
7be554a358 | ||
|
|
eb5d434157 | ||
|
|
893c7c2003 | ||
|
|
4736eadf7b | ||
|
|
1089e6210c | ||
|
|
017978d250 | ||
|
|
5a17528b8f | ||
|
|
0b147ea5db | ||
|
|
bffcf6c74c | ||
|
|
96a38f9d9b | ||
|
|
39c4c033e2 |
+11
-6
@@ -15,7 +15,10 @@ BITCOIN_RPC_TIMEOUT=10000
|
||||
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
|
||||
|
||||
API_PORT=3334
|
||||
STRATUM_PORT=3333
|
||||
STRATUM_PORTS=3333,3332,3331,3330
|
||||
|
||||
STRATUM_SECURE=true
|
||||
SECURE_STRATUM_PORTS=4333,4332,4331,4330
|
||||
|
||||
#optional telegram bot
|
||||
#TELEGRAM_BOT_TOKEN=
|
||||
@@ -32,9 +35,11 @@ NETWORK=mainnet
|
||||
|
||||
API_SECURE=false
|
||||
|
||||
ENABLE_SOLO=false
|
||||
ENABLE_PROXY=true
|
||||
#postgresql
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=
|
||||
|
||||
BRAIINS_ACCESS_TOKEN=
|
||||
|
||||
PROXY_PORT=3333
|
||||
PRODUCTION=false
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,10 +25,3 @@ DEV_FEE_ADDRESS=
|
||||
NETWORK=mainnet
|
||||
|
||||
API_SECURE=false
|
||||
|
||||
ENABLE_SOLO=true
|
||||
ENABLE_PROXY=false
|
||||
|
||||
BRAIINS_ACCESS_TOKEN=
|
||||
|
||||
PROXY_PORT=3333
|
||||
|
||||
@@ -25,10 +25,3 @@ DEV_FEE_ADDRESS=
|
||||
NETWORK=regtest
|
||||
|
||||
API_SECURE=false
|
||||
|
||||
ENABLE_SOLO=true
|
||||
ENABLE_PROXY=false
|
||||
|
||||
BRAIINS_ACCESS_TOKEN=
|
||||
|
||||
PROXY_PORT=3333
|
||||
|
||||
@@ -25,10 +25,3 @@ DEV_FEE_ADDRESS=
|
||||
NETWORK=testnet
|
||||
|
||||
API_SECURE=false
|
||||
|
||||
ENABLE_SOLO=true
|
||||
ENABLE_PROXY=false
|
||||
|
||||
BRAIINS_ACCESS_TOKEN=
|
||||
|
||||
PROXY_PORT=3333
|
||||
|
||||
Generated
+8075
-7924
File diff suppressed because it is too large
Load Diff
+44
-41
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"postinstall": "patch-package",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@@ -20,59 +21,61 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/cache-manager": "^2.0.1",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.3.2",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/platform-fastify": "^9.4.2",
|
||||
"@nestjs/schedule": "^3.0.1",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"axios": "^1.4.0",
|
||||
"big.js": "^6.2.1",
|
||||
"bitcoin-address-validation": "^2.2.1",
|
||||
"bitcoinjs-lib": "^6.1.3",
|
||||
"bitcoinjs-message": "^2.2.0",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/cache-manager": "^3.1.2",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"bitcoin-address-validation": "^2.2.3",
|
||||
"bitcoinjs-lib": "^6.1.7",
|
||||
"bs58": "^5.0.0",
|
||||
"cache-manager": "^5.2.3",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"discord.js": "^14.11.0",
|
||||
"class-validator": "^0.14.4",
|
||||
"discord.js": "^14.26.4",
|
||||
"merkle-lib": "^2.0.10",
|
||||
"node-telegram-bot-api": "^0.61.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rpc-bitcoin": "^2.0.0",
|
||||
"rxjs": "^7.2.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"tiny-secp256k1": "^2.2.3",
|
||||
"typeorm": "^0.3.17",
|
||||
"pg": "^8.20.0",
|
||||
"pg-pubsub": "^0.8.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"tiny-secp256k1": "^2.2.4",
|
||||
"typeorm": "^0.3.28",
|
||||
"zeromq": "6.0.0-beta.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/big.js": "^6.1.6",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "^18.16.12",
|
||||
"@types/node-telegram-bot-api": "^0.61.6",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^18.19.130",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "^2.3.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"jest": "^29.7.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "^3.8.3",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@fastify/middie": "^9.3.2",
|
||||
"fastify": "^5.8.5",
|
||||
"path-to-regexp": "^8.4.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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,9 +11,12 @@ export class AddressSettingsEntity extends TrackedEntity {
|
||||
@Column({ default: 0 })
|
||||
shares: number;
|
||||
|
||||
@Column({ type: 'real', default: 0 })
|
||||
@Column({ type: 'decimal', default: 0 })
|
||||
bestDifficulty: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
bestDifficultyUserAgent: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
miscCoinbaseScriptData: string;
|
||||
|
||||
|
||||
@@ -15,35 +15,73 @@ export class AddressSettingsService {
|
||||
}
|
||||
|
||||
public async getSettings(address: string, createIfNotFound: boolean) {
|
||||
const settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
||||
if (createIfNotFound == true && settings == null) {
|
||||
return await this.createNew(address);
|
||||
let settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
||||
if (createIfNotFound === true && settings == null) {
|
||||
await this.addressSettingsRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AddressSettingsEntity)
|
||||
.values({ address })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
public async updateBestDifficulty(address: string, bestDifficulty: number) {
|
||||
return await this.addressSettingsRepository.update({ address }, { bestDifficulty });
|
||||
public async updateBestDifficulty(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
|
||||
return await this.addressSettingsRepository.update({ address }, { bestDifficulty, bestDifficultyUserAgent });
|
||||
}
|
||||
|
||||
public async updateBestDifficultyIfHigher(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
|
||||
await this.addressSettingsRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AddressSettingsEntity)
|
||||
.values({ address })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
return await this.addressSettingsRepository
|
||||
.createQueryBuilder()
|
||||
.update(AddressSettingsEntity)
|
||||
.set({ bestDifficulty, bestDifficultyUserAgent })
|
||||
.where('address = :address', { address })
|
||||
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async createNew(address: string) {
|
||||
return await this.addressSettingsRepository.save({ address });
|
||||
}
|
||||
|
||||
public async addShares(address: string, shares: number) {
|
||||
return await this.addressSettingsRepository.createQueryBuilder()
|
||||
// public async addShares(address: string, shares: number) {
|
||||
// return await this.addressSettingsRepository.createQueryBuilder()
|
||||
// .update(AddressSettingsEntity)
|
||||
// .set({
|
||||
// shares: () => `"shares" + ${shares}` // Use the actual value of shares here
|
||||
// })
|
||||
// .where('address = :address', { address })
|
||||
// .execute();
|
||||
// }
|
||||
|
||||
public async resetBestDifficultyAndShares() {
|
||||
return await this.addressSettingsRepository
|
||||
.createQueryBuilder()
|
||||
.update(AddressSettingsEntity)
|
||||
.set({
|
||||
shares: () => `"shares" + ${shares}` // Use the actual value of shares here
|
||||
shares: 0,
|
||||
bestDifficulty: 0
|
||||
})
|
||||
.where('address = :address', { address })
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async resetBestDifficultyAndShares() {
|
||||
return await this.addressSettingsRepository.update({}, {
|
||||
shares: 0,
|
||||
bestDifficulty: 0
|
||||
});
|
||||
public async getHighScores() {
|
||||
return await this.addressSettingsRepository.createQueryBuilder()
|
||||
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
|
||||
.orderBy('"bestDifficulty"', 'DESC')
|
||||
.limit(10)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Column, Entity } from 'typeorm';
|
||||
|
||||
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
|
||||
import { TrackedEntity } from '../utils/TrackedEntity.entity';
|
||||
|
||||
@Entity()
|
||||
export class BlocksEntity extends TrackedEntity {
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
@PrimaryGeneratedBigIntColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Column, Entity, Index, ManyToOne } from 'typeorm';
|
||||
|
||||
import { ClientEntity } from '../client/client.entity';
|
||||
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
|
||||
import { TrackedEntity } from '../utils/TrackedEntity.entity';
|
||||
|
||||
@Entity()
|
||||
//Index for getHashRateForSession
|
||||
@Index(["address", "clientName", "sessionId"])
|
||||
//Index for statistics save
|
||||
@Index(["address", "clientName", "sessionId", "time"])
|
||||
@Index(["clientId", "time"])
|
||||
export class ClientStatisticsEntity extends TrackedEntity {
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
@PrimaryGeneratedBigIntColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 62, type: 'varchar' })
|
||||
@@ -22,14 +22,27 @@ export class ClientStatisticsEntity extends TrackedEntity {
|
||||
sessionId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'integer' })
|
||||
@Column({ type: 'bigint' })
|
||||
time: number;
|
||||
|
||||
@Column({ type: 'real' })
|
||||
@Column({ type: 'decimal' })
|
||||
shares: number;
|
||||
|
||||
@Column({ default: 0, type: 'integer' })
|
||||
@Column({ default: 0, type: 'bigint' })
|
||||
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 { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { ClientStatisticsEntity } from './client-statistics.entity';
|
||||
|
||||
@@ -8,38 +8,90 @@ import { ClientStatisticsEntity } from './client-statistics.entity';
|
||||
@Injectable()
|
||||
export class ClientStatisticsService {
|
||||
|
||||
private bulkAsyncUpdates: {
|
||||
[key: string]: Partial<ClientStatisticsEntity>
|
||||
} = {};
|
||||
|
||||
constructor(
|
||||
|
||||
|
||||
@InjectDataSource()
|
||||
private dataSource: DataSource,
|
||||
@InjectRepository(ClientStatisticsEntity)
|
||||
private clientStatisticsRepository: Repository<ClientStatisticsEntity>,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public async save(clientStatistic: Partial<ClientStatisticsEntity>) {
|
||||
// Attempt to update the existing record
|
||||
const updateResult = await this.clientStatisticsRepository
|
||||
.createQueryBuilder()
|
||||
.update(ClientStatisticsEntity)
|
||||
.set({
|
||||
shares: () => `"shares" + :sharesIncrement`,
|
||||
acceptedCount: () => `"acceptedCount" + 1`
|
||||
})
|
||||
.where('address = :address AND clientName = :clientName AND sessionId = :sessionId AND time = :time', {
|
||||
address: clientStatistic.address,
|
||||
clientName: clientStatistic.clientName,
|
||||
sessionId: clientStatistic.sessionId,
|
||||
time: clientStatistic.time,
|
||||
sharesIncrement: clientStatistic.shares
|
||||
})
|
||||
.execute();
|
||||
// public async update(clientStatistic: Partial<ClientStatisticsEntity>) {
|
||||
|
||||
// Check if the update affected any rows
|
||||
if (updateResult.affected === 0) {
|
||||
// If no rows were updated, insert a new record
|
||||
await this.clientStatisticsRepository.insert(clientStatistic);
|
||||
// await this.clientStatisticsRepository.update({ clientId: clientStatistic.clientId, 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>) {
|
||||
await this.clientStatisticsRepository.insert(clientStatistic);
|
||||
}
|
||||
|
||||
public async deleteOldStatistics() {
|
||||
@@ -53,57 +105,7 @@ export class ClientStatisticsService {
|
||||
.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) {
|
||||
|
||||
@@ -111,12 +113,12 @@ export class ClientStatisticsService {
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
time label,
|
||||
time AS label,
|
||||
(SUM(shares) * 4294967296) / 600 AS data
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.address = ? AND entry.time > ${yesterday.getTime()}
|
||||
entry.address = $1 AND entry.time > $2
|
||||
GROUP BY
|
||||
time
|
||||
ORDER BY
|
||||
@@ -125,10 +127,10 @@ export class ClientStatisticsService {
|
||||
|
||||
`;
|
||||
|
||||
const result = await this.clientStatisticsRepository.query(query, [address]);
|
||||
const result = await this.clientStatisticsRepository.query(query, [address, yesterday.getTime()]);
|
||||
|
||||
return result.map(res => {
|
||||
res.label = new Date(res.label).toISOString();
|
||||
res.label = new Date(parseInt(res.label)).toISOString();
|
||||
return res;
|
||||
}).slice(0, result.length - 1);
|
||||
|
||||
@@ -146,7 +148,7 @@ export class ClientStatisticsService {
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.address = ? AND entry.clientName = ? AND entry.time > ${oneHour.getTime()}
|
||||
entry.address = $1 AND entry.clientName = $2 AND entry.time > ${oneHour.getTime()}
|
||||
`;
|
||||
|
||||
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
|
||||
@@ -163,12 +165,12 @@ export class ClientStatisticsService {
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
time label,
|
||||
time AS label,
|
||||
(SUM(shares) * 4294967296) / 600 AS data
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.address = ? AND entry.clientName = ? AND entry.time > ${yesterday.getTime()}
|
||||
entry.address = $1 AND entry."clientName" = $2 AND entry.time > ${yesterday.getTime()}
|
||||
GROUP BY
|
||||
time
|
||||
ORDER BY
|
||||
@@ -179,7 +181,7 @@ export class ClientStatisticsService {
|
||||
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
|
||||
|
||||
return result.map(res => {
|
||||
res.label = new Date(res.label).toISOString();
|
||||
res.label = new Date(parseInt(res.label)).toISOString();
|
||||
return res;
|
||||
}).slice(0, result.length - 1);
|
||||
|
||||
@@ -187,57 +189,59 @@ export class ClientStatisticsService {
|
||||
}
|
||||
|
||||
|
||||
public async getHashRateForSession(address: string, clientName: string, sessionId: string) {
|
||||
// public async getHashRateForSession(clientId: string) {
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
createdAt,
|
||||
updatedAt,
|
||||
shares
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.address = ? AND entry.clientName = ? AND entry.sessionId = ?
|
||||
ORDER BY time DESC
|
||||
LIMIT 2;
|
||||
`;
|
||||
// const query = `
|
||||
// SELECT
|
||||
// "createdAt",
|
||||
// "updatedAt",
|
||||
// shares
|
||||
// FROM
|
||||
// client_statistics_entity AS entry
|
||||
// WHERE
|
||||
// entry."clientId" = $1
|
||||
// ORDER BY time DESC
|
||||
// LIMIT 2;
|
||||
// `;
|
||||
|
||||
const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
|
||||
// const result = await this.clientStatisticsRepository.query(query, [clientId]);
|
||||
|
||||
if (result.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
// if (result.length < 1) {
|
||||
// return 0;
|
||||
// }
|
||||
|
||||
const latestStat = result[0];
|
||||
// const latestStat = result[0];
|
||||
|
||||
if (result.length < 2) {
|
||||
const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime();
|
||||
if (time < 1) {
|
||||
return 0;
|
||||
}
|
||||
return (latestStat.shares * 4294967296) / (time / 1000);
|
||||
} else {
|
||||
const secondLatestStat = result[1];
|
||||
const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime();
|
||||
if (time < 1) {
|
||||
return 0;
|
||||
}
|
||||
return ((latestStat.shares + secondLatestStat.shares) * 4294967296) / (time / 1000);
|
||||
}
|
||||
// if (result.length < 2) {
|
||||
// const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime();
|
||||
// // 1min
|
||||
// if (time < 1000 * 60) {
|
||||
// return 0;
|
||||
// }
|
||||
// return (parseFloat(latestStat.shares) * 4294967296) / (time / 1000);
|
||||
// } else {
|
||||
// const secondLatestStat = result[1];
|
||||
// const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime();
|
||||
// // 1min
|
||||
// if (time < 1000 * 60) {
|
||||
// return 0;
|
||||
// }
|
||||
// return ((parseFloat(latestStat.shares) + parseFloat(secondLatestStat.shares)) * 4294967296) / (time / 1000);
|
||||
// }
|
||||
|
||||
}
|
||||
// }
|
||||
|
||||
public async getChartDataForSession(address: string, clientName: string, sessionId: string) {
|
||||
public async getChartDataForSession(clientId: string) {
|
||||
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
time label,
|
||||
time AS label,
|
||||
(SUM(shares) * 4294967296) / 600 AS data
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.address = ? AND entry.clientName = ? AND entry.sessionId = ? AND entry.time > ${yesterday.getTime()}
|
||||
entry."clientId" = $1 AND entry.time > ${yesterday.getTime()}
|
||||
GROUP BY
|
||||
time
|
||||
ORDER BY
|
||||
@@ -245,16 +249,16 @@ export class ClientStatisticsService {
|
||||
LIMIT 144;
|
||||
`;
|
||||
|
||||
const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
|
||||
const result = await this.clientStatisticsRepository.query(query, [clientId]);
|
||||
|
||||
return result.map(res => {
|
||||
res.label = new Date(res.label).toISOString();
|
||||
res.label = new Date(parseInt(res.label)).toISOString();
|
||||
return res;
|
||||
}).slice(0, result.length - 1);
|
||||
|
||||
}
|
||||
|
||||
public async deleteAll() {
|
||||
return await this.clientStatisticsRepository.delete({})
|
||||
return await this.clientStatisticsRepository.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { DateTimeTransformer } from '../utils/DateTimeTransformer';
|
||||
import { ClientStatisticsEntity } from '../client-statistics/client-statistics.entity';
|
||||
import { TrackedEntity } from '../utils/TrackedEntity.entity';
|
||||
|
||||
//https://www.sqlite.org/withoutrowid.html
|
||||
|
||||
//The WITHOUT ROWID optimization is likely to be helpful for tables that have non-integer
|
||||
// or composite (multi-column) PRIMARY KEYs and that do not store large strings or BLOBs.
|
||||
//WITHOUT ROWID tables work best when individual rows are not too large.
|
||||
@Entity({ withoutRowid: true })
|
||||
@Index(['address', 'clientName', 'sessionId'], { unique: true })
|
||||
@Entity()
|
||||
@Index("IDX_unique_nonce", { synchronize: false })
|
||||
@Index('idx_client_cleanup', ['id'], {
|
||||
where: '"deletedAt" IS NULL',
|
||||
// This is a partial (filtered) index — only indexes active clients
|
||||
})
|
||||
export class ClientEntity extends TrackedEntity {
|
||||
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@PrimaryColumn({ length: 62, type: 'varchar' })
|
||||
@Index()
|
||||
@Column({ length: 62, type: 'varchar' })
|
||||
address: string;
|
||||
|
||||
@PrimaryColumn({ length: 64, type: 'varchar' })
|
||||
@Column({ length: 64, type: 'varchar' })
|
||||
clientName: string;
|
||||
|
||||
@PrimaryColumn({ length: 8, type: 'varchar' })
|
||||
@Column({ length: 8, type: 'varchar', })
|
||||
sessionId: string;
|
||||
|
||||
|
||||
@@ -27,15 +30,19 @@ export class ClientEntity extends TrackedEntity {
|
||||
userAgent: string;
|
||||
|
||||
|
||||
|
||||
@Column({ type: 'datetime', transformer: new DateTimeTransformer() })
|
||||
@Column()
|
||||
startTime: Date;
|
||||
|
||||
@Column({ type: 'real', default: 0 })
|
||||
@Column({ type: 'decimal', default: 0 })
|
||||
bestDifficulty: number
|
||||
|
||||
@Column({ default: 0 })
|
||||
@Column({ default: 0, type: 'decimal' })
|
||||
hashRate: number;
|
||||
|
||||
}
|
||||
@OneToMany(
|
||||
() => ClientStatisticsEntity,
|
||||
clientStatisticsEntity => clientStatisticsEntity.client
|
||||
)
|
||||
statistics: ClientStatisticsEntity[]
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
import { ObjectLiteral, Repository } from 'typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { ClientEntity } from './client.entity';
|
||||
|
||||
@@ -11,70 +9,119 @@ import { ClientEntity } from './client.entity';
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
|
||||
|
||||
public insertQueue: { result: BehaviorSubject<ObjectLiteral | null>, partialClient: Partial<ClientEntity> }[] = [];
|
||||
|
||||
private heartbeatBulkUpdate: { [id: string]: { id: string, hashRate: number, updatedAt: Date } } = {};
|
||||
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private dataSource: DataSource,
|
||||
@InjectRepository(ClientEntity)
|
||||
private clientRepository: Repository<ClientEntity>
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
@Interval(1000 * 5)
|
||||
public async insertClients() {
|
||||
const queueCopy = [...this.insertQueue];
|
||||
this.insertQueue = [];
|
||||
// client.service.ts
|
||||
public async killDeadClients(): Promise<boolean> {
|
||||
const BATCH_SIZE = 10000;
|
||||
|
||||
const results = await this.clientRepository.insert(queueCopy.map(c => c.partialClient));
|
||||
return this.dataSource.transaction('READ COMMITTED', async (manager) => { // ← CHANGE HERE
|
||||
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]);
|
||||
|
||||
queueCopy.forEach((c, index) => {
|
||||
c.result.next(results.generatedMaps[index]);
|
||||
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 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 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;
|
||||
}
|
||||
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 {
|
||||
await this.clientRepository.query(query);
|
||||
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 = {};
|
||||
}
|
||||
|
||||
|
||||
public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> {
|
||||
|
||||
const result = new BehaviorSubject(null);
|
||||
|
||||
this.insertQueue.push({ result, partialClient });
|
||||
|
||||
|
||||
// const insertResult = await this.clientRepository.insert(partialClient);
|
||||
|
||||
const generatedMap = await firstValueFrom(result);
|
||||
const insertResult = await this.clientRepository.insert(partialClient);
|
||||
|
||||
const client = {
|
||||
...partialClient,
|
||||
...generatedMap
|
||||
...insertResult.generatedMaps[0]
|
||||
};
|
||||
|
||||
return client as ClientEntity;
|
||||
}
|
||||
|
||||
public async delete(sessionId: string) {
|
||||
return await this.clientRepository.softDelete({ sessionId });
|
||||
public async delete(id: string) {
|
||||
return await this.clientRepository.softDelete({ id });
|
||||
}
|
||||
|
||||
public async deleteOldClients() {
|
||||
@@ -90,9 +137,20 @@ export class ClientService {
|
||||
|
||||
}
|
||||
|
||||
public async updateBestDifficulty(sessionId: string, bestDifficulty: number) {
|
||||
return await this.clientRepository.update({ sessionId }, { bestDifficulty });
|
||||
public async updateBestDifficulty(id: string, bestDifficulty: number) {
|
||||
return await this.clientRepository.update({ id }, { bestDifficulty });
|
||||
}
|
||||
|
||||
public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) {
|
||||
return await this.clientRepository
|
||||
.createQueryBuilder()
|
||||
.update(ClientEntity)
|
||||
.set({ bestDifficulty })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async connectedClientCount(): Promise<number> {
|
||||
return await this.clientRepository.count();
|
||||
}
|
||||
@@ -126,19 +184,22 @@ export class ClientService {
|
||||
}
|
||||
|
||||
public async deleteAll() {
|
||||
return await this.clientRepository.softDelete({})
|
||||
return await this.clientRepository
|
||||
.createQueryBuilder()
|
||||
.softDelete()
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async getUserAgents() {
|
||||
const result = await this.clientRepository.createQueryBuilder('client')
|
||||
.select('client.userAgent as userAgent')
|
||||
.addSelect('COUNT(client.userAgent)', 'count')
|
||||
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
|
||||
.addSelect('SUM(client.hashRate)', 'totalHashRate')
|
||||
.groupBy('client.userAgent')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany();
|
||||
return result;
|
||||
}
|
||||
// public async getUserAgents() {
|
||||
// const result = await this.clientRepository.createQueryBuilder('client')
|
||||
// .select('client.userAgent as "userAgent"')
|
||||
// .addSelect('COUNT(client.userAgent)', 'count')
|
||||
// .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
|
||||
// .addSelect('SUM(client.hashRate)', 'totalHashRate')
|
||||
// .groupBy('client.userAgent')
|
||||
// .orderBy('"totalHashRate"', 'DESC')
|
||||
// .getRawMany();
|
||||
// return result;
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,45 @@
|
||||
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,9 +6,6 @@ export class RpcBlockEntity {
|
||||
@PrimaryColumn()
|
||||
blockHeight: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
lockedBy?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
data?: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InsertResult, Repository } from 'typeorm';
|
||||
|
||||
import { RpcBlockEntity } from './rpc-block.entity';
|
||||
|
||||
@@ -12,17 +12,28 @@ export class RpcBlockService {
|
||||
) {
|
||||
|
||||
}
|
||||
public getBlock(blockHeight: number) {
|
||||
public getSavedBlockTemplate(blockHeight: number) {
|
||||
return this.rpcBlockRepository.findOne({
|
||||
where: { blockHeight }
|
||||
});
|
||||
}
|
||||
|
||||
public lockBlock(blockHeight: number, process: string) {
|
||||
return this.rpcBlockRepository.save({ blockHeight, data: null, lockedBy: process });
|
||||
public saveBlock(blockHeight: number, data: string): Promise<InsertResult> {
|
||||
return this.rpcBlockRepository.upsert({ blockHeight, data }, ['blockHeight']);
|
||||
}
|
||||
|
||||
public saveBlock(blockHeight: number, data: string) {
|
||||
return this.rpcBlockRepository.update(blockHeight, { data })
|
||||
public async deleteOldBlocks() {
|
||||
const result = await this.rpcBlockRepository.createQueryBuilder('entity')
|
||||
.select('MAX(entity.blockHeight)', 'maxNumber')
|
||||
.getRawOne();
|
||||
|
||||
const newestBlock = result ? result.maxNumber : null;
|
||||
|
||||
await this.rpcBlockRepository.createQueryBuilder()
|
||||
.delete()
|
||||
.where('"blockHeight" < :newestBlock', { newestBlock })
|
||||
.execute();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
|
||||
import { TrackedEntity } from '../utils/TrackedEntity.entity';
|
||||
|
||||
@Entity()
|
||||
export class TelegramSubscriptionsEntity extends TrackedEntity {
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
@PrimaryGeneratedBigIntColumn()
|
||||
id: number;
|
||||
|
||||
@Index()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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,14 +1,113 @@
|
||||
import { ValueTransformer } from 'typeorm';
|
||||
|
||||
export class DateTimeTransformer implements ValueTransformer {
|
||||
to(value: Date): any {
|
||||
// Convert the local time to UTC before saving to the database
|
||||
const utcTime = value?.toLocaleString();
|
||||
return utcTime;
|
||||
const EUROPEAN_LOCALE_REGEX =
|
||||
/^(\d{1,2})\.(\d{1,2})\.(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i;
|
||||
const US_LOCALE_REGEX =
|
||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
from(value: any): Date {
|
||||
// Convert the UTC time from the database to the local time zone
|
||||
const matchUs = trimmed.match(US_LOCALE_REGEX);
|
||||
if (matchUs) {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
|
||||
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
import { DateTimeTransformer } from './DateTimeTransformer';
|
||||
|
||||
export abstract class TrackedEntity {
|
||||
@DeleteDateColumn({ nullable: true, type: 'datetime', transformer: new DateTimeTransformer() })
|
||||
@DeleteDateColumn({ nullable: true })
|
||||
public deletedAt?: Date;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
|
||||
@CreateDateColumn()
|
||||
public createdAt?: Date
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
|
||||
@UpdateDateColumn()
|
||||
public updatedAt?: Date
|
||||
}
|
||||
+42
-10
@@ -3,10 +3,14 @@ import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
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 { BlocksService } from './ORM/blocks/blocks.service';
|
||||
import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.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 { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@@ -18,7 +22,10 @@ export class AppController {
|
||||
private readonly clientService: ClientService,
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
private readonly blocksService: BlocksService,
|
||||
private readonly bitcoinRpcService: BitcoinRpcService
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
private readonly homeGraphService: HomeGraphService,
|
||||
private readonly addressSettingsService: AddressSettingsService,
|
||||
private readonly userAgentReportService: UserAgentReportService
|
||||
) { }
|
||||
|
||||
@Get('info')
|
||||
@@ -34,16 +41,42 @@ export class AppController {
|
||||
|
||||
|
||||
const blockData = await this.blocksService.getFoundBlocks();
|
||||
const userAgents = await this.clientService.getUserAgents();
|
||||
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 = {
|
||||
blockData,
|
||||
userAgents,
|
||||
highScores,
|
||||
uptime: this.uptime
|
||||
};
|
||||
|
||||
//1 min
|
||||
await this.cacheManager.set(CACHE_KEY, data, 1 * 60 * 1000);
|
||||
//5 min
|
||||
await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000);
|
||||
|
||||
return data;
|
||||
|
||||
@@ -59,11 +92,11 @@ export class AppController {
|
||||
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 totalMiners = userAgents.reduce((acc, userAgent) => acc + parseInt(userAgent.count), 0);
|
||||
const blockHeight = (await firstValueFrom(this.bitcoinRpcService.newBlock$)).blocks;
|
||||
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0);
|
||||
const blockHeight = this.bitcoinRpcService.miningInfo.blocks;
|
||||
const blocksFound = await this.blocksService.getFoundBlocks();
|
||||
|
||||
const data = {
|
||||
@@ -82,8 +115,7 @@ export class AppController {
|
||||
|
||||
@Get('network')
|
||||
public async network() {
|
||||
const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$);
|
||||
return miningInfo;
|
||||
return this.bitcoinRpcService.miningInfo;
|
||||
}
|
||||
|
||||
@Get('info/chart')
|
||||
@@ -97,7 +129,7 @@ export class AppController {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const chartData = await this.clientStatisticsService.getChartDataForSite();
|
||||
const chartData = await this.homeGraphService.getChartDataForSite();
|
||||
|
||||
//10 min
|
||||
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000);
|
||||
|
||||
+43
-12
@@ -1,7 +1,7 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@@ -9,11 +9,22 @@ import { AppController } from './app.controller';
|
||||
import { AddressController } from './controllers/address/address.controller';
|
||||
import { ClientController } from './controllers/client/client.controller';
|
||||
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 { BlocksEntity } from './ORM/blocks/blocks.entity';
|
||||
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 { ClientEntity } from './ORM/client/client.entity';
|
||||
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 { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity';
|
||||
import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
|
||||
import { AppService } from './services/app.service';
|
||||
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
|
||||
@@ -21,7 +32,6 @@ import { BraiinsService } from './services/braiins.service';
|
||||
import { BTCPayService } from './services/btc-pay.service';
|
||||
import { DiscordService } from './services/discord.service';
|
||||
import { NotificationService } from './services/notification.service';
|
||||
import { ProxyService } from './services/proxy.service';
|
||||
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
|
||||
import { StratumV1Service } from './services/stratum-v1.service';
|
||||
import { TelegramService } from './services/telegram.service';
|
||||
@@ -33,21 +43,43 @@ const ORMModules = [
|
||||
AddressSettingsModule,
|
||||
TelegramSubscriptionsModule,
|
||||
BlocksModule,
|
||||
RpcBlocksModule
|
||||
RpcBlocksModule,
|
||||
HomeGraphModule,
|
||||
UserAgentReportModule
|
||||
]
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: './DB/public-pool.sqlite',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST'),
|
||||
port: parseInt(configService.get('DB_PORT')),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE'),
|
||||
entities: [
|
||||
ClientEntity,
|
||||
AddressSettingsEntity,
|
||||
BlocksEntity,
|
||||
ClientStatisticsEntity,
|
||||
RpcBlockEntity,
|
||||
TelegramSubscriptionsEntity,
|
||||
HomeGraphEntity,
|
||||
UserAgentReportView
|
||||
],
|
||||
synchronize: configService.get('PRODUCTION') != 'true',
|
||||
logging: false,
|
||||
enableWAL: true,
|
||||
busyTimeout: 30 * 1000,
|
||||
|
||||
poolSize: 10,
|
||||
migrations: [
|
||||
UniqueNonceIndex
|
||||
]
|
||||
}
|
||||
},
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService]
|
||||
}),
|
||||
CacheModule.register(),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -68,7 +100,6 @@ const ORMModules = [
|
||||
NotificationService,
|
||||
BitcoinAddressValidator,
|
||||
StratumV1JobsService,
|
||||
ProxyService,
|
||||
BTCPayService,
|
||||
BraiinsService
|
||||
],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Controller, Patch } from '@nestjs/common';
|
||||
import * as bitcoinMessage from 'bitcoinjs-message';
|
||||
|
||||
|
||||
@Controller('address')
|
||||
@@ -7,16 +6,6 @@ export class AddressController {
|
||||
|
||||
@Patch('settings')
|
||||
async settings() {
|
||||
|
||||
const publicKey = '...'; // Public key corresponding to the private key used for signing
|
||||
const message = '...'; // The message that was signed
|
||||
const signature = '...'; // The signature of the message
|
||||
|
||||
const isValid: boolean = bitcoinMessage.verify(message, publicKey, signature);
|
||||
if (isValid) {
|
||||
console.log('Signature is valid!');
|
||||
} else {
|
||||
console.log('Signature is not valid!');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ describe('ClientController', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: './DB/public-pool.test.sqlite',
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
cache: true,
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ClientController {
|
||||
return {
|
||||
sessionId: worker.sessionId,
|
||||
name: worker.clientName,
|
||||
bestDifficulty: worker.bestDifficulty.toFixed(2),
|
||||
bestDifficulty: parseFloat(worker.bestDifficulty as any).toFixed(2),
|
||||
hashRate: worker.hashRate,
|
||||
startTime: worker.startTime,
|
||||
lastSeen: worker.updatedAt
|
||||
@@ -75,7 +75,7 @@ export class ClientController {
|
||||
if (worker == null) {
|
||||
return new NotFoundException();
|
||||
}
|
||||
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.address, worker.clientName, worker.sessionId);
|
||||
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id);
|
||||
|
||||
return {
|
||||
sessionId: worker.sessionId,
|
||||
|
||||
+53
-10
@@ -3,38 +3,41 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { useContainer } from 'class-validator';
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFileSync, watch } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
|
||||
if (process.env.API_PORT == null) {
|
||||
console.error('It appears your environment is not configured, create and populate an .env file.');
|
||||
return;
|
||||
}
|
||||
|
||||
let options = {};
|
||||
const secure = process.env.API_SECURE?.toLowerCase() == 'true';
|
||||
if (secure) {
|
||||
const secure = process.env.API_SECURE?.toLowerCase() === 'true';
|
||||
const currentDirectory = process.cwd();
|
||||
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
|
||||
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
|
||||
|
||||
let options: any = {};
|
||||
if (secure) {
|
||||
options = {
|
||||
https: {
|
||||
key: readFileSync(`${currentDirectory}/secrets/key.pem`),
|
||||
cert: readFileSync(`${currentDirectory}/secrets/cert.pem`),
|
||||
key: readFileSync(keyPath),
|
||||
cert: readFileSync(certPath),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
|
||||
app.setGlobalPrefix('api')
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
forbidUnknownValues: true
|
||||
//forbidNonWhitelisted: true,
|
||||
//forbidUnknownValues: true
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -55,9 +58,49 @@ async function bootstrap() {
|
||||
bitcoinjs.initEccLib(ecc);
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
// --- 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();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import { MiningJob } from './MiningJob';
|
||||
|
||||
describe('MiningJob', () => {
|
||||
let jobTemplate;
|
||||
let job: MiningJob;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||
|
||||
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||
const bitcoinRpcService = {
|
||||
newBlockTemplate$: blockTemplate$.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
|
||||
};
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
|
||||
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
|
||||
job = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'1',
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
jobTemplate
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should split coinbase around 12 bytes of extranonce space', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
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);
|
||||
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
|
||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
parseInt('00002000', 16),
|
||||
parseInt('ed460d91', 16),
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
|
||||
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
|
||||
expect(updatedBlock.timestamp).toBe(timestamp);
|
||||
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
|
||||
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it('should leave block version unchanged without a version mask', () => {
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
0,
|
||||
parseInt('ed460d91', 16),
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16)
|
||||
);
|
||||
|
||||
expect(updatedBlock.version).toBe(jobTemplate.block.version);
|
||||
});
|
||||
|
||||
it('should build the same header as the full block update path', () => {
|
||||
const versionMask = parseInt('00002000', 16);
|
||||
const nonce = parseInt('ed460d91', 16);
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
const fastHeader = job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
|
||||
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose coinbase helpers for downstream validation', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
|
||||
expect(job.getCoinbaseTxHex()).toContain(notify.params[2]);
|
||||
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', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const oversizedIdentifier = 'x'.repeat(120);
|
||||
const oversizedJob = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'2',
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
jobTemplate,
|
||||
oversizedIdentifier
|
||||
);
|
||||
const coinbase = bitcoinjs.Transaction.fromHex(oversizedJob.getCoinbaseTxHex());
|
||||
|
||||
expect(coinbase.ins[0].script.toString()).not.toContain(oversizedIdentifier);
|
||||
});
|
||||
});
|
||||
+87
-30
@@ -3,8 +3,10 @@ import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
|
||||
import { eResponseMethod } from './enums/eResponseMethod';
|
||||
import { IMiningNotify } from './stratum-messages/IMiningNotify';
|
||||
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
|
||||
|
||||
const MAX_BLOCK_WEIGHT = 4000000;
|
||||
const MAX_SCRIPT_SIZE = 100;
|
||||
|
||||
interface AddressObject {
|
||||
address: string;
|
||||
@@ -15,19 +17,27 @@ export class MiningJob {
|
||||
private coinbaseTransaction: bitcoinjs.Transaction;
|
||||
private coinbasePart1: string;
|
||||
private coinbasePart2: string;
|
||||
private coinbasePart1Buffer: Buffer;
|
||||
private coinbasePart2Buffer: Buffer;
|
||||
private merkleBranchBuffers: Buffer[];
|
||||
private notifyStaticParams: string;
|
||||
|
||||
public jobTemplateId: string;
|
||||
public networkDifficulty: number;
|
||||
|
||||
public creation: number;
|
||||
public retiredAt?: number;
|
||||
|
||||
constructor(
|
||||
private network: bitcoinjs.networks.Network,
|
||||
public jobId: string,
|
||||
payoutInformation: AddressObject[],
|
||||
jobTemplate: IJobTemplate
|
||||
jobTemplate: IJobTemplate,
|
||||
poolIdentifier: string = process.env.POOL_IDENTIFIER || 'Public-Pool'
|
||||
) {
|
||||
|
||||
this.creation = new Date().getTime();
|
||||
this.jobTemplateId = jobTemplate.blockData.id;
|
||||
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
|
||||
|
||||
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
|
||||
|
||||
@@ -39,7 +49,7 @@ export class MiningJob {
|
||||
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
|
||||
|
||||
// 39th byte onwards: Optional data with no consensus meaning
|
||||
const extra = Buffer.from('\\public-pool\\');
|
||||
const extra = Buffer.from(poolIdentifier);
|
||||
|
||||
// Encode the block height
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
|
||||
@@ -49,13 +59,24 @@ export class MiningJob {
|
||||
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
|
||||
|
||||
// generate padding and take length of encode blockHeight into account
|
||||
const padding = Buffer.alloc(8 + (3 - blockHeightEncoded.length), 0)
|
||||
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
|
||||
|
||||
// build the script
|
||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
|
||||
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]);
|
||||
if (script.length > MAX_SCRIPT_SIZE) {
|
||||
console.warn('Pool identifier is too long, removing the pool identifier');
|
||||
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
||||
}
|
||||
|
||||
this.coinbaseTransaction.ins[0].script = script;
|
||||
|
||||
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
|
||||
|
||||
if ((this.coinbaseTransaction.weight() + jobTemplate.block.weight()) > MAX_BLOCK_WEIGHT) {
|
||||
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
|
||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
||||
}
|
||||
|
||||
// get the non-witness coinbase tx
|
||||
//@ts-ignore
|
||||
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
|
||||
@@ -64,10 +85,63 @@ export class MiningJob {
|
||||
|
||||
const partOneIndex = serializedCoinbaseTx.indexOf(inputScript) + inputScript.length;
|
||||
|
||||
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - 16);
|
||||
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - (TOTAL_EXTRANONCE_SIZE_BYTES * 2));
|
||||
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
|
||||
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, '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 {
|
||||
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
|
||||
}
|
||||
|
||||
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
|
||||
const coinbaseBuffer = Buffer.concat([
|
||||
this.coinbasePart1Buffer,
|
||||
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
|
||||
this.coinbasePart2Buffer,
|
||||
]);
|
||||
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer);
|
||||
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
|
||||
|
||||
let version = jobTemplate.block.version;
|
||||
if (versionMask !== undefined && versionMask != 0) {
|
||||
version = version ^ versionMask;
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(80);
|
||||
header.writeInt32LE(version, 0);
|
||||
jobTemplate.block.prevHash.copy(header, 4);
|
||||
merkleRoot.copy(header, 36);
|
||||
header.writeUInt32LE(timestamp, 68);
|
||||
header.writeUInt32LE(jobTemplate.block.bits, 72);
|
||||
header.writeUInt32LE(nonce, 76);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
|
||||
@@ -77,7 +151,7 @@ export class MiningJob {
|
||||
return Object.assign(new bitcoinjs.Transaction(), tx);
|
||||
});
|
||||
|
||||
testBlock.transactions[0] = this.coinbaseTransaction;
|
||||
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
|
||||
|
||||
testBlock.nonce = nonce;
|
||||
|
||||
@@ -89,10 +163,10 @@ export class MiningJob {
|
||||
// set the nonces
|
||||
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
|
||||
|
||||
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - 16)}${extraNonce}${extraNonce2}`, 'hex');
|
||||
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
|
||||
|
||||
//recompute the root since we updated the coinbase script with the nonces
|
||||
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), jobTemplate.merkle_branch);
|
||||
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
|
||||
|
||||
|
||||
testBlock.timestamp = timestamp;
|
||||
@@ -101,14 +175,14 @@ export class MiningJob {
|
||||
}
|
||||
|
||||
|
||||
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
|
||||
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
|
||||
|
||||
const bothMerkles = Buffer.alloc(64);
|
||||
|
||||
bothMerkles.set(newRoot);
|
||||
|
||||
for (let i = 0; i < merkleBranches.length; i++) {
|
||||
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
|
||||
bothMerkles.set(merkleBranches[i], 32);
|
||||
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
|
||||
bothMerkles.set(newRoot);
|
||||
}
|
||||
@@ -172,24 +246,7 @@ export class MiningJob {
|
||||
}
|
||||
|
||||
public response(jobTemplate: IJobTemplate): string {
|
||||
|
||||
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';
|
||||
return `{"id":null,"method":"${eResponseMethod.MINING_NOTIFY}","params":[${JSON.stringify(this.jobId)},${this.notifyStaticParams}}\n`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import Big from 'big.js';
|
||||
import { Block } from 'bitcoinjs-lib';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidatorOptions } from 'class-validator';
|
||||
import { Socket } from 'net';
|
||||
|
||||
import { eRequestMethod } from './enums/eRequestMethod';
|
||||
import { eResponseMethod } from './enums/eResponseMethod';
|
||||
import { IMiningNotify } from './stratum-messages/IMiningNotify';
|
||||
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
||||
|
||||
export class ProxyClient {
|
||||
|
||||
|
||||
public braiinsSocket: Socket;
|
||||
public serverDifficulty: number;
|
||||
public jobs: { [jobId: string]: IMiningNotify } = {};
|
||||
private extraNonce: string;
|
||||
|
||||
constructor(public socket: Socket) {
|
||||
this.braiinsSocket = new Socket();
|
||||
this.braiinsSocket.connect(3333, 'us-east.stratum.braiins.com', () => {
|
||||
console.log('Connected to braiins');
|
||||
});
|
||||
|
||||
this.braiinsSocket.on('data', (data) => {
|
||||
data.toString()
|
||||
.split('\n')
|
||||
.filter(m => m.length > 0)
|
||||
.forEach(async (m) => {
|
||||
try {
|
||||
await this.toClient(m);
|
||||
} catch (e) {
|
||||
await socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
this.braiinsSocket.on('close', () => {
|
||||
console.log('Braiins closed connection');
|
||||
socket.end();
|
||||
});
|
||||
|
||||
|
||||
socket.on('error', async (error: Error) => { });
|
||||
|
||||
socket.on('data', (data: Buffer) => {
|
||||
data.toString()
|
||||
.split('\n')
|
||||
.filter(m => m.length > 0)
|
||||
.forEach(async (m) => {
|
||||
try {
|
||||
await this.toServer(m);
|
||||
} catch (e) {
|
||||
await socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
})
|
||||
});
|
||||
socket.on('close', () => {
|
||||
console.log('Client closed connection');
|
||||
this.braiinsSocket.end();
|
||||
})
|
||||
}
|
||||
|
||||
private async toClient(message: string) {
|
||||
let parsedMessage: IMiningNotify | any;
|
||||
try {
|
||||
parsedMessage = JSON.parse(message);
|
||||
} catch (e) {
|
||||
//console.log("Invalid JSON");
|
||||
await this.socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedMessage.method == null && Array.isArray(parsedMessage.result)) {
|
||||
this.extraNonce = parsedMessage.result[1];
|
||||
}
|
||||
|
||||
switch (parsedMessage.method) {
|
||||
case eResponseMethod.SET_DIFFICULTY: {
|
||||
this.serverDifficulty = parsedMessage.params[0];
|
||||
parsedMessage.params[0] = 512;
|
||||
message = JSON.stringify(parsedMessage);
|
||||
break;
|
||||
}
|
||||
case eResponseMethod.MINING_NOTIFY: {
|
||||
// clear jobs
|
||||
if ((parsedMessage as IMiningNotify).params[8]) {
|
||||
this.jobs = {};
|
||||
}
|
||||
this.jobs[parsedMessage.params[0]] = parsedMessage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('Server:');
|
||||
console.log(message);
|
||||
this.socket.write(message + `\n`);
|
||||
}
|
||||
private async toServer(message: string) {
|
||||
|
||||
let parsedMessage = null;
|
||||
try {
|
||||
parsedMessage = JSON.parse(message);
|
||||
} catch (e) {
|
||||
//console.log("Invalid JSON");
|
||||
await this.socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (parsedMessage.method) {
|
||||
|
||||
case eRequestMethod.AUTHORIZE: {
|
||||
parsedMessage.params[0] = 'battlechicken';
|
||||
message = JSON.stringify(parsedMessage);
|
||||
break;
|
||||
}
|
||||
case eRequestMethod.SUBMIT: {
|
||||
parsedMessage.params[0] = 'battlechicken';
|
||||
|
||||
message = JSON.stringify(parsedMessage);
|
||||
|
||||
const miningSubmitMessage = plainToInstance(
|
||||
MiningSubmitMessage,
|
||||
parsedMessage,
|
||||
);
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(miningSubmitMessage, validatorOptions);
|
||||
|
||||
if (errors.length === 0) {
|
||||
this.checkSubmission(miningSubmitMessage);
|
||||
}
|
||||
else {
|
||||
console.log('parsing error')
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('Client:');
|
||||
console.log(message);
|
||||
this.braiinsSocket.write(message + '\n');
|
||||
|
||||
}
|
||||
|
||||
private checkSubmission(submission: MiningSubmitMessage) {
|
||||
console.log('Submission:');
|
||||
console.log(submission);
|
||||
|
||||
const job = this.jobs[submission.jobId];
|
||||
if (job == null) {
|
||||
console.log('Job not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const versionMask = parseInt(submission.versionMask, 16);
|
||||
|
||||
const block = new Block();
|
||||
|
||||
block.version = parseInt(job.params[5], 16);
|
||||
if (submission.versionMask !== undefined && versionMask != 0) {
|
||||
block.version = (block.version ^ versionMask);
|
||||
}
|
||||
|
||||
const prevHash = this.swapEndianWords(job.params[1]);
|
||||
block.prevHash = Buffer.from(prevHash, 'hex');
|
||||
const coinbase = Buffer.from(`${job.params[2]}${this.extraNonce}${submission.extraNonce2}${job.params[3]}`, 'hex');
|
||||
const coinbaseHash = bitcoinjs.crypto.hash256(coinbase);
|
||||
block.merkleRoot = this.calculateMerkleRootHash(coinbaseHash, job.params[4]);
|
||||
block.timestamp = parseInt(submission.ntime, 16);
|
||||
block.bits = parseInt(job.params[6], 16);
|
||||
block.nonce = parseInt(submission.nonce, 16);
|
||||
|
||||
const header = block.toBuffer(true);
|
||||
|
||||
const diff = this.calculateDifficulty(header);
|
||||
|
||||
console.log(`DIFFICULTY: ${diff.submissionDifficulty}`)
|
||||
}
|
||||
|
||||
|
||||
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
|
||||
|
||||
const bothMerkles = Buffer.alloc(64);
|
||||
|
||||
bothMerkles.set(newRoot);
|
||||
|
||||
for (let i = 0; i < merkleBranches.length; i++) {
|
||||
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
|
||||
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
|
||||
bothMerkles.set(newRoot);
|
||||
}
|
||||
|
||||
return bothMerkles.subarray(0, 32)
|
||||
}
|
||||
|
||||
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } {
|
||||
|
||||
const hashResult = bitcoinjs.crypto.hash256(header);
|
||||
|
||||
let s64 = this.le256todouble(hashResult);
|
||||
|
||||
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
|
||||
const difficulty = truediffone.div(s64.toString());
|
||||
return { submissionDifficulty: difficulty.toNumber(), submissionHash: hashResult.toString('hex') };
|
||||
}
|
||||
|
||||
private le256todouble(target: Buffer): bigint {
|
||||
|
||||
const number = target.reduceRight((acc, byte) => {
|
||||
// Shift the number 8 bits to the left and OR with the current byte
|
||||
return (acc << BigInt(8)) | BigInt(byte);
|
||||
}, BigInt(0));
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
private swapEndianWords(str: string) {
|
||||
const hexGroups = str.match(/.{1,8}/g);
|
||||
// Reverse each group and concatenate them
|
||||
const reversedHexString = hexGroups.reduce((pre, cur, indx, arr) => {
|
||||
const reversed = cur.match(/.{2}/g).reverse();
|
||||
return `${pre}${reversed.join('')}`;
|
||||
}, '');
|
||||
return reversedHexString;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DataSource } from 'typeorm';
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module';
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
import { BlocksEntity } from '../ORM/blocks/blocks.entity';
|
||||
import { BlocksService } from '../ORM/blocks/blocks.service';
|
||||
import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity';
|
||||
import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module';
|
||||
@@ -18,8 +19,10 @@ import { ClientService } from '../ORM/client/client.service';
|
||||
import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
|
||||
import { StratumV1Client } from './StratumV1Client';
|
||||
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
|
||||
import { MiningJob } from './MiningJob';
|
||||
import { effectiveJobDifficulty, StratumV1Client } from './StratumV1Client';
|
||||
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
||||
|
||||
|
||||
|
||||
@@ -50,8 +53,12 @@ describe('StratumV1Client', () => {
|
||||
let client: StratumV1Client;
|
||||
|
||||
let socketEmitter: (...args: any[]) => void;
|
||||
const emitMessage = (message: string) => socketEmitter(Buffer.from(`${message}\n`));
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject(null);
|
||||
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
@@ -59,12 +66,13 @@ describe('StratumV1Client', () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: './DB/public-pool.test.sqlite',
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
cache: true,
|
||||
logging: false
|
||||
logging: false,
|
||||
entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity]
|
||||
}),
|
||||
ClientModule,
|
||||
ClientStatisticsModule,
|
||||
@@ -94,23 +102,41 @@ describe('StratumV1Client', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
console.log('NEW TEST')
|
||||
jest.useFakeTimers({ advanceTimers: true })
|
||||
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
clientService = moduleRef.get<ClientService>(ClientService);
|
||||
|
||||
const dataSource = moduleRef.get<DataSource>(DataSource);
|
||||
|
||||
dataSource.getRepository(ClientEntity).delete({});
|
||||
dataSource.getRepository(ClientStatisticsEntity).delete({});
|
||||
await dataSource.getRepository(ClientStatisticsEntity).clear();
|
||||
await dataSource.getRepository(ClientEntity).clear();
|
||||
await dataSource.getRepository(BlocksEntity).clear();
|
||||
|
||||
|
||||
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService);
|
||||
|
||||
configService = moduleRef.get<ConfigService>(ConfigService);
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'DEV_FEE_ADDRESS':
|
||||
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
case 'NETWORK':
|
||||
return 'testnet';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
(StratumV1Client as any).blockedUserAgentLogState.clear();
|
||||
(StratumV1Client as any).validationErrorLogState.clear();
|
||||
|
||||
bitcoinRpcService = new MockBitcoinRpcService(null);
|
||||
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
|
||||
bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable();
|
||||
bitcoinRpcService = {
|
||||
newBlockTemplate$: newBlockEmitter.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
|
||||
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
|
||||
} as any;
|
||||
|
||||
|
||||
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
|
||||
@@ -126,8 +152,15 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
socket.end = jest.fn();
|
||||
jest.spyOn(socket, 'destroy').mockImplementation(() => socket);
|
||||
|
||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
||||
notificationService = {
|
||||
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
|
||||
} as any;
|
||||
blocksService = {
|
||||
save: jest.fn().mockResolvedValue(undefined)
|
||||
} as any;
|
||||
|
||||
|
||||
client = new StratumV1Client(
|
||||
@@ -143,12 +176,15 @@ describe('StratumV1Client', () => {
|
||||
);
|
||||
|
||||
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
|
||||
jest.spyOn(client as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true })
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
client.destroy();
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
})
|
||||
|
||||
@@ -158,30 +194,118 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
it('should close socket on invalid JSON', () => {
|
||||
socketEmitter(Buffer.from('INVALID'));
|
||||
emitMessage('INVALID');
|
||||
jest.spyOn(socket, 'destroy');
|
||||
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 () => {
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
expect(socket.on).toHaveBeenCalled();
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",4]}\n`, expect.any(Function));
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",8]}\n`, expect.any(Function));
|
||||
|
||||
});
|
||||
|
||||
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'NON_COMPLIANT_USER_AGENTS':
|
||||
return 'NMMiner';
|
||||
case 'DEV_FEE_ADDRESS':
|
||||
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
case 'NETWORK':
|
||||
return 'testnet';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
expect((client as any).statistics).toBeUndefined();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Blocked non-compliant connection from userAgent: NMMiner');
|
||||
});
|
||||
|
||||
it('should throttle repeated non-compliant user agent logs', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'NON_COMPLIANT_USER_AGENTS':
|
||||
return 'NMMiner';
|
||||
case 'DEV_FEE_ADDRESS':
|
||||
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
case 'NETWORK':
|
||||
return 'testnet';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
const secondSocket = new Socket();
|
||||
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
|
||||
socketEmitter = listener;
|
||||
return secondSocket;
|
||||
});
|
||||
secondSocket.end = jest.fn();
|
||||
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
|
||||
const secondClient = new StratumV1Client(
|
||||
secondSocket,
|
||||
stratumV1JobsService,
|
||||
bitcoinRpcService,
|
||||
clientService,
|
||||
clientStatisticsService,
|
||||
notificationService,
|
||||
blocksService,
|
||||
configService,
|
||||
moduleRef.get<AddressSettingsService>(AddressSettingsService)
|
||||
);
|
||||
|
||||
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(secondSocket.destroy).toHaveBeenCalled();
|
||||
expect(consoleLogSpy.mock.calls.filter(call => call[0]?.startsWith('Blocked non-compliant connection'))).toHaveLength(1);
|
||||
await secondClient.destroy();
|
||||
});
|
||||
|
||||
|
||||
it('should respond to mining.configure', async () => {
|
||||
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
expect(socket.on).toHaveBeenCalled();
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_CONFIGURE));
|
||||
emitMessage(MockRecording1.MINING_CONFIGURE);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":2,"error":null,"result":{"version-rolling":true,"version-rolling.mask":"1fffe000"}}\n`, expect.any(Function));
|
||||
});
|
||||
@@ -191,7 +315,7 @@ describe('StratumV1Client', () => {
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
expect(socket.on).toHaveBeenCalled();
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
expect(socket.write).toHaveBeenCalledWith('{"id":3,"error":null,"result":true}\n', expect.any(Function));
|
||||
});
|
||||
@@ -200,28 +324,30 @@ describe('StratumV1Client', () => {
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
expect(socket.on).toHaveBeenCalled();
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
|
||||
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":4,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set difficulty', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
console.log('should set difficulty')
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[16384]}\n`);
|
||||
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[100000]}\n`);
|
||||
|
||||
});
|
||||
|
||||
it('should save client', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
|
||||
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);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const clientCount = await clientService.connectedClientCount();
|
||||
@@ -244,9 +370,9 @@ describe('StratumV1Client', () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
|
||||
|
||||
|
||||
@@ -255,10 +381,10 @@ describe('StratumV1Client', () => {
|
||||
|
||||
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1903c943255c7075626c69632d706f6f6c5c","ffffffff037a90000000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc3b0f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",false]}\n`);
|
||||
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`);
|
||||
|
||||
|
||||
socketEmitter(Buffer.from(MockRecording1.MINING_SUBMIT));
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
|
||||
jest.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -268,6 +394,277 @@ describe('StratumV1Client', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should use the header-only fast path for non-block submissions', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
||||
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
|
||||
|
||||
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(buildHeaderSpy).toHaveBeenCalled();
|
||||
expect(fullBlockSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should write accepted response before share accounting finishes', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
let finishAccounting: () => void;
|
||||
const accountingPromise = new Promise<void>((resolve) => {
|
||||
finishAccounting = resolve;
|
||||
});
|
||||
jest.spyOn((client as any).statistics, 'addShares').mockReturnValue(accountingPromise);
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||
|
||||
finishAccounting();
|
||||
jest.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
it('should update address best difficulty through the atomic path', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: 1024,
|
||||
submissionHash: 'share'
|
||||
});
|
||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
||||
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
|
||||
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
|
||||
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
|
||||
|
||||
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(updateIfHigherSpy).toHaveBeenCalledWith(
|
||||
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
1024,
|
||||
expect.any(String)
|
||||
);
|
||||
expect(clientUpdateIfHigherSpy).toHaveBeenCalledWith(expect.any(String), 1024);
|
||||
expect(getSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject duplicate submissions', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
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);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
|
||||
});
|
||||
|
||||
it('should reject submissions for unknown jobs', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
|
||||
|
||||
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(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "ff", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
|
||||
expect(await clientService.connectedClientCount()).toBe(0);
|
||||
expect(hashSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject submissions when the job template has expired', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
stratumV1JobsService.blocks = {};
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
|
||||
});
|
||||
|
||||
it('should reject low difficulty shares', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
|
||||
expect(await clientService.connectedClientCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject submissions with short extranonce2 values', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(expect.stringContaining(`"error":[20,"Mining Submit validation error"`));
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Mining Submit validation error: extraNonce2:isLength'));
|
||||
});
|
||||
|
||||
it('should throttle repeated mining submit validation logs', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const secondSocket = new Socket();
|
||||
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
|
||||
socketEmitter = listener;
|
||||
return secondSocket;
|
||||
});
|
||||
secondSocket.end = jest.fn();
|
||||
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
|
||||
const secondClient = new StratumV1Client(
|
||||
secondSocket,
|
||||
stratumV1JobsService,
|
||||
bitcoinRpcService,
|
||||
clientService,
|
||||
clientStatisticsService,
|
||||
notificationService,
|
||||
blocksService,
|
||||
configService,
|
||||
moduleRef.get<AddressSettingsService>(AddressSettingsService)
|
||||
);
|
||||
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
|
||||
|
||||
socketEmitter(Buffer.from(`${MockRecording1.MINING_SUBSCRIBE}\n`));
|
||||
socketEmitter(Buffer.from(`${MockRecording1.MINING_AUTHORIZE}\n`));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
socketEmitter(Buffer.from(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}\n`));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(consoleWarnSpy.mock.calls.filter(call => call[0]?.startsWith('Mining Submit validation error'))).toHaveLength(1);
|
||||
await secondClient.destroy();
|
||||
});
|
||||
|
||||
it('should close socket when a submit arrives before stratum is initialized', async () => {
|
||||
const endSpy = jest.spyOn(socket, 'end');
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(endSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should submit and persist found blocks', async () => {
|
||||
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({
|
||||
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).toHaveBeenCalledWith(expect.objectContaining({
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height,
|
||||
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
worker: 'bitaxe3',
|
||||
sessionId: MockRecording1.EXTRA_NONCE,
|
||||
blockData: expect.any(String)
|
||||
}));
|
||||
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
|
||||
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+320
-114
@@ -1,8 +1,7 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Big from 'big.js';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidatorOptions } from 'class-validator';
|
||||
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
|
||||
import * as crypto from 'crypto';
|
||||
import { Socket } from 'net';
|
||||
import { firstValueFrom, Subscription } from 'rxjs';
|
||||
@@ -25,32 +24,60 @@ import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
|
||||
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
||||
import { StratumErrorMessage } from './stratum-messages/StratumErrorMessage';
|
||||
import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
|
||||
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
|
||||
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
|
||||
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
|
||||
|
||||
const TRUE_DIFF_ONE = 2.695953529101131e67;
|
||||
const BLOCKED_USER_AGENT_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 {
|
||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
||||
|
||||
private clientSubscription: SubscriptionMessage;
|
||||
public clientSubscription: SubscriptionMessage;
|
||||
private clientConfiguration: ConfigurationMessage;
|
||||
private clientAuthorization: AuthorizationMessage;
|
||||
private clientSuggestedDifficulty: SuggestDifficulty;
|
||||
private stratumSubscription: Subscription;
|
||||
private backgroundWork: NodeJS.Timer[] = [];
|
||||
private backgroundWork: NodeJS.Timeout[] = [];
|
||||
|
||||
private statistics: StratumV1ClientStatistics;
|
||||
private stratumInitialized = false;
|
||||
private usedSuggestedDifficulty = false;
|
||||
private sessionDifficulty: number = 16384;
|
||||
private sessionDifficulty: number = 100000;
|
||||
private oldSessionDifficulty: number = 100000;
|
||||
private diffChangeJobId: number | null = null;
|
||||
|
||||
private entity: ClientEntity;
|
||||
private clientEntity: ClientEntity;
|
||||
private creatingEntity: Promise<void>;
|
||||
|
||||
public extraNonceAndSessionId: string;
|
||||
public sessionStart: Date;
|
||||
public noFee: boolean;
|
||||
public hashRate: number;
|
||||
//public noFee: boolean;
|
||||
//public hashRate: number = 0;
|
||||
|
||||
private buffer: string = '';
|
||||
private connectionClosed = false;
|
||||
|
||||
private miningSubmissionHashes = new Set<string>()
|
||||
|
||||
constructor(
|
||||
public readonly socket: Socket,
|
||||
@@ -65,17 +92,23 @@ export class StratumV1Client {
|
||||
) {
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
data.toString()
|
||||
.split('\n')
|
||||
.filter(m => m.length > 0)
|
||||
.forEach(async (m) => {
|
||||
this.buffer += data.toString();
|
||||
let lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
|
||||
|
||||
(async () => {
|
||||
for (const m of lines.filter(l => l.length > 0)) {
|
||||
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await this.handleMessage(m);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +116,9 @@ export class StratumV1Client {
|
||||
|
||||
public async destroy() {
|
||||
|
||||
await this.clientService.delete(this.extraNonceAndSessionId);
|
||||
if (this.clientEntity?.id) {
|
||||
await this.clientService.delete(this.clientEntity.id);
|
||||
}
|
||||
|
||||
if (this.stratumSubscription != null) {
|
||||
this.stratumSubscription.unsubscribe();
|
||||
@@ -95,10 +130,8 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
private getRandomHexString() {
|
||||
const randomBytes = crypto.randomBytes(4); // 4 bytes = 32 bits
|
||||
const randomNumber = randomBytes.readUInt32BE(0); // Convert bytes to a 32-bit unsigned integer
|
||||
const hexString = randomNumber.toString(16).padStart(8, '0'); // Convert to hex and pad with zeros
|
||||
return hexString;
|
||||
const randomBytes = crypto.randomBytes(EXTRANONCE1_SIZE_BYTES);
|
||||
return randomBytes.toString('hex');
|
||||
}
|
||||
|
||||
|
||||
@@ -126,18 +159,23 @@ export class StratumV1Client {
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
//forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(subscriptionMessage, validatorOptions);
|
||||
|
||||
if (errors.length === 0) {
|
||||
if (this.isBlockedUserAgent(subscriptionMessage.userAgent)) {
|
||||
this.logBlockedUserAgent(subscriptionMessage.userAgent);
|
||||
this.closeSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sessionStart == null) {
|
||||
this.sessionStart = new Date();
|
||||
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService);
|
||||
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;
|
||||
@@ -170,7 +208,7 @@ export class StratumV1Client {
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
//forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(configurationMessage, validatorOptions);
|
||||
@@ -184,7 +222,7 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
} else {
|
||||
console.error('Configuration validation error');
|
||||
console.log('Configuration validation error');
|
||||
const err = new StratumErrorMessage(
|
||||
configurationMessage.id,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
@@ -208,25 +246,28 @@ export class StratumV1Client {
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
//forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(authorizationMessage, validatorOptions);
|
||||
|
||||
if (errors.length === 0) {
|
||||
this.clientAuthorization = authorizationMessage;
|
||||
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
||||
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
||||
}
|
||||
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error('Authorization validation error');
|
||||
const err = new StratumErrorMessage(
|
||||
authorizationMessage.id,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Authorization validation error',
|
||||
errors).response();
|
||||
console.error(err);
|
||||
//console.log(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return;
|
||||
@@ -247,7 +288,7 @@ export class StratumV1Client {
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
//forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(suggestDifficultyMessage, validatorOptions);
|
||||
@@ -256,6 +297,7 @@ export class StratumV1Client {
|
||||
|
||||
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
||||
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
||||
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
||||
if (!success) {
|
||||
return;
|
||||
@@ -279,7 +321,7 @@ export class StratumV1Client {
|
||||
case eRequestMethod.SUBMIT: {
|
||||
|
||||
if (this.stratumInitialized == false) {
|
||||
console.log('Submit before initalized');
|
||||
//console.log('Submit before initalized');
|
||||
await this.socket.end();
|
||||
return;
|
||||
}
|
||||
@@ -292,7 +334,7 @@ export class StratumV1Client {
|
||||
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
//forbidNonWhitelisted: true,
|
||||
};
|
||||
|
||||
const errors = await validate(miningSubmitMessage, validatorOptions);
|
||||
@@ -308,17 +350,18 @@ export class StratumV1Client {
|
||||
|
||||
|
||||
} else {
|
||||
console.error('Mining Submit validation error');
|
||||
this.logValidationError('Mining Submit validation error', errors);
|
||||
const err = new StratumErrorMessage(
|
||||
miningSubmitMessage.id,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Mining Submit validation error',
|
||||
errors).response();
|
||||
console.error(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
this.closeSocket();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -335,7 +378,7 @@ export class StratumV1Client {
|
||||
&& this.clientAuthorization != null
|
||||
&& this.stratumInitialized == false) {
|
||||
|
||||
await this.initStratum();
|
||||
this.initStratum();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -343,9 +386,16 @@ export class StratumV1Client {
|
||||
private async initStratum() {
|
||||
this.stratumInitialized = true;
|
||||
|
||||
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
|
||||
this.logBlockedUserAgent(this.clientSubscription.userAgent);
|
||||
this.closeSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.clientSubscription.userAgent) {
|
||||
case 'cpuminer': {
|
||||
this.sessionDifficulty = 0.1;
|
||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +410,9 @@ export class StratumV1Client {
|
||||
|
||||
this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe(async (jobTemplate) => {
|
||||
try {
|
||||
if(jobTemplate.blockData.clearJobs){
|
||||
this.miningSubmissionHashes.clear();
|
||||
}
|
||||
await this.sendNewMiningJob(jobTemplate);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
@@ -373,34 +426,40 @@ export class StratumV1Client {
|
||||
}, 60 * 1000)
|
||||
);
|
||||
|
||||
this.backgroundWork.push(
|
||||
setInterval(async () => {
|
||||
await this.statistics.saveShares(this.entity);
|
||||
}, 60 * 1000)
|
||||
);
|
||||
// this.backgroundWork.push(
|
||||
// setInterval(async () => {
|
||||
// await this.statistics.saveShares(this.clientEntity);
|
||||
// }, 60 * 1000)
|
||||
// );
|
||||
}
|
||||
|
||||
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
|
||||
|
||||
let payoutInformation;
|
||||
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
|
||||
//50Th/s
|
||||
this.noFee = false;
|
||||
if (this.entity) {
|
||||
this.hashRate = await this.clientStatisticsService.getHashRateForSession(this.clientAuthorization.address, this.clientAuthorization.worker, this.extraNonceAndSessionId);
|
||||
this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
|
||||
}
|
||||
if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
|
||||
payoutInformation = [
|
||||
let payoutInformation= [
|
||||
{ address: this.clientAuthorization.address, percent: 100 }
|
||||
];
|
||||
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
|
||||
// //50Th/s
|
||||
// this.noFee = false;
|
||||
// if (this.clientEntity) {
|
||||
// this.hashRate = await this.clientStatisticsService.getHashRateForSession(this.clientEntity.id);
|
||||
// // 250Gh/s
|
||||
// if(this.hashRate < 250000000000){
|
||||
// this.statistics.targetSubmitShareEveryNSeconds = 10;
|
||||
// }
|
||||
// this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
|
||||
// }
|
||||
// if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
|
||||
// payoutInformation = [
|
||||
// { address: this.clientAuthorization.address, percent: 100 }
|
||||
// ];
|
||||
|
||||
} else {
|
||||
payoutInformation = [
|
||||
{ address: devFeeAddress, percent: 1.5 },
|
||||
{ address: this.clientAuthorization.address, percent: 98.5 }
|
||||
];
|
||||
}
|
||||
// } else {
|
||||
// payoutInformation = [
|
||||
// { address: devFeeAddress, percent: 1.5 },
|
||||
// { address: this.clientAuthorization.address, percent: 98.5 }
|
||||
// ];
|
||||
// }
|
||||
|
||||
const networkConfig = this.configService.get('NETWORK');
|
||||
let network;
|
||||
@@ -419,7 +478,8 @@ export class StratumV1Client {
|
||||
network,
|
||||
this.stratumV1JobsService.getNextId(),
|
||||
payoutInformation,
|
||||
jobTemplate
|
||||
jobTemplate,
|
||||
this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
|
||||
);
|
||||
|
||||
this.stratumV1JobsService.addJob(job);
|
||||
@@ -436,13 +496,14 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
|
||||
private async handleMiningSubmission(submission: MiningSubmitMessage) {
|
||||
private async ensureClientEntity() {
|
||||
if (this.clientEntity != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.entity == null) {
|
||||
if (this.creatingEntity == null) {
|
||||
this.creatingEntity = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
this.entity = await this.clientService.insert({
|
||||
this.creatingEntity = (async () => {
|
||||
this.clientEntity = await this.clientService.insert({
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
address: this.clientAuthorization.address,
|
||||
clientName: this.clientAuthorization.worker,
|
||||
@@ -450,22 +511,17 @@ export class StratumV1Client {
|
||||
startTime: new Date(),
|
||||
bestDifficulty: 0
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
await this.creatingEntity;
|
||||
|
||||
} else {
|
||||
await this.creatingEntity;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await this.creatingEntity;
|
||||
}
|
||||
|
||||
private async handleMiningSubmission(submission: MiningSubmitMessage) {
|
||||
|
||||
const job = this.stratumV1JobsService.getJobById(submission.jobId);
|
||||
|
||||
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification
|
||||
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
|
||||
if (job == null) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
@@ -478,28 +534,98 @@ export class StratumV1Client {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
|
||||
|
||||
const updatedJobBlock = job.copyAndUpdateBlock(
|
||||
if (jobTemplate == null) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
'Job Template not found').response();
|
||||
//console.log(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const classification = this.stratumV1JobsService.classifyJobForShare(job);
|
||||
if (classification === 'stale-rejected') {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
'stale').response();
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const submissionHash = [
|
||||
submission.jobId,
|
||||
submission.extraNonce2,
|
||||
submission.ntime,
|
||||
submission.nonce,
|
||||
submission.versionMask ?? ''
|
||||
].join(':');
|
||||
if (this.miningSubmissionHashes.has(submissionHash)) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.DuplicateShare,
|
||||
'Duplicate share').response();
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
this.miningSubmissionHashes.add(submissionHash);
|
||||
}
|
||||
|
||||
const versionMask = parseInt(submission.versionMask, 16);
|
||||
const nonce = parseInt(submission.nonce, 16);
|
||||
const timestamp = parseInt(submission.ntime, 16);
|
||||
|
||||
const header = job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
parseInt(submission.versionMask, 16),
|
||||
parseInt(submission.nonce, 16),
|
||||
versionMask,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
parseInt(submission.ntime, 16)
|
||||
timestamp
|
||||
);
|
||||
const header = updatedJobBlock.toBuffer(true);
|
||||
const { submissionDifficulty } = this.calculateDifficulty(header);
|
||||
|
||||
//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 >= this.sessionDifficulty) {
|
||||
if (submissionDifficulty >= effectiveDiff) {
|
||||
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
|
||||
console.log('!!! BLOCK FOUND !!!');
|
||||
const updatedJobBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
const blockHex = updatedJobBlock.toHex(false);
|
||||
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||
if (result === 'SUCCESS!') {
|
||||
await this.blocksService.save({
|
||||
height: jobTemplate.blockData.height,
|
||||
minerAddress: this.clientAuthorization.address,
|
||||
@@ -509,40 +635,29 @@ export class StratumV1Client {
|
||||
});
|
||||
|
||||
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
|
||||
//success
|
||||
if (result == null) {
|
||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||
} else {
|
||||
console.warn(`[Block submit rejected at height ${jobTemplate.blockData.height}]: ${result}`);
|
||||
}
|
||||
}
|
||||
await this.ensureClientEntity();
|
||||
try {
|
||||
await this.statistics.addShares(this.sessionDifficulty);
|
||||
await this.statistics.addShares(this.clientEntity, effectiveDiff);
|
||||
const now = new Date();
|
||||
// only update every minute
|
||||
if (this.entity.updatedAt == null || now.getTime() - this.entity.updatedAt.getTime() > 1000 * 60) {
|
||||
await this.clientService.heartbeat(this.entity.address, this.entity.clientName, this.entity.sessionId, this.hashRate, now);
|
||||
this.entity.updatedAt = now;
|
||||
}
|
||||
//if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) {
|
||||
this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now);
|
||||
this.clientEntity.updatedAt = now;
|
||||
//}
|
||||
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.DuplicateShare,
|
||||
'Duplicate share').response();
|
||||
console.error(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (submissionDifficulty > this.entity.bestDifficulty) {
|
||||
await this.clientService.updateBestDifficulty(this.extraNonceAndSessionId, submissionDifficulty);
|
||||
this.entity.bestDifficulty = submissionDifficulty;
|
||||
if (submissionDifficulty > (await this.addressSettingsService.getSettings(this.clientAuthorization.address, true)).bestDifficulty) {
|
||||
await this.addressSettingsService.updateBestDifficulty(this.clientAuthorization.address, submissionDifficulty);
|
||||
}
|
||||
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
|
||||
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
||||
this.clientEntity.bestDifficulty = submissionDifficulty;
|
||||
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
||||
}
|
||||
|
||||
|
||||
@@ -562,7 +677,7 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
//await this.checkDifficulty();
|
||||
return true;
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
@@ -574,6 +689,11 @@ export class StratumV1Client {
|
||||
|
||||
if (targetDiff != this.sessionDifficulty) {
|
||||
//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;
|
||||
|
||||
const data = JSON.stringify({
|
||||
@@ -583,11 +703,14 @@ export class StratumV1Client {
|
||||
}) + '\n';
|
||||
|
||||
|
||||
await this.socket.write(data);
|
||||
const success = await this.write(data);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// we need to clear the jobs so that the difficulty set takes effect. Otherwise the different miner implementations can cause issues
|
||||
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
|
||||
jobTemplate.blockData.clearJobs = true;
|
||||
await this.sendNewMiningJob(jobTemplate);
|
||||
|
||||
}
|
||||
@@ -597,24 +720,107 @@ export class StratumV1Client {
|
||||
|
||||
const hashResult = bitcoinjs.crypto.hash256(header);
|
||||
|
||||
let s64 = this.le256todouble(hashResult);
|
||||
|
||||
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
|
||||
const difficulty = truediffone.div(s64.toString());
|
||||
return { submissionDifficulty: difficulty.toNumber(), submissionHash: hashResult.toString('hex') };
|
||||
const target = this.le256todouble(hashResult);
|
||||
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
|
||||
return { submissionDifficulty, submissionHash: hashResult.toString('hex') };
|
||||
}
|
||||
|
||||
|
||||
private le256todouble(target: Buffer): bigint {
|
||||
private le256todouble(target: Buffer): number {
|
||||
|
||||
const number = target.reduceRight((acc, byte) => {
|
||||
// Shift the number 8 bits to the left and OR with the current byte
|
||||
return (acc << BigInt(8)) | BigInt(byte);
|
||||
}, BigInt(0));
|
||||
let number = 0;
|
||||
for (let i = target.length - 1; i >= 0; i--) {
|
||||
number = number * 256 + target[i];
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
private isBlockedUserAgent(userAgent: string): boolean {
|
||||
const blockedUserAgents = this.configService.get<string>('NON_COMPLIANT_USER_AGENTS')
|
||||
|| this.configService.get<string>('BLOCKED_USER_AGENTS')
|
||||
|| this.configService.get<string>('COMPLIANT_HEADERS');
|
||||
if (!blockedUserAgents || blockedUserAgents.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockedList = blockedUserAgents.split(',').map(ua => ua.trim().toLowerCase());
|
||||
const userAgentLower = userAgent.toLowerCase();
|
||||
|
||||
return blockedList.some(blocked => blocked.length > 0 && userAgentLower.includes(blocked));
|
||||
}
|
||||
|
||||
private logBlockedUserAgent(userAgent: string) {
|
||||
const now = Date.now();
|
||||
const logState = StratumV1Client.blockedUserAgentLogState.get(userAgent);
|
||||
|
||||
if (logState != null && now < logState.nextLogAt) {
|
||||
logState.suppressed += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const suppressed = logState?.suppressed ?? 0;
|
||||
const suffix = suppressed > 0 ? ` (${suppressed} similar connections suppressed)` : '';
|
||||
console.log(`Blocked non-compliant connection from userAgent: ${userAgent}${suffix}`);
|
||||
StratumV1Client.blockedUserAgentLogState.set(userAgent, {
|
||||
nextLogAt: now + BLOCKED_USER_AGENT_LOG_INTERVAL_MS,
|
||||
suppressed: 0
|
||||
});
|
||||
}
|
||||
|
||||
private logValidationError(label: string, errors: ValidationError[]) {
|
||||
const now = Date.now();
|
||||
const signature = this.getValidationErrorSignature(errors);
|
||||
const sample = this.getValidationErrorSample(errors);
|
||||
const key = `${label}:${signature}`;
|
||||
const logState = StratumV1Client.validationErrorLogState.get(key);
|
||||
|
||||
if (logState != null && now < logState.nextLogAt) {
|
||||
logState.suppressed += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const suppressed = logState?.suppressed ?? 0;
|
||||
const suffix = suppressed > 0 ? ` (${suppressed} similar validation errors suppressed)` : '';
|
||||
console.warn(`${label}: ${signature}${sample}${suffix}`);
|
||||
StratumV1Client.validationErrorLogState.set(key, {
|
||||
nextLogAt: now + VALIDATION_ERROR_LOG_INTERVAL_MS,
|
||||
suppressed: 0,
|
||||
sample
|
||||
});
|
||||
}
|
||||
|
||||
private getValidationErrorSignature(errors: ValidationError[]): string {
|
||||
if (errors.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return errors.map(error => {
|
||||
const constraints = Object.keys(error.constraints ?? {}).sort().join('|') || 'invalid';
|
||||
return `${error.property}:${constraints}`;
|
||||
}).join(';');
|
||||
}
|
||||
|
||||
private getValidationErrorSample(errors: ValidationError[]): string {
|
||||
const values = errors
|
||||
.map(error => error.value)
|
||||
.filter(value => value != null)
|
||||
.map(value => String(value).replace(/[\r\n]/g, '').slice(0, 64));
|
||||
|
||||
if (values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ` sample=${values.join(',')}`;
|
||||
}
|
||||
|
||||
private closeSocket() {
|
||||
this.connectionClosed = true;
|
||||
if (!this.socket.destroyed) {
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async write(message: string): Promise<boolean> {
|
||||
try {
|
||||
if (!this.socket.destroyed && !this.socket.writableEnded) {
|
||||
@@ -631,7 +837,7 @@ export class StratumV1Client {
|
||||
|
||||
return true;
|
||||
} 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();
|
||||
if (!this.socket.destroyed) {
|
||||
this.socket.destroy();
|
||||
@@ -645,7 +851,7 @@ export class StratumV1Client {
|
||||
} else if (!this.socket.destroyed) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
|
||||
|
||||
describe('StratumV1ClientStatistics', () => {
|
||||
const client = {
|
||||
id: 'client-id',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'bitaxe3',
|
||||
sessionId: '57a6f098'
|
||||
} as any;
|
||||
|
||||
let clientStatisticsService: {
|
||||
insert: jest.Mock,
|
||||
updateBulkAsync: jest.Mock
|
||||
};
|
||||
let statistics: StratumV1ClientStatistics;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
|
||||
clientStatisticsService = {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
updateBulkAsync: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should insert the first share bucket', async () => {
|
||||
await statistics.addShares(client, 64);
|
||||
|
||||
expect(clientStatisticsService.insert).toHaveBeenCalledWith({
|
||||
time: new Date('2026-05-06T12:00:00Z').getTime(),
|
||||
clientId: client.id,
|
||||
shares: 64,
|
||||
acceptedCount: 1,
|
||||
address: client.address,
|
||||
clientName: client.clientName,
|
||||
sessionId: client.sessionId
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the current share bucket for additional shares', async () => {
|
||||
await statistics.addShares(client, 64);
|
||||
await statistics.addShares(client, 32);
|
||||
|
||||
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({
|
||||
time: new Date('2026-05-06T12:00:00Z').getTime(),
|
||||
clientId: client.id,
|
||||
shares: 96,
|
||||
acceptedCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new share bucket when the time slot changes', async () => {
|
||||
await statistics.addShares(client, 64);
|
||||
jest.setSystemTime(new Date('2026-05-06T12:10:00Z'));
|
||||
|
||||
await statistics.addShares(client, 32);
|
||||
|
||||
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({
|
||||
time: new Date('2026-05-06T12:00:00Z').getTime(),
|
||||
clientId: client.id,
|
||||
shares: 64,
|
||||
acceptedCount: 1
|
||||
});
|
||||
expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({
|
||||
time: new Date('2026-05-06T12:10:00Z').getTime(),
|
||||
clientId: client.id,
|
||||
shares: 32,
|
||||
acceptedCount: 1,
|
||||
address: client.address,
|
||||
clientName: client.clientName,
|
||||
sessionId: client.sessionId
|
||||
});
|
||||
});
|
||||
|
||||
it('should not suggest a difficulty change before enough time or shares have passed', () => {
|
||||
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
||||
});
|
||||
|
||||
it('should lower difficulty when a miner has not submitted shares for more than a minute', () => {
|
||||
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
|
||||
|
||||
expect(statistics.getSuggestedDifficulty(64)).toBe(12);
|
||||
});
|
||||
|
||||
it('should increase difficulty for rapid submissions', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 1000)));
|
||||
await statistics.addShares(client, 64);
|
||||
}
|
||||
|
||||
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
|
||||
});
|
||||
|
||||
it('should decrease difficulty for slow submissions', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 150000)));
|
||||
await statistics.addShares(client, 64);
|
||||
}
|
||||
|
||||
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -2,67 +2,110 @@ import { ClientStatisticsService } from '../ORM/client-statistics/client-statist
|
||||
import { ClientEntity } from '../ORM/client/client.entity';
|
||||
|
||||
const CACHE_SIZE = 30;
|
||||
const TARGET_SUBMISSION_PER_SECOND = 10;
|
||||
const MIN_DIFF = 0.00001;
|
||||
const MIN_DIFF = 0.001;
|
||||
export class StratumV1ClientStatistics {
|
||||
|
||||
private shareBacklog: number = 0;
|
||||
public targetSubmitShareEveryNSeconds: number = 30;
|
||||
public hashRate = 0;
|
||||
|
||||
private shares: number = 0;
|
||||
private acceptedCount: number = 0;
|
||||
|
||||
private submissionCacheStart: Date;
|
||||
private submissionCache = [];
|
||||
private submissionCache: { time: Date, difficulty: number }[] = [];
|
||||
private submissionCacheDifficultySum = 0;
|
||||
|
||||
private currentTimeSlot: number = null;
|
||||
|
||||
constructor(
|
||||
private readonly clientStatisticsService: ClientStatisticsService
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
) {
|
||||
this.submissionCacheStart = new Date();
|
||||
}
|
||||
|
||||
|
||||
public async saveShares(client: ClientEntity) {
|
||||
|
||||
if (client == null || client.address == null || client.clientName == null || client.sessionId == null) {
|
||||
return;
|
||||
}
|
||||
// We don't want to save them here because it can be DB intensive, instead do it every once in
|
||||
// awhile with saveShares()
|
||||
public async addShares(client: ClientEntity, targetDifficulty: number) {
|
||||
|
||||
// 10 min
|
||||
var coeff = 1000 * 60 * 10;
|
||||
var date = new Date();
|
||||
var rounded = new Date(Math.floor(date.getTime() / coeff) * coeff);
|
||||
var timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime();
|
||||
|
||||
await this.clientStatisticsService.save({
|
||||
time: rounded.getTime(),
|
||||
shares: this.shareBacklog,
|
||||
if (this.submissionCache.length > CACHE_SIZE) {
|
||||
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
|
||||
this.submissionCache.shift();
|
||||
}
|
||||
this.submissionCache.push({
|
||||
time: date,
|
||||
difficulty: targetDifficulty,
|
||||
});
|
||||
this.submissionCacheDifficultySum += targetDifficulty;
|
||||
|
||||
if (this.currentTimeSlot == null) {
|
||||
// First record, insert it
|
||||
this.currentTimeSlot = timeSlot;
|
||||
this.shares += targetDifficulty;
|
||||
this.acceptedCount++;
|
||||
await this.clientStatisticsService.insert({
|
||||
time: this.currentTimeSlot,
|
||||
clientId: client.id,
|
||||
shares: this.shares,
|
||||
acceptedCount: this.acceptedCount,
|
||||
address: client.address,
|
||||
clientName: client.clientName,
|
||||
sessionId: client.sessionId
|
||||
});
|
||||
|
||||
this.shareBacklog = 0;
|
||||
}
|
||||
|
||||
// We don't want to save them here because it can be DB intensive, stead do it every once in
|
||||
// awhile with saveShares()
|
||||
public async addShares(targetDifficulty: number) {
|
||||
|
||||
if (this.submissionCache.length > CACHE_SIZE) {
|
||||
this.submissionCache.shift();
|
||||
}
|
||||
|
||||
this.submissionCache.push({
|
||||
time: new Date(),
|
||||
difficulty: targetDifficulty,
|
||||
} else if (this.currentTimeSlot != timeSlot) {
|
||||
// Transitioning to a new time slot,
|
||||
// First update the old time slot with the latest data
|
||||
this.clientStatisticsService.updateBulkAsync({
|
||||
time: this.currentTimeSlot,
|
||||
clientId: client.id,
|
||||
shares: this.shares,
|
||||
acceptedCount: this.acceptedCount,
|
||||
});
|
||||
// Set the new time slot and add incoming shares then insert it
|
||||
this.currentTimeSlot = timeSlot;
|
||||
this.shares = targetDifficulty;
|
||||
this.acceptedCount = 1
|
||||
await this.clientStatisticsService.insert({
|
||||
time: this.currentTimeSlot,
|
||||
clientId: client.id,
|
||||
shares: this.shares,
|
||||
acceptedCount: this.acceptedCount,
|
||||
address: client.address,
|
||||
clientName: client.clientName,
|
||||
sessionId: client.sessionId
|
||||
});
|
||||
} else {
|
||||
// Accept the shares if none of the prior conditions are met,
|
||||
// saving to memory for storing later
|
||||
this.shares += targetDifficulty;
|
||||
this.acceptedCount++;
|
||||
this.clientStatisticsService.updateBulkAsync({
|
||||
time: this.currentTimeSlot,
|
||||
clientId: client.id,
|
||||
shares: this.shares,
|
||||
acceptedCount: this.acceptedCount,
|
||||
});
|
||||
|
||||
this.shareBacklog += targetDifficulty;
|
||||
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
// miner hasn't submitted shares in one minute
|
||||
if (this.submissionCache.length < 5) {
|
||||
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
|
||||
return this.nearestPowerOfTwo(clientDifficulty / 6);
|
||||
return this.nearestDifficultyStep(clientDifficulty / 6);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -73,39 +116,45 @@ export class StratumV1ClientStatistics {
|
||||
return pre;
|
||||
}, 0);
|
||||
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 targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND;
|
||||
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
||||
if (!Number.isFinite(difficultyPerSecond) || !Number.isFinite(targetDifficulty)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
||||
return this.nearestPowerOfTwo(targetDifficulty)
|
||||
return this.nearestDifficultyStep(targetDifficulty)
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private nearestPowerOfTwo(val): number {
|
||||
private nearestDifficultyStep(val: number): number {
|
||||
if (val === 0) {
|
||||
return null;
|
||||
}
|
||||
if (val < MIN_DIFF) {
|
||||
return MIN_DIFF;
|
||||
}
|
||||
let x = val | (val >> 1);
|
||||
x = x | (x >> 2);
|
||||
x = x | (x >> 4);
|
||||
x = x | (x >> 8);
|
||||
x = x | (x >> 16);
|
||||
x = x | (x >> 32);
|
||||
const res = x - (x >> 1);
|
||||
if (res == 0 && val * 100 < MIN_DIFF) {
|
||||
return MIN_DIFF;
|
||||
}
|
||||
if (res == 0) {
|
||||
return this.nearestPowerOfTwo(val * 100) / 100;
|
||||
}
|
||||
return res;
|
||||
|
||||
const exponent = Math.floor(Math.log2(val));
|
||||
const lower = 2 ** exponent;
|
||||
const middle = lower + lower / 2;
|
||||
const upper = lower * 2;
|
||||
|
||||
const distances = [
|
||||
{ value: lower, diff: Math.abs(val - lower) },
|
||||
{ value: middle, diff: Math.abs(val - middle) },
|
||||
{ value: upper, diff: Math.abs(val - upper) },
|
||||
];
|
||||
|
||||
distances.sort((a, b) => a.diff - b.diff);
|
||||
return distances[0].value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
|
||||
import { AuthorizationMessage } from './AuthorizationMessage';
|
||||
|
||||
describe('AuthorizationMessage', () => {
|
||||
it('should parse address, worker, and starting difficulty', () => {
|
||||
const message = plainToInstance(
|
||||
AuthorizationMessage,
|
||||
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=2048"]}')
|
||||
);
|
||||
|
||||
expect(message.address).toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
|
||||
expect(message.worker).toBe('worker1');
|
||||
expect(message.password).toBe('x,d=2048');
|
||||
expect(message.startingDiff).toBe(2048);
|
||||
});
|
||||
|
||||
it('should default worker name when one is not provided', () => {
|
||||
const message = plainToInstance(
|
||||
AuthorizationMessage,
|
||||
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4","x"]}')
|
||||
);
|
||||
|
||||
expect(message.worker).toBe('worker');
|
||||
expect(message.startingDiff).toBeNull();
|
||||
});
|
||||
|
||||
it('should build successful authorization responses', () => {
|
||||
const message = plainToInstance(
|
||||
AuthorizationMessage,
|
||||
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x"]}')
|
||||
);
|
||||
|
||||
expect(message.response()).toEqual({
|
||||
id: 3,
|
||||
error: null,
|
||||
result: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Expose, Transform } from 'class-transformer';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, MaxLength } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
|
||||
@@ -8,7 +8,7 @@ import { StratumBaseMessage } from './StratumBaseMessage';
|
||||
export class AuthorizationMessage extends StratumBaseMessage {
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(2)
|
||||
params: string[];
|
||||
|
||||
@@ -24,18 +24,36 @@ export class AuthorizationMessage extends StratumBaseMessage {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[0].split('.')[1] == null ? 'worker' : obj.params[0].split('.')[1];
|
||||
const workerName = obj.params[0].split('.')[1];
|
||||
if (workerName == null) {
|
||||
return 'worker';
|
||||
}
|
||||
return workerName;
|
||||
})
|
||||
public worker: string;
|
||||
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
const password: string | null = obj.params[1];
|
||||
if (password?.includes('d=')) {
|
||||
return parseInt(password.split('d=')[1]);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
@IsOptional()
|
||||
public startingDiff: number;
|
||||
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[1];
|
||||
})
|
||||
@MaxLength(64)
|
||||
public password: string;
|
||||
@IsOptional()
|
||||
public password?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { MiningSubmitMessage } from './MiningSubmitMessage';
|
||||
|
||||
@@ -13,7 +14,7 @@ describe('MiningSubmitMessage', () => {
|
||||
|
||||
describe('test message parsing', () => {
|
||||
|
||||
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'
|
||||
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "9902000000000000", "64b1f10f", "2402812d", "00006000"]}'
|
||||
|
||||
const message = plainToInstance(
|
||||
MiningSubmitMessage,
|
||||
@@ -24,11 +25,62 @@ describe('MiningSubmitMessage', () => {
|
||||
expect(message.id).toEqual(5);
|
||||
expect(message.userId).toEqual('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3');
|
||||
expect(message.jobId).toEqual('1');
|
||||
expect(message.extraNonce2).toEqual('99020000');
|
||||
expect(message.extraNonce2).toEqual('9902000000000000');
|
||||
expect(message.ntime).toEqual('64b1f10f');
|
||||
expect(message.nonce).toEqual('2402812d');
|
||||
expect(message.versionMask).toEqual('00006000');
|
||||
});
|
||||
|
||||
it('should validate 8-byte extranonce2 submissions', async () => {
|
||||
const errors = await validate(message);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reject short extranonce2 submissions', async () => {
|
||||
const shortMessage = plainToInstance(
|
||||
MiningSubmitMessage,
|
||||
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'),
|
||||
);
|
||||
|
||||
const errors = await validate(shortMessage);
|
||||
|
||||
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject long extranonce2 submissions', async () => {
|
||||
const longMessage = plainToInstance(
|
||||
MiningSubmitMessage,
|
||||
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "990200000000000000", "64b1f10f", "2402812d", "00006000"]}'),
|
||||
);
|
||||
|
||||
const errors = await validate(longMessage);
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Expose, Transform } from 'class-transformer';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
|
||||
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
|
||||
import { StratumBaseMessage } from './StratumBaseMessage';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
|
||||
export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
|
||||
@@ -25,6 +28,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
public jobId: string;
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[2];
|
||||
})
|
||||
@@ -63,6 +67,17 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
};
|
||||
}
|
||||
|
||||
public hash(): string{
|
||||
const canonical = JSON.stringify({
|
||||
versionMask: this.versionMask ?? '',
|
||||
nonce: this.nonce,
|
||||
extraNonce2: this.extraNonce2,
|
||||
ntime: this.ntime,
|
||||
jobId: this.jobId,
|
||||
});
|
||||
return bitcoinjs.crypto.hash256(Buffer.from(canonical)).toString('base64');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IsEnum, IsNumber } from 'class-validator';
|
||||
import { IsDefined, IsEnum, IsNumber, IsNumberString, isNumberString, IsOptional } from 'class-validator';
|
||||
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
|
||||
export class StratumBaseMessage {
|
||||
@IsNumber()
|
||||
id?: number = null;
|
||||
@IsDefined()
|
||||
id?: number | string = null;
|
||||
@IsEnum(eRequestMethod)
|
||||
method: eRequestMethod;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { eStratumErrorCode } from '../enums/eStratumErrorCode';
|
||||
export class StratumErrorMessage {
|
||||
|
||||
constructor(
|
||||
private id: number = null,
|
||||
private id: number | string = null,
|
||||
private errorCode: eStratumErrorCode,
|
||||
private errorMessage: string,
|
||||
private validationErrors: ValidationError[] = []
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
|
||||
import { SubscriptionMessage } from './SubscriptionMessage';
|
||||
|
||||
describe('SubscriptionMessage', () => {
|
||||
it('should parse and refine known user agents', () => {
|
||||
const bosminer = plainToInstance(
|
||||
SubscriptionMessage,
|
||||
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bosminer/23.08"]}')
|
||||
);
|
||||
const cpuminer = plainToInstance(
|
||||
SubscriptionMessage,
|
||||
JSON.parse('{"id":1,"method":"mining.subscribe","params":["cpuminer-opt/1.0"]}')
|
||||
);
|
||||
|
||||
expect(bosminer.userAgent).toBe('Braiins OS');
|
||||
expect(cpuminer.userAgent).toBe('cpuminer');
|
||||
});
|
||||
|
||||
it('should default missing user agents to unknown', () => {
|
||||
const message = plainToInstance(
|
||||
SubscriptionMessage,
|
||||
JSON.parse('{"id":1,"method":"mining.subscribe","params":[]}')
|
||||
);
|
||||
|
||||
expect(message.userAgent).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should respond with extranonce2 size of 8 bytes', () => {
|
||||
const message = plainToInstance(
|
||||
SubscriptionMessage,
|
||||
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bitaxe v2.2"]}')
|
||||
);
|
||||
|
||||
expect(message.response('57a6f098')).toEqual({
|
||||
id: 1,
|
||||
error: null,
|
||||
result: [
|
||||
[['mining.notify', '57a6f098']],
|
||||
'57a6f098',
|
||||
8
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
|
||||
import { IsArray, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
|
||||
import { StratumBaseMessage } from './StratumBaseMessage';
|
||||
|
||||
export class SubscriptionMessage extends StratumBaseMessage {
|
||||
@@ -14,7 +15,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[0] == null ? 'unknown' : SubscriptionMessage.refineUserAgent(obj.params[0]);
|
||||
return obj?.params?.[0] == null ? 'unknown' : SubscriptionMessage.refineUserAgent(obj.params[0]);
|
||||
})
|
||||
public userAgent: string;
|
||||
|
||||
@@ -32,7 +33,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
|
||||
['mining.notify', clientId]
|
||||
],
|
||||
clientId, //Extranonce1 - Hex-encoded, per-connection unique string which will be used for coinbase serialization later. Keep it safe!
|
||||
4 //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
|
||||
EXTRANONCE2_SIZE_BYTES //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
|
||||
]
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
|
||||
|
||||
public static refineUserAgent(userAgent: string): string {
|
||||
// return userAgent;
|
||||
userAgent = userAgent.split(' ')[0].split('/')[0].split('V')[0];
|
||||
userAgent = userAgent.split(' ')[0].split('/')[0].split('V')[0].split('-')[0];
|
||||
|
||||
if (userAgent.includes('bosminer') || userAgent.includes('bOS')) {
|
||||
userAgent = 'Braiins OS';
|
||||
|
||||
@@ -31,7 +31,7 @@ export class SuggestDifficulty extends StratumBaseMessage {
|
||||
|
||||
public response(difficulty: number) {
|
||||
return {
|
||||
id: this.id,
|
||||
id: null,
|
||||
method: eResponseMethod.SET_DIFFICULTY,
|
||||
params: [difficulty]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const EXTRANONCE1_SIZE_BYTES = 4;
|
||||
export const EXTRANONCE2_SIZE_BYTES = 8;
|
||||
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
|
||||
+84
-24
@@ -1,9 +1,11 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
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 { ClientService } from '../ORM/client/client.service';
|
||||
import { HomeGraphService } from '../ORM/home-graph/home-graph.service';
|
||||
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService implements OnModuleInit {
|
||||
@@ -11,42 +13,100 @@ export class AppService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
private readonly clientService: ClientService,
|
||||
private readonly dataSource: DataSource
|
||||
private readonly rpcBlockService: RpcBlockService,
|
||||
private readonly homeGraphService: HomeGraphService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly userAgentReportService: UserAgentReportService
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
// if (process.env.NODE_APP_INSTANCE == '0') {
|
||||
// await this.dataSource.query(`VACUUM;`);
|
||||
// }
|
||||
if (process.env.MASTER == 'true') {
|
||||
|
||||
setInterval(async () => {
|
||||
await this.deleteOldStatistics();
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
setInterval(async () => {
|
||||
console.log('Killing dead clients');
|
||||
while (await this.clientService.killDeadClients()) {
|
||||
|
||||
}
|
||||
console.log('Finished killing clients');
|
||||
}, 1000 * 60 * 5);
|
||||
|
||||
setInterval(async () => {
|
||||
console.log('Deleting Old Blocks');
|
||||
await this.rpcBlockService.deleteOldBlocks();
|
||||
}, 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);
|
||||
|
||||
|
||||
//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;`);
|
||||
}
|
||||
|
||||
@Interval(1000 * 60 * 60)
|
||||
private async deleteOldStatistics() {
|
||||
console.log('Deleting statistics');
|
||||
if (process.env.ENABLE_SOLO == 'true' && (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0')) {
|
||||
|
||||
const deletedStatistics = await this.clientStatisticsService.deleteOldStatistics();
|
||||
console.log(`Deleted ${deletedStatistics.affected} old statistics`);
|
||||
const deletedClients = await this.clientService.deleteOldClients();
|
||||
console.log(`Deleted ${deletedClients.affected} old clients`);
|
||||
}
|
||||
}
|
||||
|
||||
@Interval(1000 * 60 * 5)
|
||||
private async killDeadClients() {
|
||||
console.log('Killing dead clients');
|
||||
if (process.env.ENABLE_SOLO == 'true' && (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0')) {
|
||||
await this.clientService.killDeadClients();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
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,123 +1,154 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RPCClient } from 'rpc-bitcoin';
|
||||
import { BehaviorSubject, filter, shareReplay } from 'rxjs';
|
||||
import { RpcBlockService } from 'src/ORM/rpc-block/rpc-block.service';
|
||||
import * as zmq from 'zeromq/v5-compat';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs';
|
||||
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
|
||||
import * as zmq from 'zeromq';
|
||||
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||
import * as PGPubsub from 'pg-pubsub';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
@Injectable()
|
||||
export class BitcoinRpcService {
|
||||
export class BitcoinRpcService implements OnModuleInit {
|
||||
|
||||
private blockHeight = 0;
|
||||
private client: RPCClient;
|
||||
private _newBlock$: BehaviorSubject<IMiningInfo> = new BehaviorSubject(undefined);
|
||||
public newBlock$ = this._newBlock$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
|
||||
private client: AxiosInstance;
|
||||
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
|
||||
private pubsubInstance: PGPubsub;
|
||||
private resetTemplateInterval$ = new Subject<void>();
|
||||
private rpcRequestId = 0;
|
||||
|
||||
public miningInfo: IMiningInfo;
|
||||
public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private rpcBlockService: RpcBlockService
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
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 user = this.configService.get('BITCOIN_RPC_USER');
|
||||
const pass = this.configService.get('BITCOIN_RPC_PASSWORD');
|
||||
let user = this.configService.get('BITCOIN_RPC_USER');
|
||||
let pass = this.configService.get('BITCOIN_RPC_PASSWORD');
|
||||
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
|
||||
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
|
||||
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
|
||||
|
||||
this.client = new RPCClient({ url, port, timeout, user, pass });
|
||||
if (cookiefile != null && cookiefile !== '') {
|
||||
const [cookieUser, cookiePass] = fs.readFileSync(cookiefile).toString().trim().split(':');
|
||||
user = cookieUser;
|
||||
pass = cookiePass;
|
||||
}
|
||||
|
||||
this.client.getrpcinfo().then((res) => {
|
||||
const baseURL = this.buildRpcUrl(url, port);
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
auth: {
|
||||
username: user,
|
||||
password: pass
|
||||
}
|
||||
});
|
||||
|
||||
this.callRpc('getrpcinfo').then((res) => {
|
||||
console.log('Bitcoin RPC connected');
|
||||
}, () => {
|
||||
console.error('Could not reach RPC host');
|
||||
});
|
||||
|
||||
if (this.configService.get('BITCOIN_ZMQ_HOST')) {
|
||||
const sock = zmq.socket("sub");
|
||||
sock.connect(this.configService.get('BITCOIN_ZMQ_HOST'));
|
||||
sock.subscribe("rawblock");
|
||||
sock.on("message", async (topic: Buffer, message: Buffer) => {
|
||||
console.log("new block zmq");
|
||||
await this.pollMiningInfo();
|
||||
this.miningInfo = await this.getMiningInfo();
|
||||
|
||||
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));
|
||||
});
|
||||
this.pollMiningInfo().then(() => { });
|
||||
} else {
|
||||
setInterval(this.pollMiningInfo.bind(this), 500);
|
||||
console.log('Using ZMQ');
|
||||
const sock = new zmq.Subscriber;
|
||||
|
||||
sock.connectTimeout = 1000;
|
||||
sock.events.on('connect', () => {
|
||||
console.log('ZMQ Connected');
|
||||
});
|
||||
sock.events.on('connect:retry', () => {
|
||||
console.error('ZMQ Unable to connect, Retrying');
|
||||
});
|
||||
|
||||
sock.connect(this.configService.get('BITCOIN_ZMQ_HOST'));
|
||||
sock.subscribe('rawblock');
|
||||
// Don't await this, otherwise it will block the rest of the program
|
||||
this.listenForNewBlocks(sock);
|
||||
|
||||
// Between new blocks we want refresh jobs with the latest transactions
|
||||
this.resetTemplateInterval$.pipe(
|
||||
startWith(null),
|
||||
switchMap(() =>interval(60000))
|
||||
).subscribe(async () =>{
|
||||
await this.getAndBroadcastLatestTemplate();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async listenForNewBlocks(sock: zmq.Subscriber) {
|
||||
for await (const [topic, msg] of sock) {
|
||||
console.log("New Block");
|
||||
this.miningInfo = await this.getMiningInfo();
|
||||
await this.getAndBroadcastLatestTemplate();
|
||||
|
||||
//Reset the block update interval
|
||||
this.resetTemplateInterval$.next();
|
||||
}
|
||||
}
|
||||
|
||||
public async pollMiningInfo() {
|
||||
const miningInfo = await this.getMiningInfo();
|
||||
if (miningInfo != null && miningInfo.blocks > this.blockHeight) {
|
||||
console.log("block height change");
|
||||
this._newBlock$.next(miningInfo);
|
||||
this.blockHeight = miningInfo.blocks;
|
||||
}
|
||||
public async getAndBroadcastLatestTemplate() {
|
||||
const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks);
|
||||
this._newBlockTemplate$.next(blockTemplate);
|
||||
await this.pubsubInstance.publish('miningInfo', this.miningInfo);
|
||||
}
|
||||
|
||||
private async waitForBlock(blockHeight: number): Promise<IBlockTemplate> {
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
private async loadBlockTemplate(blockHeight: number) {
|
||||
|
||||
const block = await this.rpcBlockService.getBlock(blockHeight);
|
||||
if (block != null && block.data != null) {
|
||||
console.log('promise loop resolved');
|
||||
return Promise.resolve(JSON.parse(block.data));
|
||||
}
|
||||
console.log('promise loop');
|
||||
}
|
||||
}
|
||||
console.log(`Master fetching block template ${blockHeight}`);
|
||||
|
||||
public async getBlockTemplate(blockHeight: number): Promise<IBlockTemplate> {
|
||||
let result: IBlockTemplate;
|
||||
try {
|
||||
|
||||
const block = await this.rpcBlockService.getBlock(blockHeight);
|
||||
|
||||
if (block != null && block.data != null) {
|
||||
return Promise.resolve(JSON.parse(block.data));
|
||||
} else if (block == null) {
|
||||
|
||||
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.client.getblocktemplate({
|
||||
template_request: {
|
||||
let blockTemplate: IBlockTemplate;
|
||||
while (blockTemplate == null) {
|
||||
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
|
||||
{
|
||||
rules: ['segwit'],
|
||||
mode: 'template',
|
||||
capabilities: ['serverlist', 'proposal']
|
||||
}
|
||||
});
|
||||
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(result));
|
||||
|
||||
} else {
|
||||
//wait for block
|
||||
result = await this.waitForBlock(blockHeight);
|
||||
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
try {
|
||||
console.log(`Saving block ${blockHeight}`);
|
||||
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
|
||||
console.log('block saved');
|
||||
} catch (e) {
|
||||
console.error('Error getblocktemplate:', e.message);
|
||||
throw new Error('Error getblocktemplate');
|
||||
console.error('Error saving block', e);
|
||||
}
|
||||
console.log(`getblocktemplate tx count: ${result.transactions.length}`);
|
||||
return result;
|
||||
|
||||
return blockTemplate;
|
||||
}
|
||||
|
||||
public async getMiningInfo(): Promise<IMiningInfo> {
|
||||
try {
|
||||
return await this.client.getmininginfo();
|
||||
return await this.callRpc<IMiningInfo>('getmininginfo');
|
||||
} catch (e) {
|
||||
console.error('Error getmininginfo', e.message);
|
||||
return null;
|
||||
@@ -128,9 +159,7 @@ export class BitcoinRpcService {
|
||||
public async SUBMIT_BLOCK(hexdata: string): Promise<string> {
|
||||
let response: string = 'unknown';
|
||||
try {
|
||||
response = await this.client.submitblock({
|
||||
hexdata
|
||||
});
|
||||
response = await this.callRpc<string>('submitblock', [hexdata]);
|
||||
if (response == null) {
|
||||
response = 'SUCCESS!';
|
||||
}
|
||||
@@ -144,5 +173,28 @@ export class BitcoinRpcService {
|
||||
return response;
|
||||
|
||||
}
|
||||
|
||||
private async callRpc<T>(method: string, params: unknown[] = []): Promise<T> {
|
||||
const response = await this.client.post('', {
|
||||
jsonrpc: '1.0',
|
||||
id: ++this.rpcRequestId,
|
||||
method,
|
||||
params
|
||||
});
|
||||
|
||||
if (response.data.error != null) {
|
||||
throw response.data.error;
|
||||
}
|
||||
|
||||
return response.data.result;
|
||||
}
|
||||
|
||||
private buildRpcUrl(url: string, port: number): string {
|
||||
const normalizedUrl = /^https?:\/\//i.test(url) ? url : `http://${url}`;
|
||||
const rpcUrl = new URL(normalizedUrl);
|
||||
if (Number.isFinite(port) && port > 0) {
|
||||
rpcUrl.port = port.toString();
|
||||
}
|
||||
return rpcUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class DiscordService implements OnModuleInit {
|
||||
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
this.token = this.configService.get('DISCORD_BOT_TOKEN');
|
||||
this.clientId = this.configService.get('DISCORD_BOT_CLIENTID');
|
||||
this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID');
|
||||
@@ -61,7 +61,7 @@ export class DiscordService implements OnModuleInit {
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
|
||||
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
if (this.bot == null) {
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export class DiscordService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async registerCommands() {
|
||||
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
const rest = new REST().setToken(this.token);
|
||||
try {
|
||||
console.log(`Started refreshing ${commands.length} application (/) commands.`);
|
||||
@@ -113,7 +113,7 @@ export class DiscordService implements OnModuleInit {
|
||||
}
|
||||
|
||||
public async notifyRestarted() {
|
||||
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
if (this.bot == null) {
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export class DiscordService implements OnModuleInit {
|
||||
}
|
||||
|
||||
public async notifySubscribersBlockFound(height: number, block: Block, message: string) {
|
||||
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
if (this.bot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Server, Socket } from 'net';
|
||||
|
||||
import { ProxyClient } from '../models/ProxyClient';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService implements OnModuleInit {
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (process.env.ENABLE_PROXY == 'true') {
|
||||
console.log('Connecting to braiins');
|
||||
|
||||
|
||||
const proxyServer = new Server(async (socket: Socket) => {
|
||||
const proxyClient = new ProxyClient(socket);
|
||||
});
|
||||
|
||||
proxyServer.listen(process.env.PROXY_PORT, () => {
|
||||
console.log(`Proxy server is listening on port ${process.env.PROXY_PORT}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BehaviorSubject, firstValueFrom, skip } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
|
||||
describe('StratumV1JobsService', () => {
|
||||
let blockTemplate$: BehaviorSubject<IBlockTemplate>;
|
||||
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } };
|
||||
let service: StratumV1JobsService;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
height
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
blockTemplate$ = new BehaviorSubject(createTemplate());
|
||||
bitcoinRpcService = {
|
||||
newBlockTemplate$: blockTemplate$.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
|
||||
};
|
||||
service = new StratumV1JobsService(bitcoinRpcService as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should create job templates from block templates', async () => {
|
||||
const jobTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
|
||||
expect(jobTemplate.blockData).toEqual(expect.objectContaining({
|
||||
id: '1',
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height,
|
||||
clearJobs: true,
|
||||
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue
|
||||
}));
|
||||
expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0);
|
||||
expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32);
|
||||
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should retire jobs when the block height changes', async () => {
|
||||
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
|
||||
|
||||
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1;
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1));
|
||||
const jobTemplate = await nextTemplate;
|
||||
|
||||
expect(jobTemplate.blockData.clearJobs).toBe(true);
|
||||
expect(service.getJobById('old-job')).toEqual(expect.objectContaining({
|
||||
jobId: 'old-job',
|
||||
retiredAt: Date.now()
|
||||
}));
|
||||
expect(service.getJobTemplateById(firstTemplate.blockData.id).blockData.retiredAt).toBe(Date.now());
|
||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should age retired jobs and templates after the retention window', async () => {
|
||||
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const oldCreation = Date.now() - (1000 * 60 * 11);
|
||||
const retiredAt = Date.now() - (1000 * 60 * 11);
|
||||
firstTemplate.blockData.retiredAt = retiredAt;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
service.jobs[`old-job-${i}`] = { jobId: `old-job-${i}`, creation: oldCreation - i, retiredAt } as any;
|
||||
(service as any).trackJob(`old-job-${i}`);
|
||||
service.blocks[`old-template-${i}`] = {
|
||||
blockData: { creation: oldCreation - i, retiredAt }
|
||||
} as any;
|
||||
(service as any).trackBlock(`old-template-${i}`);
|
||||
}
|
||||
|
||||
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height;
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(createTemplate());
|
||||
const jobTemplate = await nextTemplate;
|
||||
|
||||
expect(jobTemplate.blockData.clearJobs).toBe(false);
|
||||
expect(service.getJobById('old-job-4')).toBeUndefined();
|
||||
expect(service.getJobTemplateById('old-template-4')).toBeUndefined();
|
||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should classify retired jobs inside and outside the stale grace window', () => {
|
||||
const job = { jobId: '1', creation: Date.now(), retiredAt: Date.now() - 1000 } as any;
|
||||
|
||||
expect(service.classifyJobForShare(job, Date.now())).toBe('stale-creditable');
|
||||
|
||||
job.retiredAt = Date.now() - 6000;
|
||||
|
||||
expect(service.classifyJobForShare(job, Date.now())).toBe('stale-rejected');
|
||||
expect(service.classifyJobForShare({ jobId: '2', creation: Date.now() } as any, Date.now())).toBe('active');
|
||||
});
|
||||
|
||||
it('should increment job ids when jobs are added', () => {
|
||||
expect(service.getNextId()).toBe('1');
|
||||
|
||||
service.addJob({ jobId: '1', creation: Date.now() } as any);
|
||||
|
||||
expect(service.getNextId()).toBe('2');
|
||||
expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' }));
|
||||
});
|
||||
});
|
||||
@@ -13,66 +13,63 @@ export interface IJobTemplate {
|
||||
merkle_branch: string[];
|
||||
blockData: {
|
||||
id: string,
|
||||
creation: number,
|
||||
coinbasevalue: number;
|
||||
networkDifficulty: number;
|
||||
height: number;
|
||||
clearJobs: boolean;
|
||||
retiredAt?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const STALE_GRACE_MS = parseInt(process.env.STRATUM_STALE_GRACE_MS) || 5000;
|
||||
const MIN_RETAINED = 3;
|
||||
export { STALE_GRACE_MS };
|
||||
|
||||
@Injectable()
|
||||
export class StratumV1JobsService {
|
||||
|
||||
private lastIntervalCount: number;
|
||||
private skipNext: boolean = false;
|
||||
public newMiningJob$: Observable<IJobTemplate>;
|
||||
|
||||
public latestJobId: number = 1;
|
||||
public latestJobTemplateId: number = 1;
|
||||
|
||||
public jobs: { [jobId: string]: MiningJob } = {};
|
||||
|
||||
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 jobRetentionMs = parseInt(process.env.JOB_RETENTION_MS) || 600000;
|
||||
private jobOrder: string[] = [];
|
||||
private blockOrder: string[] = [];
|
||||
private jobOrderSet = new Set<string>();
|
||||
private blockOrderSet = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService
|
||||
) {
|
||||
|
||||
this.newMiningJob$ = combineLatest([this.bitcoinRpcService.newBlock$, interval(60000).pipe(delay(this.delay), startWith(-1))]).pipe(
|
||||
switchMap(([miningInfo, interval]) => {
|
||||
return from(this.bitcoinRpcService.getBlockTemplate(miningInfo.blocks)).pipe(map((blockTemplate) => {
|
||||
return {
|
||||
blockTemplate,
|
||||
interval
|
||||
this.newMiningJob$ = this.bitcoinRpcService.newBlockTemplate$.pipe(
|
||||
map((blockTemplate) => {
|
||||
|
||||
if (process.env.MASTER == 'true') {
|
||||
console.log('Updating block template');
|
||||
}
|
||||
}))
|
||||
}),
|
||||
map(({ blockTemplate, interval }) => {
|
||||
|
||||
let clearJobs = false;
|
||||
if (this.lastIntervalCount === interval) {
|
||||
const currentBlockHeight = this.bitcoinRpcService.miningInfo.blocks;
|
||||
|
||||
if(this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight){
|
||||
console.log('New template is new block, clearing jobs');
|
||||
clearJobs = true;
|
||||
this.skipNext = true;
|
||||
console.log('new block')
|
||||
this.lastBlockHeight = currentBlockHeight;
|
||||
}
|
||||
|
||||
if (this.skipNext == true && clearJobs == false) {
|
||||
this.skipNext = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.lastIntervalCount = interval;
|
||||
|
||||
const currentTime = Math.floor(new Date().getTime() / 1000);
|
||||
return {
|
||||
version: blockTemplate.version,
|
||||
bits: parseInt(blockTemplate.bits, 16),
|
||||
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
|
||||
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
|
||||
coinbasevalue: blockTemplate.coinbasevalue,
|
||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||
timestamp: blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime,
|
||||
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
|
||||
clearJobs,
|
||||
height: blockTemplate.height
|
||||
@@ -113,6 +110,7 @@ export class StratumV1JobsService {
|
||||
merkle_branch,
|
||||
blockData: {
|
||||
id,
|
||||
creation: new Date().getTime(),
|
||||
coinbasevalue,
|
||||
networkDifficulty,
|
||||
height,
|
||||
@@ -121,14 +119,14 @@ export class StratumV1JobsService {
|
||||
}
|
||||
}),
|
||||
tap((data) => {
|
||||
if (data.blockData.clearJobs) {
|
||||
this.blocks = {};
|
||||
this.jobs = {};
|
||||
}
|
||||
this.cleanup(data.blockData.clearJobs);
|
||||
this.blocks[data.blockData.id] = data;
|
||||
this.trackBlock(data.blockData.id);
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 })
|
||||
)
|
||||
|
||||
this.newMiningJob$.subscribe();
|
||||
}
|
||||
|
||||
private calculateNetworkDifficulty(nBits: number) {
|
||||
@@ -137,7 +135,8 @@ export class StratumV1JobsService {
|
||||
|
||||
const target: number = mantissa * Math.pow(256, (exponent - 3)); // Calculate the target value
|
||||
|
||||
const difficulty: number = (Math.pow(2, 208) * 65535) / target; // Calculate the difficulty
|
||||
const maxTarget = Math.pow(2, 208) * 65535; // Easiest target (max_target)
|
||||
const difficulty: number = maxTarget / target; // Calculate the difficulty
|
||||
|
||||
return difficulty;
|
||||
}
|
||||
@@ -152,8 +151,80 @@ export class StratumV1JobsService {
|
||||
return this.blocks[jobTemplateId];
|
||||
}
|
||||
|
||||
public cleanup(clearJobs: boolean, now: number = Date.now()) {
|
||||
if (clearJobs) {
|
||||
for (const id of this.blockOrder) {
|
||||
const block = this.blocks[id];
|
||||
if (block != null && block.blockData.retiredAt === undefined) {
|
||||
block.blockData.retiredAt = now;
|
||||
}
|
||||
}
|
||||
for (const jobId of this.jobOrder) {
|
||||
const job = this.jobs[jobId];
|
||||
if (job != null && job.retiredAt === undefined) {
|
||||
job.retiredAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ageEntries(
|
||||
this.blocks,
|
||||
this.blockOrder,
|
||||
this.blockOrderSet,
|
||||
now,
|
||||
entry => entry.blockData.creation,
|
||||
entry => entry.blockData.retiredAt,
|
||||
);
|
||||
this.ageEntries(
|
||||
this.jobs,
|
||||
this.jobOrder,
|
||||
this.jobOrderSet,
|
||||
now,
|
||||
entry => entry.creation,
|
||||
entry => entry.retiredAt,
|
||||
);
|
||||
}
|
||||
|
||||
private ageEntries<T>(
|
||||
map: Record<string, T>,
|
||||
order: string[],
|
||||
orderSet: Set<string>,
|
||||
now: number,
|
||||
getCreation: (entry: T) => number,
|
||||
getRetiredAt: (entry: T) => number | undefined,
|
||||
): void {
|
||||
while (order.length > MIN_RETAINED) {
|
||||
const id = order[0];
|
||||
const entry = map[id];
|
||||
if (entry == null) {
|
||||
order.shift();
|
||||
orderSet.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retiredAt = getRetiredAt(entry);
|
||||
|
||||
if (retiredAt !== undefined && now - retiredAt > this.jobRetentionMs) {
|
||||
delete map[id];
|
||||
order.shift();
|
||||
orderSet.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (retiredAt === undefined && now - getCreation(entry) > this.jobRetentionMs * 2) {
|
||||
delete map[id];
|
||||
order.shift();
|
||||
orderSet.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public addJob(job: MiningJob) {
|
||||
this.jobs[job.jobId] = job;
|
||||
this.trackJob(job.jobId);
|
||||
this.latestJobId++;
|
||||
}
|
||||
|
||||
@@ -161,6 +232,13 @@ export class StratumV1JobsService {
|
||||
return this.jobs[jobId];
|
||||
}
|
||||
|
||||
public classifyJobForShare(job: MiningJob, now: number = Date.now()): 'active' | 'stale-creditable' | 'stale-rejected' {
|
||||
if (job.retiredAt === undefined) {
|
||||
return 'active';
|
||||
}
|
||||
return (now - job.retiredAt) <= STALE_GRACE_MS ? 'stale-creditable' : 'stale-rejected';
|
||||
}
|
||||
|
||||
public getNextTemplateId() {
|
||||
return this.latestJobTemplateId.toString(16);
|
||||
}
|
||||
@@ -168,5 +246,20 @@ export class StratumV1JobsService {
|
||||
return this.latestJobId.toString(16);
|
||||
}
|
||||
|
||||
private trackJob(jobId: string): void {
|
||||
if (this.jobOrderSet.has(jobId)) {
|
||||
return;
|
||||
}
|
||||
this.jobOrderSet.add(jobId);
|
||||
this.jobOrder.push(jobId);
|
||||
}
|
||||
|
||||
private trackBlock(blockId: string): void {
|
||||
if (this.blockOrderSet.has(blockId)) {
|
||||
return;
|
||||
}
|
||||
this.blockOrderSet.add(blockId);
|
||||
this.blockOrder.push(blockId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StratumV1Service } from './stratum-v1.service';
|
||||
|
||||
describe('StratumV1Service', () => {
|
||||
const originalMaster = process.env.MASTER;
|
||||
const originalStratumPorts = process.env.STRATUM_PORTS;
|
||||
const originalStratumSecure = process.env.STRATUM_SECURE;
|
||||
const originalSecureStratumPorts = process.env.SECURE_STRATUM_PORTS;
|
||||
|
||||
let service: StratumV1Service;
|
||||
let clientService;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
clientService = {
|
||||
deleteAll: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
service = new StratumV1Service(
|
||||
{} as any,
|
||||
clientService,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any
|
||||
);
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('MASTER', originalMaster);
|
||||
restoreEnv('STRATUM_PORTS', originalStratumPorts);
|
||||
restoreEnv('STRATUM_SECURE', originalStratumSecure);
|
||||
restoreEnv('SECURE_STRATUM_PORTS', originalSecureStratumPorts);
|
||||
consoleLogSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should skip Stratum listeners in the master process', async () => {
|
||||
process.env.MASTER = 'true';
|
||||
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer');
|
||||
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer');
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(clientService.deleteAll).toHaveBeenCalled();
|
||||
expect(startSocketServerSpy).not.toHaveBeenCalled();
|
||||
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners');
|
||||
});
|
||||
|
||||
it('should start Stratum listeners in worker processes', async () => {
|
||||
process.env.MASTER = 'false';
|
||||
process.env.STRATUM_PORTS = '3333,3334';
|
||||
process.env.STRATUM_SECURE = 'true';
|
||||
process.env.SECURE_STRATUM_PORTS = '4333';
|
||||
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
|
||||
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer').mockImplementation(() => undefined);
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
expect(clientService.deleteAll).not.toHaveBeenCalled();
|
||||
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
|
||||
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
|
||||
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
@@ -11,10 +11,20 @@ import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { TlsOptions, TLSSocket, createServer } from 'tls';
|
||||
import * as path from 'path';
|
||||
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class StratumV1Service implements OnModuleInit {
|
||||
|
||||
private socketTimeout = 0;
|
||||
private emptySocket = 0;
|
||||
private normalClosure = 0;
|
||||
private errorClosure = 0;
|
||||
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
private readonly clientService: ClientService,
|
||||
@@ -30,23 +40,38 @@ export class StratumV1Service implements OnModuleInit {
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
|
||||
if (process.env.ENABLE_SOLO == 'true') {
|
||||
//await this.clientStatisticsService.deleteAll();
|
||||
if (process.env.NODE_APP_INSTANCE == '0') {
|
||||
if (process.env.MASTER == 'true') {
|
||||
await this.clientService.deleteAll();
|
||||
console.log('Master process skipping Stratum socket listeners');
|
||||
return;
|
||||
}
|
||||
|
||||
// wait for all the other processes to init for an even connection distribution
|
||||
setTimeout(() => {
|
||||
this.startSocketServer();
|
||||
}, 1000 * 10)
|
||||
process.env.STRATUM_PORTS.split(',').forEach(port => {
|
||||
this.startSocketServer(parseInt(port));
|
||||
});
|
||||
if (process.env.STRATUM_SECURE?.toLowerCase() === 'true') {
|
||||
process.env.SECURE_STRATUM_PORTS.split(',').forEach(port => {
|
||||
this.startSecureSocketServer(parseInt(port));
|
||||
});
|
||||
}
|
||||
}, (10000));
|
||||
|
||||
setInterval(() => {
|
||||
console.log(`Socket stats: ${this.emptySocket} empty, ${this.socketTimeout} timeouts, ${this.normalClosure} normal closure, ${this.errorClosure} error closure`);
|
||||
this.emptySocket = 0;
|
||||
this.socketTimeout = 0;
|
||||
this.normalClosure = 0;
|
||||
this.errorClosure = 0;
|
||||
}, 1000 * 60);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private startSocketServer() {
|
||||
private startSocketServer(port: number) {
|
||||
const server = new Server(async (socket: Socket) => {
|
||||
|
||||
//5 min
|
||||
socket.setTimeout(1000 * 60 * 5);
|
||||
// Set 15-minute timeout
|
||||
socket.setTimeout(1000 * 60 * 15);
|
||||
|
||||
const client = new StratumV1Client(
|
||||
socket,
|
||||
@@ -60,32 +85,129 @@ export class StratumV1Service implements OnModuleInit {
|
||||
this.addressSettingsService
|
||||
);
|
||||
|
||||
|
||||
socket.on('close', async (hadError: boolean) => {
|
||||
// Unified cleanup function
|
||||
const cleanup = async (reason: string) => {
|
||||
if (client.extraNonceAndSessionId != null) {
|
||||
// Handle socket disconnection
|
||||
await client.destroy();
|
||||
console.log(`Client ${client.extraNonceAndSessionId} disconnected, hadError?:${hadError}`);
|
||||
if (reason == 'Error') {
|
||||
this.errorClosure++;
|
||||
} else {
|
||||
this.normalClosure++;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
console.log('socket timeout');
|
||||
}
|
||||
if (!socket.destroyed) {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle client disconnection
|
||||
socket.on('close', async (hadError: boolean) => {
|
||||
await cleanup(hadError ? "Error" : "Normal Closure");
|
||||
});
|
||||
|
||||
socket.on('error', async (error: Error) => { });
|
||||
// Handle socket timeouts
|
||||
socket.on('timeout', async () => {
|
||||
if (socket.bytesRead == 0 || socket.bytesWritten == 0) {
|
||||
this.emptySocket++;
|
||||
} else {
|
||||
this.socketTimeout++;
|
||||
}
|
||||
await cleanup("Timeout");
|
||||
});
|
||||
|
||||
// //console.log(`Client disconnected, socket error, ${client.extraNonceAndSessionId}`);
|
||||
// Handle errors properly
|
||||
socket.on('error', async (error: Error) => {
|
||||
await cleanup("Error");
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
|
||||
});
|
||||
|
||||
server.listen(process.env.STRATUM_PORT, () => {
|
||||
console.log(`Stratum server is listening on port ${process.env.STRATUM_PORT}`);
|
||||
// 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),
|
||||
};
|
||||
|
||||
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
|
||||
// Set 15-minute timeout
|
||||
socket.setTimeout(1000 * 60 * 15);
|
||||
|
||||
const client = new StratumV1Client(
|
||||
socket,
|
||||
this.stratumV1JobsService,
|
||||
this.bitcoinRpcService,
|
||||
this.clientService,
|
||||
this.clientStatisticsService,
|
||||
this.notificationService,
|
||||
this.blocksService,
|
||||
this.configService,
|
||||
this.addressSettingsService
|
||||
);
|
||||
|
||||
const cleanup = async (reason: string) => {
|
||||
if (client.extraNonceAndSessionId != null) {
|
||||
await client.destroy();
|
||||
if (reason === 'Error') {
|
||||
this.errorClosure++;
|
||||
} else {
|
||||
this.normalClosure++;
|
||||
}
|
||||
}
|
||||
if (!socket.destroyed) {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('close', async (hadError: boolean) => {
|
||||
await cleanup(hadError ? 'Error' : 'Normal Closure');
|
||||
});
|
||||
|
||||
socket.on('timeout', async () => {
|
||||
if (socket.bytesRead === 0 || socket.bytesWritten === 0) {
|
||||
this.emptySocket++;
|
||||
} else {
|
||||
this.socketTimeout++;
|
||||
}
|
||||
await cleanup('Timeout');
|
||||
});
|
||||
|
||||
socket.on('error', async (error: Error) => {
|
||||
await cleanup('Error');
|
||||
});
|
||||
|
||||
// your protocol handling stays the same
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`Server error: ${err.message}`);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Stratum TLS server is listening on port ${port}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { validate } from 'bitcoin-address-validation';
|
||||
import { Block } from 'bitcoinjs-lib';
|
||||
import * as TelegramBot from 'node-telegram-bot-api';
|
||||
|
||||
import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/telegram-subscriptions.service';
|
||||
|
||||
@@ -10,7 +10,9 @@ import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/tele
|
||||
@Injectable()
|
||||
export class TelegramService implements OnModuleInit {
|
||||
|
||||
private bot: TelegramBot;
|
||||
private bot: AxiosInstance;
|
||||
private updateOffset = 0;
|
||||
private pollingTimer: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@@ -20,7 +22,10 @@ export class TelegramService implements OnModuleInit {
|
||||
if (token == null || token.length < 1) {
|
||||
return;
|
||||
}
|
||||
this.bot = new TelegramBot(token, { polling: true });
|
||||
this.bot = axios.create({
|
||||
baseURL: `https://api.telegram.org/bot${token}/`,
|
||||
timeout: 10000
|
||||
});
|
||||
console.log('Telegram bot init');
|
||||
|
||||
|
||||
@@ -32,23 +37,10 @@ export class TelegramService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bot.onText(/\/subscribe/, async (msg) => {
|
||||
const address = msg.text.split('/subscribe ')[1];
|
||||
if (validate(address) == false) {
|
||||
this.bot.sendMessage(msg.chat.id, "Invalid address.");
|
||||
return;
|
||||
}
|
||||
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
|
||||
this.bot.sendMessage(msg.chat.id, "Subscribed!");
|
||||
});
|
||||
|
||||
this.bot.onText(/\/start/, (msg) => {
|
||||
this.bot.sendMessage(msg.chat.id, "Welcome to the public-pool bot. /subscribe <address> to get notified.");
|
||||
});
|
||||
|
||||
this.bot.on('message', (msg) => {
|
||||
console.log(msg);
|
||||
});
|
||||
await this.pollUpdates();
|
||||
this.pollingTimer = setInterval(async () => {
|
||||
await this.pollUpdates();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
|
||||
@@ -57,8 +49,57 @@ export class TelegramService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const subscribers = await this.telegramSubscriptionsService.getSubscriptions(address);
|
||||
subscribers.forEach(subscriber => {
|
||||
this.bot.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
|
||||
await Promise.all(subscribers.map(subscriber => {
|
||||
return this.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
|
||||
}));
|
||||
}
|
||||
|
||||
private async pollUpdates() {
|
||||
try {
|
||||
const response = await this.bot.get('getUpdates', {
|
||||
params: {
|
||||
offset: this.updateOffset,
|
||||
timeout: 0
|
||||
}
|
||||
});
|
||||
|
||||
for (const update of response.data.result ?? []) {
|
||||
this.updateOffset = update.update_id + 1;
|
||||
await this.handleMessage(update.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Telegram polling failed', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(msg: any) {
|
||||
if (msg?.text == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.text.startsWith('/subscribe')) {
|
||||
const address = msg.text.split('/subscribe ')[1];
|
||||
if (validate(address) == false) {
|
||||
await this.sendMessage(msg.chat.id, 'Invalid address.');
|
||||
return;
|
||||
}
|
||||
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
|
||||
await this.sendMessage(msg.chat.id, 'Subscribed!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.text.startsWith('/start')) {
|
||||
await this.sendMessage(msg.chat.id, 'Welcome to the public-pool bot. /subscribe <address> to get notified.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
private async sendMessage(chatId: number | string, text: string) {
|
||||
await this.bot.post('sendMessage', {
|
||||
chat_id: chatId,
|
||||
text
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@ export class MockRecording1 {
|
||||
public static MINING_AUTHORIZE = `{"id": 3, "method": "mining.authorize", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "x"]}`;
|
||||
public static MINING_SUGGEST_DIFFICULTY = `{"id": 4, "method": "mining.suggest_difficulty", "params": [512]}`;
|
||||
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", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`;
|
||||
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`;
|
||||
public static TIME = '64b3f3ec';
|
||||
}
|
||||
Reference in New Issue
Block a user