feat: external pool shares (#89)

* feat:external shares
---------

Co-authored-by: mrv777 <mrv777@users.noreply.github.com>
Co-authored-by: Benjamin Wilson <admin@opensourceminer.com>
This commit is contained in:
mr-vcrypto 2025-02-23 14:43:01 -06:00 committed by GitHub
parent 0201703e35
commit fda3f21b71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 279 additions and 32 deletions

View File

@ -0,0 +1,30 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
@Index(['address', 'time'])
export class ExternalSharesEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ type: 'integer' })
time: number;
@Column({ type: 'real' })
difficulty: number;
@Column({ length: 128, type: 'varchar', nullable: true })
userAgent: string;
@Column({ length: 128, type: 'varchar', nullable: true })
externalPoolName: string;
@Column()
header: string;
}

View File

@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
import { ExternalSharesService } from './external-shares.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ExternalSharesEntity])],
providers: [ExternalSharesService],
exports: [TypeOrmModule, ExternalSharesService],
})
export class ExternalSharesModule { }

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
@Injectable()
export class ExternalSharesService {
constructor(
@InjectRepository(ExternalSharesEntity)
private externalSharesRepository: Repository<ExternalSharesEntity>
) {}
public async insert(externalShare: Partial<ExternalSharesEntity>) {
return await this.externalSharesRepository.insert(externalShare);
}
public async getTopDifficulties(): Promise<Array<{userAgent: string, time: number, externalPoolName: string, difficulty: number}>> {
return await this.externalSharesRepository
.createQueryBuilder('share')
.select('share.userAgent', 'userAgent')
.addSelect('share.time', 'time')
.addSelect('share.externalPoolName', 'externalPoolName')
.addSelect('MAX(share.difficulty)', 'difficulty')
.groupBy('share.address')
.orderBy('MAX(share.difficulty)', 'DESC')
.limit(10)
.getRawMany();
}
public async getAddressBestDifficulty(address: string): Promise<number> {
const result = await this.externalSharesRepository
.createQueryBuilder()
.select('MAX(difficulty)', 'maxDifficulty')
.where('address = :address', { address })
.getRawOne();
return result?.maxDifficulty || 0;
}
public async deleteOldShares() {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return await this.externalSharesRepository
.createQueryBuilder()
.delete()
.from(ExternalSharesEntity)
.where('time < :time', { time: oneDayAgo.getTime() })
.execute();
}
public async deleteAll() {
return await this.externalSharesRepository.delete({});
}
}

View File

