Add payout modes and production switchover schema

This commit is contained in:
Ben
2026-06-20 13:59:05 -04:00
parent a33c530268
commit a9d425f32f
61 changed files with 4164 additions and 1029 deletions
@@ -3,12 +3,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { ClientController } from './client.controller';
describe('ClientController', () => {
let controller: ClientController;
let clientService: { getBySessionId: jest.Mock; getActiveIds: jest.Mock };
let addressSettingsService: { getSettings: jest.Mock };
let shareAccountingService: {
getAddressSummary: jest.Mock;
@@ -16,7 +18,8 @@ describe('ClientController', () => {
getSessionSummary: jest.Mock;
getSessionSummaries: jest.Mock;
};
let redisMessagingService: { getClientPresenceByAddress: jest.Mock };
let payoutSnapshotService: { getLatestExpectedPayoutForAddress: jest.Mock };
let redisMessagingService: { getClientPresenceByAddress: jest.Mock; removeClientPresence: jest.Mock };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -28,6 +31,7 @@ describe('ClientController', () => {
getByAddress: jest.fn(),
getByName: jest.fn(),
getBySessionId: jest.fn(),
getActiveIds: jest.fn(async (ids: string[]) => new Set(ids)),
},
},
{
@@ -53,10 +57,17 @@ describe('ClientController', () => {
getSessionSummaries: jest.fn().mockResolvedValue(new Map()),
},
},
{
provide: PayoutSnapshotService,
useValue: {
getLatestExpectedPayoutForAddress: jest.fn().mockResolvedValue(null),
},
},
{
provide: RedisMessagingService,
useValue: {
getClientPresenceByAddress: jest.fn().mockResolvedValue([]),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
},
},
],
@@ -64,8 +75,10 @@ describe('ClientController', () => {
}).compile();
controller = module.get<ClientController>(ClientController);
clientService = module.get(ClientService);
addressSettingsService = module.get(AddressSettingsService);
shareAccountingService = module.get(ShareAccountingService);
payoutSnapshotService = module.get(PayoutSnapshotService);
redisMessagingService = module.get(RedisMessagingService);
});
@@ -83,6 +96,7 @@ describe('ClientController', () => {
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
address: 'bc1qtest',
},
]);
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
@@ -100,4 +114,88 @@ describe('ClientController', () => {
}),
}));
});
it('should expose the latest current PPLNS expected payout for an address', async () => {
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
payoutSnapshotService.getLatestExpectedPayoutForAddress.mockResolvedValue({
snapshotId: '19',
blockHeight: 900001,
payoutMode: 'pplns',
payoutSats: 1234,
creditedDifficulty: 50,
percent: 12.34,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toEqual(expect.objectContaining({
expectedPayout: expect.objectContaining({
snapshotId: '19',
payoutSats: 1234,
}),
}));
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).toHaveBeenCalledWith('bc1qtest');
});
it('should not query expected PPLNS payout for solo-only address requests', async () => {
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest', 'solo')).resolves.toEqual(expect.objectContaining({
expectedPayout: null,
}));
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).not.toHaveBeenCalled();
});
it('should hide stale Redis workers that are no longer active in the database', async () => {
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
{
clientId: 'active-client',
address: 'bc1qtest',
sessionId: 'active1',
clientName: 'active-worker',
bestDifficulty: 64,
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
},
{
clientId: 'stale-client',
address: 'bc1qtest',
sessionId: 'stale1',
clientName: 'stale-worker',
bestDifficulty: 128,
hashRate: 2048,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
},
]);
clientService.getActiveIds.mockResolvedValue(new Set(['active-client']));
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 0,
totalCreditedDifficulty: 0,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toMatchObject({
workersCount: 1,
workers: [
{
sessionId: 'active1',
name: 'active-worker',
},
],
});
expect(redisMessagingService.removeClientPresence)
.toHaveBeenCalledWith('stale-client', 'bc1qtest');
});
});
+61 -16
View File
@@ -1,10 +1,12 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
@Controller('client')
@@ -15,13 +17,15 @@ export class ClientController {
private readonly clientStatisticsService: ClientStatisticsService,
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService: ShareAccountingService,
private readonly payoutSnapshotService: PayoutSnapshotService,
private readonly redisMessagingService: RedisMessagingService
) { }
@Get(':address')
async getClientInfo(@Param('address') address: string) {
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getClientInfo(@Param('address') address: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const workers = await this.getActiveAddressWorkers(address, mode);
const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId));
const addressSettings = process.env.API_ONLY === 'true'
@@ -30,15 +34,19 @@ export class ClientController {
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
return Math.max(best, Number(worker.bestDifficulty ?? 0));
}, 0);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getAddressSummary(address),
bestDifficulty,
);
const accountingSummary = mode == null
? await this.shareAccountingService.getAddressSummary(address)
: await this.shareAccountingService.getAddressSummary(address, mode);
const accounting = this.withBestSubmissionDifficulty(accountingSummary, bestDifficulty);
const expectedPayout = mode === 'solo'
? null
: await this.payoutSnapshotService.getLatestExpectedPayoutForAddress(address);
const response = {
bestDifficulty,
workersCount: workers.length,
accounting,
expectedPayout,
workers: await Promise.all(
workers.map(async (worker) => {
const sessionSummary = sessionSummaries.get(worker.clientId);
@@ -49,6 +57,7 @@ export class ClientController {
return {
sessionId: worker.sessionId,
name: worker.clientName,
payoutMode: worker.payoutMode,
bestDifficulty: bestDifficulty.toFixed(2),
hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate,
startTime: worker.startTime,
@@ -65,9 +74,16 @@ export class ClientController {
return await this.clientStatisticsService.getChartDataForAddress(address);
}
@Get(':address/chart/payout-modes')
async getClientInfoChartByPayoutMode(@Param('address') address: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
return await this.clientStatisticsService.getChartDataForAddressByPayoutMode(address, mode);
}
@Get(':address/:workerName')
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const addressWorkers = await this.getActiveAddressWorkers(address, mode);
const workers = addressWorkers
.filter(worker => worker.clientName === workerName);
@@ -79,24 +95,28 @@ export class ClientController {
}, 0);
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getWorkerGroupSummary(address, workerName),
bestDifficulty,
);
const chartDataByPayoutMode = await this.clientStatisticsService.getChartDataForGroupByPayoutMode(address, workerName, mode);
const accountingSummary = mode == null
? await this.shareAccountingService.getWorkerGroupSummary(address, workerName)
: await this.shareAccountingService.getWorkerGroupSummary(address, workerName, mode);
const accounting = this.withBestSubmissionDifficulty(accountingSummary, bestDifficulty);
const response = {
name: workerName,
bestDifficulty: Math.floor(bestDifficulty),
payoutModes: [...new Set(workers.map(worker => worker.payoutMode))],
accounting,
chartData: chartData,
chartDataByPayoutMode,
}
return response;
}
@Get(':address/:workerName/:sessionId')
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const addressWorkers = await this.getActiveAddressWorkers(address, mode);
const presenceWorker = addressWorkers
.find(worker => worker.clientName === workerName && worker.sessionId === sessionId);
const worker = presenceWorker == null
@@ -106,14 +126,18 @@ export class ClientController {
sessionId: presenceWorker.sessionId,
clientName: presenceWorker.clientName,
bestDifficulty: presenceWorker.bestDifficulty,
payoutMode: presenceWorker.payoutMode,
startTime: presenceWorker.startTime,
};
if (worker == null) {
return new NotFoundException();
}
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id);
const chartDataByPayoutMode = await this.clientStatisticsService.getChartDataForSessionByPayoutMode(worker.id, mode);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getSessionSummary(worker.id),
mode == null
? await this.shareAccountingService.getSessionSummary(worker.id)
: await this.shareAccountingService.getSessionSummary(worker.id, mode),
worker.bestDifficulty,
);
@@ -121,8 +145,10 @@ export class ClientController {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: Math.floor(worker.bestDifficulty),
payoutMode: worker.payoutMode,
accounting,
chartData: chartData,
chartDataByPayoutMode,
startTime: worker.startTime
}
return response;
@@ -144,4 +170,23 @@ export class ClientController {
bestSubmissionDifficulty: fallback,
};
}
private getRequestedPayoutMode(payoutMode?: string): PayoutMode | undefined {
if (payoutMode == null || payoutMode === 'all') {
return undefined;
}
return normalizePayoutMode(payoutMode);
}
private async getActiveAddressWorkers(address: string, payoutMode?: PayoutMode) {
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
const activeIds = await this.clientService.getActiveIds(workers.map(worker => worker.clientId));
const staleWorkers = workers.filter(worker => !activeIds.has(worker.clientId));
void Promise.all(staleWorkers.map(worker => {
return this.redisMessagingService.removeClientPresence(worker.clientId, worker.address)
.catch(() => undefined);
}));
return workers.filter(worker => activeIds.has(worker.clientId) && (payoutMode == null || worker.payoutMode === payoutMode));
}
}