mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Speed up template publication and share validation
This commit is contained in:
@@ -4,13 +4,46 @@ import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||
import { PayoutMode } from '../types/payout-mode';
|
||||
|
||||
const MINING_INFO_CHANNEL = 'mining-info.updated';
|
||||
const BLOCK_TEMPLATE_CHANNEL = 'block-template.updated';
|
||||
const SV1_BRIDGE_CHANNEL = 'sv1-bridge.updated';
|
||||
const MINING_INFO_KEY = 'mining-info:latest';
|
||||
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
|
||||
const blockTemplateKey = (height: number) => `block-template:${height}`;
|
||||
const SV1_BRIDGE_LATEST_KEY = 'sv1-bridge:latest';
|
||||
const BLOCK_TEMPLATE_CACHE_TTL_SECONDS = 60 * 60;
|
||||
const sv1BridgeLatestKey = (payoutMode: PayoutMode) =>
|
||||
`${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`;
|
||||
const blockTemplateLegacyKey = (height: number) => `block-template:${height}`;
|
||||
const blockTemplateKey = (
|
||||
height: number,
|
||||
previousBlockHash: string,
|
||||
payoutMode: PayoutMode,
|
||||
) => `block-template:${height}:${previousBlockHash}:${payoutMode}`;
|
||||
const blockTemplateHeightPointerKey = (height: number, payoutMode: PayoutMode) =>
|
||||
`block-template:${height}:${payoutMode}:latest`;
|
||||
const blockTemplateLatestPointerKey = (payoutMode: PayoutMode) =>
|
||||
`${BLOCK_TEMPLATE_LATEST_KEY}:${payoutMode}`;
|
||||
const jsonCacheKey = (key: string) => `json-cache:${key}`;
|
||||
|
||||
export interface BlockTemplateUpdate {
|
||||
schemaVersion: 1;
|
||||
eventId: string;
|
||||
height: number;
|
||||
previousBlockHash: string;
|
||||
payoutMode: PayoutMode;
|
||||
publishedAtMs: number;
|
||||
}
|
||||
|
||||
export interface Sv1BridgeUpdate {
|
||||
schemaVersion: 1;
|
||||
type: 'subsidy-bridge';
|
||||
eventId: string;
|
||||
template: IBlockTemplate;
|
||||
publishedAtMs: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
private publisher: RedisClientType;
|
||||
@@ -75,6 +108,99 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public async publishBlockTemplateUpdate(update: BlockTemplateUpdate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.publisher.publish(BLOCK_TEMPLATE_CHANNEL, JSON.stringify(update));
|
||||
}
|
||||
|
||||
public async subscribeBlockTemplateUpdates(handler: (update: BlockTemplateUpdate) => Promise<void>): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.subscriber.subscribe(BLOCK_TEMPLATE_CHANNEL, async message => {
|
||||
try {
|
||||
const update = JSON.parse(message) as Partial<BlockTemplateUpdate>;
|
||||
if (update.schemaVersion !== 1
|
||||
|| !Number.isInteger(update.height)
|
||||
|| typeof update.eventId !== 'string'
|
||||
|| typeof update.previousBlockHash !== 'string'
|
||||
|| (update.payoutMode !== 'solo' && update.payoutMode !== 'pplns')) {
|
||||
throw new Error('unsupported block template update');
|
||||
}
|
||||
await handler(update as BlockTemplateUpdate);
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis block template update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async publishSv1BridgeUpdate(update: Sv1BridgeUpdate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(update);
|
||||
const validated = this.parseSv1BridgeUpdate(serialized);
|
||||
const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo';
|
||||
// The bridge envelope is self-contained. Deliver it before spending a
|
||||
// second Redis round trip on best-effort replay metadata.
|
||||
await this.publisher.publish(SV1_BRIDGE_CHANNEL, serialized);
|
||||
void Promise.all([
|
||||
this.publisher.setEx(
|
||||
sv1BridgeLatestKey(payoutMode),
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
serialized,
|
||||
),
|
||||
...(payoutMode === 'solo'
|
||||
? [this.publisher.setEx(
|
||||
SV1_BRIDGE_LATEST_KEY,
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
serialized,
|
||||
)]
|
||||
: []),
|
||||
]).catch(error => {
|
||||
console.error(`Unable to cache latest SV1 bridge: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async subscribeSv1BridgeUpdates(handler: (update: Sv1BridgeUpdate) => Promise<void>): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.subscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
|
||||
try {
|
||||
await handler(this.parseSv1BridgeUpdate(message));
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis SV1 bridge update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getLatestSv1BridgeUpdate(
|
||||
payoutMode: PayoutMode = 'solo',
|
||||
): Promise<Sv1BridgeUpdate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
let value = await this.publisher.get(sv1BridgeLatestKey(payoutMode)) as string | null;
|
||||
if (value == null && payoutMode === 'solo') {
|
||||
// Backward-compatible replay for solo bridges published before
|
||||
// latest pointers were split by payout mode.
|
||||
value = await this.publisher.get(SV1_BRIDGE_LATEST_KEY) as string | null;
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const update = this.parseSv1BridgeUpdate(value);
|
||||
return update.template.payoutMode === payoutMode ? update : null;
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis latest SV1 bridge update: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async setLatestMiningInfo(miningInfo: IMiningInfo) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
@@ -90,31 +216,100 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
}
|
||||
|
||||
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate) {
|
||||
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<string | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const payoutMode = blockTemplate.payoutMode === 'pplns' ? 'pplns' : 'solo';
|
||||
const key = blockTemplateKey(height, blockTemplate.previousblockhash, payoutMode);
|
||||
const serialized = JSON.stringify(blockTemplate);
|
||||
await Promise.all([
|
||||
this.publisher.setEx(key, BLOCK_TEMPLATE_CACHE_TTL_SECONDS, serialized),
|
||||
this.publisher.setEx(
|
||||
blockTemplateHeightPointerKey(height, payoutMode),
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
key,
|
||||
),
|
||||
this.publisher.set(blockTemplateLatestPointerKey(payoutMode), key),
|
||||
]);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the payload shape understood by pre-channel workers. Call this only
|
||||
* once the PPLNS-safe compatibility template is ready, then publish the
|
||||
* mining-info notification that wakes those workers.
|
||||
*/
|
||||
public async setLegacyBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(blockTemplate);
|
||||
await Promise.all([
|
||||
this.publisher.set(blockTemplateKey(height), serialized),
|
||||
this.publisher.setEx(blockTemplateLegacyKey(height), 15 * 60, serialized),
|
||||
// Old workers JSON.parse this key directly, so it must remain a
|
||||
// template payload rather than one of the new pointer values.
|
||||
this.publisher.set(BLOCK_TEMPLATE_LATEST_KEY, serialized),
|
||||
]);
|
||||
}
|
||||
|
||||
public async getBlockTemplate(height: number): Promise<IBlockTemplate | null> {
|
||||
public async getBlockTemplate(
|
||||
height: number,
|
||||
payoutMode: PayoutMode = 'solo',
|
||||
previousBlockHash?: string,
|
||||
): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(blockTemplateKey(height));
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
if (previousBlockHash != null) {
|
||||
return this.readBlockTemplateValue(
|
||||
await this.publisher.get(blockTemplateKey(height, previousBlockHash, payoutMode)) as string | null,
|
||||
);
|
||||
}
|
||||
|
||||
const pointer = await this.publisher.get(blockTemplateHeightPointerKey(height, payoutMode)) as string | null;
|
||||
const pointedTemplate = await this.readBlockTemplatePointer(pointer);
|
||||
if (pointedTemplate != null) {
|
||||
return pointedTemplate;
|
||||
}
|
||||
|
||||
// Backward-compatible replay for templates written before tip-specific keys.
|
||||
const legacyTemplate = this.readBlockTemplateValue(
|
||||
await this.publisher.get(blockTemplateLegacyKey(height)) as string | null,
|
||||
);
|
||||
return this.matchesPayoutMode(legacyTemplate, payoutMode)
|
||||
? legacyTemplate
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplate(): Promise<IBlockTemplate | null> {
|
||||
public async getLatestBlockTemplate(payoutMode: PayoutMode = 'solo'): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY);
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
const modeValue = await this.publisher.get(blockTemplateLatestPointerKey(payoutMode)) as string | null;
|
||||
if (modeValue != null) {
|
||||
return this.readBlockTemplatePointer(modeValue);
|
||||
}
|
||||
|
||||
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY) as string | null;
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+$/.test(value as string)) {
|
||||
return this.getBlockTemplate(Number(value), payoutMode);
|
||||
}
|
||||
const legacyTemplate = await this.readBlockTemplatePointer(value);
|
||||
return this.matchesPayoutMode(legacyTemplate, payoutMode)
|
||||
? legacyTemplate
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplates(): Promise<IBlockTemplate[]> {
|
||||
const [solo, pplns] = await Promise.all([
|
||||
this.getLatestBlockTemplate('solo'),
|
||||
this.getLatestBlockTemplate('pplns'),
|
||||
]);
|
||||
return [solo, pplns].filter((template): template is IBlockTemplate => template != null);
|
||||
}
|
||||
|
||||
public async getJsonCache<T>(key: string): Promise<T | null> {
|
||||
@@ -148,6 +343,74 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
private async readBlockTemplatePointer(value: string | null): Promise<IBlockTemplate | null> {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('block-template:')) {
|
||||
return this.readBlockTemplateValue(await this.publisher.get(value) as string | null);
|
||||
}
|
||||
return this.readBlockTemplateValue(value);
|
||||
}
|
||||
|
||||
private readBlockTemplateValue(value: string | null): IBlockTemplate | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as IBlockTemplate;
|
||||
return parsed != null && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis block template: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private matchesPayoutMode(
|
||||
template: IBlockTemplate | null,
|
||||
payoutMode: PayoutMode,
|
||||
): template is IBlockTemplate {
|
||||
return template != null
|
||||
&& (template.payoutMode == null
|
||||
|| template.payoutMode === 'all'
|
||||
|| template.payoutMode === payoutMode);
|
||||
}
|
||||
|
||||
private parseSv1BridgeUpdate(message: string): Sv1BridgeUpdate {
|
||||
const update = JSON.parse(message) as Partial<Sv1BridgeUpdate>;
|
||||
const template = update.template as Partial<IBlockTemplate> | undefined;
|
||||
const payoutMode = template?.payoutMode;
|
||||
const payoutOutputs = template?.payoutOutputs;
|
||||
const hasExplicitPplnsPayout = payoutMode !== 'pplns' || (
|
||||
typeof template?.payoutSnapshotId === 'string'
|
||||
&& template.payoutSnapshotId.trim().length > 0
|
||||
&& Array.isArray(payoutOutputs)
|
||||
&& payoutOutputs.length > 0
|
||||
&& Number.isSafeInteger(template.coinbasevalue)
|
||||
&& payoutOutputs.every(output => (
|
||||
typeof output.address === 'string'
|
||||
&& output.address.trim().length > 0
|
||||
&& Number.isSafeInteger(output.amountSats)
|
||||
&& output.amountSats >= 0
|
||||
))
|
||||
&& payoutOutputs.reduce((sum, output) => sum + output.amountSats, 0)
|
||||
=== template.coinbasevalue
|
||||
);
|
||||
if (update.schemaVersion !== 1
|
||||
|| update.type !== 'subsidy-bridge'
|
||||
|| typeof update.eventId !== 'string'
|
||||
|| template?.jobType !== 'empty'
|
||||
|| (payoutMode !== 'solo' && payoutMode !== 'pplns')
|
||||
|| !Array.isArray(template?.transactions)
|
||||
|| template.transactions.length !== 0
|
||||
|| typeof template.previousblockhash !== 'string'
|
||||
|| !Number.isInteger(template.height)
|
||||
|| !hasExplicitPplnsPayout) {
|
||||
throw new Error('unsupported SV1 bridge update');
|
||||
}
|
||||
return update as Sv1BridgeUpdate;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
if (!this.connected) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user