@ -24,7 +24,9 @@ import { NotificationService } from './services/notification.service';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service';
import { TelegramService } from './services/telegram.service';
import { ExternalSharesService } from './services/external-shares.service';
import { ExternalShareController } from './controllers/external-share/external-share.controller';
import { ExternalSharesModule } from './ORM/external-shares/external-shares.module';
const ORMModules = [
ClientStatisticsModule,
@ -32,7 +34,8 @@ const ORMModules = [
AddressSettingsModule,
TelegramSubscriptionsModule,
BlocksModule,
RpcBlocksModule
RpcBlocksModule,
ExternalSharesModule
]
@Module({
@ -56,7 +59,8 @@ const ORMModules = [
controllers: [
AppController,
ClientController,
AddressController
AddressController,
ExternalShareController
],
providers: [
DiscordService,
@ -68,7 +72,8 @@ const ORMModules = [
BitcoinAddressValidator,
StratumV1JobsService,
BTCPayService,
BraiinsService
BraiinsService,
ExternalSharesService,
],
})
export class AppModule {

View File

@ -0,0 +1,72 @@
import { Body, Controller, Post, Get, UnauthorizedException, Headers } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../../models/ExternalPoolShare';
import { ExternalSharesService } from '../../ORM/external-shares/external-shares.service';
import { DifficultyUtils } from '../../utils/difficulty.utils';
import * as bitcoinjs from 'bitcoinjs-lib';
@Controller('share')
export class ExternalShareController {
private readonly apiKey: string;
private readonly minimumDifficulty: number;
constructor(
private readonly configService: ConfigService,
private readonly externalSharesService: ExternalSharesService,
) {
this.apiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
this.minimumDifficulty = this.configService.get('MINIMUM_DIFFICULTY') || 1000000000000; // 1T
}
@Get('top-difficulties')
async getTopDifficulties() {
const topDifficulties = await this.externalSharesService.getTopDifficulties();
return topDifficulties;
}
@Post()
async submitExternalShare(
@Body() externalShare: ExternalPoolShare,
@Headers('x-api-key') apiKey: string,
) {
// Only validate API key if one is configured
if (this.apiKey && apiKey !== this.apiKey) {
throw new UnauthorizedException('Invalid API key');
}
// Validate the header hash matches claimed difficulty
const headerBuffer = Buffer.from(externalShare.header, 'hex');
const { submissionDifficulty: difficulty } = DifficultyUtils.calculateDifficulty(headerBuffer);
// Verify the calculated difficulty matches or exceeds minimum difficulty
if (difficulty < this.minimumDifficulty) {
throw new UnauthorizedException('Share difficulty too low');
}
const block = bitcoinjs.Block.fromBuffer(headerBuffer);
const tenMinutesAgo = Math.floor(Date.now() / 1000) - (10 * 60);
if (block.timestamp < tenMinutesAgo) {
throw new UnauthorizedException('Share timestamp too old - must be within last 10 minutes');
}
// Store share submission
await this.externalSharesService.insert({
address: externalShare.address,
clientName: externalShare.worker,
time: new Date().getTime(),
difficulty: difficulty,
userAgent: externalShare.userAgent,
externalPoolName: externalShare.externalPoolName,
header: externalShare.header
});
console.log(`Accepted external share. ${difficulty}`);
return {
success: true,
calculatedDifficulty: difficulty,
};
}
}

View File

@ -0,0 +1,27 @@
import { IsString, Matches, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBitcoinAddress } from './validators/bitcoin-address.validator';
export class ExternalPoolShare {
@IsString()
@MaxLength(64)
worker: string;
@IsString()
@IsBitcoinAddress()
address: string;
@IsString()
@MaxLength(128)
userAgent: string;
@IsString()
@MaxLength(128)
externalPoolName: string;
@IsString()
@Matches(/^[0-9a-fA-F]+$/, {
message: 'Header must be a valid hex string'
})
header: string;
}

View File

@ -1,5 +1,4 @@
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';
@ -7,7 +6,6 @@ import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
import { clearInterval } from 'timers';
import { createInterface } from 'readline';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
@ -28,6 +26,8 @@ import { StratumErrorMessage } from './stratum-messages/StratumErrorMessage';
import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { ExternalSharesService } from '../services/external-shares.service';
import { DifficultyUtils } from '../utils/difficulty.utils';
export class StratumV1Client {
@ -63,7 +63,8 @@ export class StratumV1Client {
private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService
private readonly addressSettingsService: AddressSettingsService,
private readonly externalSharesService: ExternalSharesService
) {
this.socket.on('data', (data: Buffer) => {
@ -492,7 +493,7 @@ export class StratumV1Client {
parseInt(submission.ntime, 16)
);
const header = updatedJobBlock.toBuffer(true);
const { submissionDifficulty } = this.calculateDifficulty(header);
const { submissionDifficulty } = DifficultyUtils.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
@ -549,6 +550,18 @@ export class StratumV1Client {
}
const externalShareSubmissionEnabled: boolean = this.configService.get('EXTERNAL_SHARE_SUBMISSION_ENABLED')?.toLowerCase() == 'true';
const minimumDifficulty: number = parseFloat(this.configService.get('MINIMUM_DIFFICULTY')) || 1000000000000.0; // 1T
if (externalShareSubmissionEnabled && submissionDifficulty >= minimumDifficulty) {
// Submit share to API if enabled
this.externalSharesService.submitShare({
worker: this.clientAuthorization.worker,
address: this.clientAuthorization.address,
userAgent: this.clientSubscription.userAgent,
header: header.toString('hex'),
externalPoolName: this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
});
}
} else {
const err = new StratumErrorMessage(
@ -596,28 +609,6 @@ export class StratumV1Client {
}
}
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 async write(message: string): Promise<boolean> {
try {
if (!this.socket.destroyed && !this.socket.writableEnded) {

View File

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../models/ExternalPoolShare';
@Injectable()
export class ExternalSharesService {
private readonly shareApiUrl: string;
private readonly shareApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService
) {
this.shareApiUrl = this.configService.get('SHARE_SUBMISSION_URL') || 'https://web.public-pool.io';
this.shareApiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
}
public submitShare(share: ExternalPoolShare): void {
this.httpService.post(`${this.shareApiUrl}/api/share`, share, {
headers: {
'x-api-key': this.shareApiKey
}
}).subscribe({
next: () =>{
console.log('External share accepted');
},
error: (error) => console.error('Failed to submit share to API:', error.message)
});
}
}

View File

@ -10,6 +10,7 @@ import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { NotificationService } from './notification.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { ExternalSharesService } from './external-shares.service';
@Injectable()
@ -23,7 +24,8 @@ export class StratumV1Service implements OnModuleInit {
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly addressSettingsService: AddressSettingsService
private readonly addressSettingsService: AddressSettingsService,
private readonly externalSharesService: ExternalSharesService
) {
}
@ -54,7 +56,8 @@ export class StratumV1Service implements OnModuleInit {
this.notificationService,
this.blocksService,
this.configService,
this.addressSettingsService
this.addressSettingsService,
this.externalSharesService
);

View File

@ -0,0 +1,23 @@
import Big from 'big.js';
import * as bitcoinjs from 'bitcoinjs-lib';
export class DifficultyUtils {
static calculateDifficulty(header: Buffer): { submissionDifficulty: number; submissionHash: string } {
const hashResult = bitcoinjs.crypto.hash256(Buffer.isBuffer(header) ? header : Buffer.from(header, 'hex'));
const s64 = DifficultyUtils.le256todouble(hashResult);
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
const difficulty = truediffone.div(s64.toString());
return {
submissionDifficulty: difficulty.toNumber(),
submissionHash: hashResult.toString('hex')
};
}
private static le256todouble(target: Buffer): bigint {
const number = target.reduceRight((acc, byte) => {
return (acc << BigInt(8)) | BigInt(byte);
}, BigInt(0));
return number;
}
}