From a1ec8aa1e7e7a96ddcdd43731a818446746e8c9d Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Tue, 10 Mar 2026 07:19:45 +0100 Subject: [PATCH 01/11] add tickets mode to block generator --- .../jam/jamnp-s/tasks/ticket-distribution.ts | 7 ++ .../block-authorship/generator.test.ts | 80 ++++++++++++ .../workers/block-authorship/generator.ts | 57 ++++++++- packages/workers/block-authorship/main.ts | 115 ++++++++++++++---- .../comms-authorship-network/protocol.ts | 9 +- packages/workers/jam-network/main.ts | 5 + 6 files changed, 242 insertions(+), 31 deletions(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index 5f3efece2..388a6bce0 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -144,9 +144,16 @@ export class TicketDistributionTask { } } + private onTicketReceivedCallback?: (epochIndex: Epoch, ticket: SignedTicket) => void; + + setOnTicketReceived(cb: (epochIndex: Epoch, ticket: SignedTicket) => void) { + this.onTicketReceivedCallback = cb; + } + private onTicketReceived(epochIndex: Epoch, ticket: SignedTicket) { logger.trace`Received ticket for epoch ${epochIndex}, attempt ${ticket.attempt}`; // Add to pending queue for potential re-distribution this.addTicket(epochIndex, ticket); + this.onTicketReceivedCallback?.(epochIndex, ticket); } } diff --git a/packages/workers/block-authorship/generator.test.ts b/packages/workers/block-authorship/generator.test.ts index 8c6941cfb..d4b0b02c8 100644 --- a/packages/workers/block-authorship/generator.test.ts +++ b/packages/workers/block-authorship/generator.test.ts @@ -13,11 +13,13 @@ import { type ValidatorIndex, ValidatorKeys, } from "@typeberry/block"; +import { SignedTicket, tryAsTicketAttempt } from "@typeberry/block/tickets.js"; import { Bytes, BytesBlob } from "@typeberry/bytes"; import { asKnownSize, FixedSizeArray } from "@typeberry/collections"; import { tinyChainSpec } from "@typeberry/config"; import { BANDERSNATCH_KEY_BYTES, + BANDERSNATCH_PROOF_BYTES, BANDERSNATCH_VRF_SIGNATURE_BYTES, BLS_KEY_BYTES, ED25519_KEY_BYTES, @@ -222,6 +224,84 @@ describe("Generator", () => { deepEqual(block, expectedBlock); }); + it("should include sorted tickets during contest period", async () => { + // tinyChainSpec: contestLength = 10, so slot 1 is in contest period (1 < 10) + const state = createMockState(0); + const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); + const statesDb = createMockStatesDb(state); + + const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + + // Create two tickets with different signatures + const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig1.raw[0] = 1; + const sig2 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig2.raw[0] = 2; + + const ticket1 = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig1.asOpaque(), + }); + const ticket2 = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig2.asOpaque(), + }); + + // Mock verifyTickets: ticket2 gets smaller ID (0x01...) and ticket1 gets larger ID (0x02...) + // so the sorted order should be [ticket2, ticket1] + const id1 = Bytes.fill(HASH_SIZE, 0x02).asOpaque(); + const id2 = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); + mock.method(bandersnatchVrf, "verifyTickets", () => + Promise.resolve([ + { isValid: true, entropyHash: id1 }, + { isValid: true, entropyHash: id2 }, + ]), + ); + + const validatorIndex = tryAsValidatorIndex(0); + // Slot 1 is in contest period (1 < contestLength=10) + const timeSlot = tryAsTimeSlot(1); + + const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ + ticket1, + ticket2, + ]); + + // Tickets should be sorted by ID ascending: ticket2 (id=0x01) before ticket1 (id=0x02) + const tickets = block.extrinsic.tickets as unknown as SignedTicket[]; + deepEqual(tickets.length, 2); + deepEqual(tickets[0].signature, sig2.asOpaque()); + deepEqual(tickets[1].signature, sig1.asOpaque()); + }); + + it("should exclude tickets outside contest period", async () => { + // tinyChainSpec: contestLength = 10, epochLength = 12 + // Slot 10 is outside contest period (10 >= 10) + const state = createMockState(9); + const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); + const statesDb = createMockStatesDb(state); + + const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + + const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + const ticket1 = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig1.asOpaque(), + }); + + const validatorIndex = tryAsValidatorIndex(0); + // Slot 10 is NOT in contest period (10 >= contestLength=10) + const timeSlot = tryAsTimeSlot(10); + + const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ + ticket1, + ]); + + // No tickets should be included outside contest period + const tickets = block.extrinsic.tickets as unknown as SignedTicket[]; + deepEqual(tickets.length, 0); + }); + it("should create block with epoch marker at epoch boundary", async () => { // tinyChainSpec.epochLength = 12, so: // - timeslot 11 is last slot of epoch 0 diff --git a/packages/workers/block-authorship/generator.ts b/packages/workers/block-authorship/generator.ts index 75542a2f7..ffb6cc782 100644 --- a/packages/workers/block-authorship/generator.ts +++ b/packages/workers/block-authorship/generator.ts @@ -8,7 +8,9 @@ import { } from "@typeberry/block"; import { type BlockView, Extrinsic } from "@typeberry/block/block.js"; import { DisputesExtrinsic } from "@typeberry/block/disputes.js"; +import type { SignedTicket } from "@typeberry/block/tickets.js"; import { Bytes, BytesBlob } from "@typeberry/bytes"; +import { HashSet } from "@typeberry/collections/hash-set.js"; import type { ChainSpec } from "@typeberry/config"; import { BANDERSNATCH_VRF_SIGNATURE_BYTES, type BandersnatchSecretSeed } from "@typeberry/crypto"; import type { BlocksDb, StatesDb } from "@typeberry/database"; @@ -67,8 +69,9 @@ export class Generator { bandersnatchSecret: BandersnatchSecretSeed, sealPayload: BlockSealInput, timeSlot: TimeSlot, + pendingTickets: SignedTicket[] = [], ): Promise { - const newBlock = await this.nextBlock(validatorIndex, bandersnatchSecret, sealPayload, timeSlot); + const newBlock = await this.nextBlock(validatorIndex, bandersnatchSecret, sealPayload, timeSlot, pendingTickets); return reencodeAsView(Block.Codec, newBlock, this.chainSpec); } @@ -99,11 +102,54 @@ export class Generator { return entropyHashResult; } + private async prepareTicketsExtrinsic( + pendingTickets: SignedTicket[], + state: ReturnType["lastState"], + ): Promise { + if (pendingTickets.length === 0) { + return []; + } + + const verificationResults = await bandersnatchVrf.verifyTickets( + this.bandersnatch, + state.designatedValidatorData.length, + state.epochRoot, + pendingTickets, + state.entropy[2], + ); + + // Build a set of ticket IDs already in the state accumulator for fast lookup + const accumulatedIds = HashSet.from(state.ticketsAccumulator.map((t) => t.id)); + + // Combine tickets with their IDs, filter out invalid ones and those already accumulated + const withIds = pendingTickets + .map((ticket, i) => ({ + ticket, + id: verificationResults[i].entropyHash, + isValid: verificationResults[i].isValid, + })) + .filter(({ isValid, id }) => isValid && !accumulatedIds.has(id)); + + // Sort by ID ascending (Ordering.value is -1/0/1, compatible with Array.sort) + withIds.sort((a, b) => a.id.compare(b.id).value); + + // Deduplicate by ID + const deduped: typeof withIds = []; + for (const item of withIds) { + if (deduped.length === 0 || !deduped[deduped.length - 1].id.isEqualTo(item.id)) { + deduped.push(item); + } + } + + return deduped.slice(0, this.chainSpec.maxTicketsPerExtrinsic).map(({ ticket }) => ticket); + } + async nextBlock( validatorIndex: ValidatorIndex, bandersnatchSecret: BandersnatchSecretSeed, sealPayload: BlockSealInput, timeSlot: TimeSlot, + pendingTickets: SignedTicket[] = [], ) { this.metrics.recordBlockAuthoringStarted(timeSlot); const startTime = now(); @@ -133,9 +179,14 @@ export class Generator { const hasher = new TransitionHasher(this.keccakHasher, this.blake2b); const stateRoot = this.states.getStateRoot(lastState); - // TODO create extrinsic + const slotInEpoch = timeSlot % this.chainSpec.epochLength; + const isContestPeriod = slotInEpoch < this.chainSpec.contestLength; + + // Include tickets only during contest period + const ticketsForExtrinsic = isContestPeriod ? await this.prepareTicketsExtrinsic(pendingTickets, lastState) : []; + const extrinsic = Extrinsic.create({ - tickets: asOpaqueType([]), + tickets: asOpaqueType(ticketsForExtrinsic), preimages: [], guarantees: asOpaqueType([]), assurances: asOpaqueType([]), diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index 5708e62e4..12250d276 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -8,7 +8,7 @@ import { tryAsTimeSlot, tryAsValidatorIndex, } from "@typeberry/block"; -import type { TicketAttempt } from "@typeberry/block/tickets.js"; +import type { SignedTicket } from "@typeberry/block/tickets.js"; import { BytesBlob } from "@typeberry/bytes"; import { HashSet } from "@typeberry/collections/hash-set.js"; import type { NetworkingComms } from "@typeberry/comms-authorship-network"; @@ -23,10 +23,11 @@ import { Blake2b, keccak } from "@typeberry/hash"; import { Logger } from "@typeberry/logger"; import { tryAsU64 } from "@typeberry/numbers"; import { Safrole } from "@typeberry/safrole"; +import bandersnatchVrf from "@typeberry/safrole/bandersnatch-vrf.js"; import { BandernsatchWasm } from "@typeberry/safrole/bandersnatch-wasm.js"; import { JAM_FALLBACK_SEAL, JAM_TICKET_SEAL } from "@typeberry/safrole/constants.js"; import { type SafroleSealingKeys, SafroleSealingKeysKind, type State, type ValidatorData } from "@typeberry/state"; -import { asOpaqueType, assertNever, Result } from "@typeberry/utils"; +import { asOpaqueType, Result } from "@typeberry/utils"; import type { WorkerConfig } from "@typeberry/workers-api"; import { type BlockSealInput, Generator } from "./generator.js"; import type { BlockAuthorshipConfig, GeneratorInternal } from "./protocol.js"; @@ -108,16 +109,6 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return tryAsU64(BigInt(startTimeSlot) * slotDurationMs + timeFromStart + slotDurationMs); } - function getKeyForCurrentSlot(sealingKeySeries: SafroleSealingKeys, keys: ValidatorKeys[], timeSlot: TimeSlot) { - if (sealingKeySeries.kind === SafroleSealingKeysKind.Keys) { - const indexForCurrentSlot = timeSlot % sealingKeySeries.keys.length; - const sealingKey = sealingKeySeries.keys[indexForCurrentSlot]; - return keys.find((x) => x.bandersnatchPublic.isEqualTo(sealingKey)) ?? null; - } - - throw new Error("Tickets mode is not supported yet"); - } - function getValidatorIndex(key: ValidatorKeys, currentValidatorData: PerValidator) { const index = currentValidatorData.findIndex((data) => data.bandersnatch.isEqualTo(key.bandersnatchPublic)); if (index < 0) { @@ -126,20 +117,52 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return tryAsValidatorIndex(index); } - function getSealPayload( + /** + * Returns the validator key and seal payload for the current slot, or null if we are not the author. + * + * Keys mode (fallback): matches our key against the slot's assigned bandersnatch key. + * Tickets mode: finds the key whose VRF output matches the slot's ticket ID. + */ + async function getAuthorInfo( sealingKeySeries: SafroleSealingKeys, + keys: ValidatorKeys[], + timeSlot: TimeSlot, entropy: EntropyHash, - attempt?: TicketAttempt, - ): BlockSealInput { + ): Promise<{ key: ValidatorKeys; sealPayload: BlockSealInput; logId: string } | null> { if (sealingKeySeries.kind === SafroleSealingKeysKind.Keys) { - return asOpaqueType(BytesBlob.blobFromParts(JAM_FALLBACK_SEAL, entropy.raw)); + const indexForCurrentSlot = timeSlot % sealingKeySeries.keys.length; + const sealingKey = sealingKeySeries.keys[indexForCurrentSlot]; + const key = keys.find((x) => x.bandersnatchPublic.isEqualTo(sealingKey)) ?? null; + if (key === null) { + return null; + } + return { + key, + sealPayload: asOpaqueType(BytesBlob.blobFromParts(JAM_FALLBACK_SEAL, entropy.raw)), + logId: `key ${key.bandersnatchPublic}`, + }; } - if (sealingKeySeries.kind === SafroleSealingKeysKind.Tickets) { - return asOpaqueType(BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([attempt ?? 0]))); + // Tickets mode: each slot is sealed by the validator who can produce the VRF output + // matching the ticket's ID for that slot. + const index = timeSlot % sealingKeySeries.tickets.length; + const ticket = sealingKeySeries.tickets.at(index); + if (ticket === undefined) { + return null; } - assertNever(sealingKeySeries); + const payload = BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([ticket.attempt])); + for (const key of keys) { + const result = await bandersnatchVrf.getVrfOutputHash(bandersnatch, key.bandersnatchSecret, payload); + if (result.isOk && ticket.id.isEqualTo(result.ok)) { + return { + key, + sealPayload: asOpaqueType(payload), + logId: `ticket ${ticket.id} (attempt ${ticket.attempt})`, + }; + } + } + return null; } function isEpochChanged(lastTimeslot: TimeSlot, currentTimeslot: TimeSlot): boolean { @@ -149,11 +172,18 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC } function logEpochBlockCreation(epoch: Epoch, sealingKeySeries: SafroleSealingKeys) { + if (sealingKeySeries.kind === SafroleSealingKeysKind.Tickets) { + logger.info`[EPOCH ${epoch}] Tickets mode active with ${sealingKeySeries.tickets.length} tickets.`; + return; + } + let isCreating = false; const epochStart = epoch * chainSpec.epochLength; const epochEnd = epochStart + chainSpec.epochLength; for (let slot = epochStart; slot < epochEnd; slot++) { - const key = getKeyForCurrentSlot(sealingKeySeries, keys, tryAsTimeSlot(slot)); + const indexForCurrentSlot = slot % sealingKeySeries.keys.length; + const sealingKey = sealingKeySeries.keys[indexForCurrentSlot]; + const key = keys.find((x) => x.bandersnatchPublic.isEqualTo(sealingKey)) ?? null; if (key !== null) { isCreating = true; logger.info`[EPOCH ${epoch}] Validator ${key.bandersnatchPublic.toString()} will author block at slot ${slot}`; @@ -178,6 +208,29 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return Result.ok(state.sealingKeySeries); } + // Ticket pool: epochIndex -> SignedTicket[] + const ticketPool = new Map(); + + function addToPool(epochIndex: number, tickets: SignedTicket[]) { + // Clear old epochs when epoch changes + if (ticketPool.size > 0 && !ticketPool.has(epochIndex)) { + ticketPool.clear(); + } + const existing = ticketPool.get(epochIndex) ?? []; + for (const ticket of tickets) { + if (!existing.some((t) => t.signature.isEqualTo(ticket.signature))) { + existing.push(ticket); + } + } + ticketPool.set(epochIndex, existing); + } + + // Receive tickets from peers (via jam-network worker) + networkingComms.setOnReceivedTickets(({ epochIndex, tickets }) => { + logger.log`Received ${tickets.length} tickets from peers for epoch ${epochIndex}`; + addToPool(epochIndex, tickets); + }); + const isFastForward = config.workerParams.isFastForward; let lastGeneratedSlot = startTimeSlot; let ticketsGeneratedForEpoch = -1; @@ -246,6 +299,9 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC } else { logger.log`Generated ${ticketsResult.ok.length} tickets for epoch ${epoch}. Distributing...`; + // Add own tickets to pool + addToPool(epoch, ticketsResult.ok); + // Send directly to network worker (bypasses main thread) await networkingComms.sendTickets({ epochIndex: epoch, tickets: ticketsResult.ok }); } @@ -264,18 +320,25 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC logEpochBlockCreation(epoch, selingKeySeriesResult.ok); } - const key = getKeyForCurrentSlot(selingKeySeriesResult.ok, keys, timeSlot); + const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; + const authorInfo = await getAuthorInfo(selingKeySeriesResult.ok, keys, timeSlot, entropy); - if (key !== null && currentValidatorData !== undefined) { + if (authorInfo !== null && currentValidatorData !== undefined) { + const { key, sealPayload } = authorInfo; const validatorIndex = getValidatorIndex(key, currentValidatorData); if (validatorIndex === null) { continue; } - logger.log`Attempting to create a block using key ${key.bandersnatchPublic} located at validator index ${validatorIndex}.`; - const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; - const sealPayload = getSealPayload(selingKeySeriesResult.ok, entropy); - const newBlock = await generator.nextBlockView(validatorIndex, key.bandersnatchSecret, sealPayload, timeSlot); + logger.log`Attempting to create a block using ${authorInfo.logId} located at validator index ${validatorIndex}.`; + const currentEpochTickets = ticketPool.get(epoch) ?? []; + const newBlock = await generator.nextBlockView( + validatorIndex, + key.bandersnatchSecret, + sealPayload, + timeSlot, + currentEpochTickets, + ); counter += 1; lastGeneratedSlot = timeSlot; logger.trace`Sending block ${counter}`; diff --git a/packages/workers/comms-authorship-network/protocol.ts b/packages/workers/comms-authorship-network/protocol.ts index ac508dff3..af239c48f 100644 --- a/packages/workers/comms-authorship-network/protocol.ts +++ b/packages/workers/comms-authorship-network/protocol.ts @@ -21,8 +21,13 @@ export const protocol = createProtocol("authorship-network", { response: codec.nothing, }, }, - // Messages from jam-network to block-authorship (none for now) - fromWorker: {}, + // Messages from jam-network to block-authorship + fromWorker: { + receivedTickets: { + request: TicketsMessage.Codec, + response: codec.nothing, + }, + }, }); export type NetworkingComms = Api; diff --git a/packages/workers/jam-network/main.ts b/packages/workers/jam-network/main.ts index 491109e31..d378b9337 100644 --- a/packages/workers/jam-network/main.ts +++ b/packages/workers/jam-network/main.ts @@ -61,6 +61,11 @@ export async function main( } }); + // Relay tickets received from peers back to block-authorship + network.ticketTask.setOnTicketReceived((epochIndex, ticket) => { + authorshipComms.sendReceivedTickets({ epochIndex, tickets: [ticket] }); + }); + await network.network.start(); // stop the network when the worker is finishing. From 3a4cdad88aebf7a1cad666114981edef6804e202 Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Tue, 10 Mar 2026 07:38:44 +0100 Subject: [PATCH 02/11] fix build --- packages/workers/block-authorship/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index 12250d276..991143d1c 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -226,7 +226,7 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC } // Receive tickets from peers (via jam-network worker) - networkingComms.setOnReceivedTickets(({ epochIndex, tickets }) => { + networkingComms.setOnReceivedTickets(async ({ epochIndex, tickets }) => { logger.log`Received ${tickets.length} tickets from peers for epoch ${epochIndex}`; addToPool(epochIndex, tickets); }); From 2dbcf936a7beef16a775ab4e670441e380c4f50c Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Fri, 27 Mar 2026 20:07:27 +0100 Subject: [PATCH 03/11] feat: add ticket pool, peer relay, and tickets-mode authorship --- .../jamnp-s/tasks/ticket-distribution.test.ts | 51 +++++- .../jam/jamnp-s/tasks/ticket-distribution.ts | 24 ++- .../block-authorship/generator.test.ts | 146 ++++++++++++++++-- .../workers/block-authorship/generator.ts | 56 ++++--- packages/workers/block-authorship/main.ts | 138 +++++++++++++++-- .../comms-authorship-network/protocol.ts | 10 +- .../tickets-message.ts | 19 +++ packages/workers/jam-network/main.ts | 7 +- 8 files changed, 383 insertions(+), 68 deletions(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.test.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.test.ts index 1eb670304..2532389bc 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.test.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.test.ts @@ -261,7 +261,7 @@ describe("TicketDistributionTask", () => { peer1.ticketTask.maintainDistribution(); await tick(); - // Self receives the ticket (via onTicketReceived -> addTicket) + // Self receives the ticket (via onTicketReceived -> addTicket, no callback set) assert.strictEqual(self.receivedTickets.length, 1); // Self should re-distribute to peer2 (and peer1, but peer1 already has it) @@ -272,4 +272,53 @@ describe("TicketDistributionTask", () => { assert.strictEqual(peer2.receivedTickets.length, 1); assert.deepStrictEqual(peer2.receivedTickets[0].ticket, ticket); }); + + it("should NOT redistribute ticket if validation callback returns false", async () => { + const self = await init("self"); + const peer1 = await init("peer1"); + const peer2 = await init("peer2"); + + self.openConnection(peer1); + self.openConnection(peer2); + await tick(); + + // Validation always rejects + self.ticketTask.setOnTicketReceived(async () => false); + + const ticket = createTestTicket(0); + peer1.ticketTask.addTicket(TEST_EPOCH, ticket); + peer1.ticketTask.maintainDistribution(); + await tick(); + + // self.addTicket was NOT called (callback returned false), so nothing to redistribute + assert.strictEqual(self.receivedTickets.length, 0); + self.ticketTask.maintainDistribution(); + await tick(); + assert.strictEqual(peer2.receivedTickets.length, 0); + }); + + it("should redistribute ticket if validation callback returns true", async () => { + const self = await init("self"); + const peer1 = await init("peer1"); + const peer2 = await init("peer2"); + + self.openConnection(peer1); + self.openConnection(peer2); + await tick(); + + // Validation always accepts + self.ticketTask.setOnTicketReceived(async () => true); + + const ticket = createTestTicket(0); + peer1.ticketTask.addTicket(TEST_EPOCH, ticket); + peer1.ticketTask.maintainDistribution(); + await tick(); + + // self.addTicket WAS called (callback returned true) + assert.strictEqual(self.receivedTickets.length, 1); + self.ticketTask.maintainDistribution(); + await tick(); + assert.strictEqual(peer2.receivedTickets.length, 1); + assert.deepStrictEqual(peer2.receivedTickets[0].ticket, ticket); + }); }); diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index 388a6bce0..8cfda72d1 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -144,16 +144,30 @@ export class TicketDistributionTask { } } - private onTicketReceivedCallback?: (epochIndex: Epoch, ticket: SignedTicket) => void; + private onTicketReceivedCallback?: (epochIndex: Epoch, ticket: SignedTicket) => Promise; - setOnTicketReceived(cb: (epochIndex: Epoch, ticket: SignedTicket) => void) { + /** + * Register a callback that validates a received ticket. + * The ticket is only added to the redistribution pool if the callback returns `true`. + * This prevents redistribution of invalid tickets (e.g. those with a tampered `attempt` field). + */ + setOnTicketReceived(cb: (epochIndex: Epoch, ticket: SignedTicket) => Promise) { this.onTicketReceivedCallback = cb; } private onTicketReceived(epochIndex: Epoch, ticket: SignedTicket) { logger.trace`Received ticket for epoch ${epochIndex}, attempt ${ticket.attempt}`; - // Add to pending queue for potential re-distribution - this.addTicket(epochIndex, ticket); - this.onTicketReceivedCallback?.(epochIndex, ticket); + if (this.onTicketReceivedCallback) { + // Validate first; only redistribute if valid to avoid spreading tampered tickets. + this.onTicketReceivedCallback(epochIndex, ticket).then((isValid) => { + if (isValid) { + this.addTicket(epochIndex, ticket); + } else { + logger.warn`Dropping invalid ticket for epoch ${epochIndex} (validation failed)`; + } + }); + } else { + this.addTicket(epochIndex, ticket); + } } } diff --git a/packages/workers/block-authorship/generator.test.ts b/packages/workers/block-authorship/generator.test.ts index d4b0b02c8..72d7a7a71 100644 --- a/packages/workers/block-authorship/generator.test.ts +++ b/packages/workers/block-authorship/generator.test.ts @@ -13,7 +13,7 @@ import { type ValidatorIndex, ValidatorKeys, } from "@typeberry/block"; -import { SignedTicket, tryAsTicketAttempt } from "@typeberry/block/tickets.js"; +import { SignedTicket, Ticket, tryAsTicketAttempt } from "@typeberry/block/tickets.js"; import { Bytes, BytesBlob } from "@typeberry/bytes"; import { asKnownSize, FixedSizeArray } from "@typeberry/collections"; import { tinyChainSpec } from "@typeberry/config"; @@ -247,24 +247,19 @@ describe("Generator", () => { signature: sig2.asOpaque(), }); - // Mock verifyTickets: ticket2 gets smaller ID (0x01...) and ticket1 gets larger ID (0x02...) + // ticket2 gets smaller ID (0x01...) and ticket1 gets larger ID (0x02...) // so the sorted order should be [ticket2, ticket1] const id1 = Bytes.fill(HASH_SIZE, 0x02).asOpaque(); const id2 = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); - mock.method(bandersnatchVrf, "verifyTickets", () => - Promise.resolve([ - { isValid: true, entropyHash: id1 }, - { isValid: true, entropyHash: id2 }, - ]), - ); const validatorIndex = tryAsValidatorIndex(0); // Slot 1 is in contest period (1 < contestLength=10) const timeSlot = tryAsTimeSlot(1); + // IDs are now pre-computed before passing to nextBlock const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ - ticket1, - ticket2, + { ticket: ticket1, id: id1 }, + { ticket: ticket2, id: id2 }, ]); // Tickets should be sorted by ID ascending: ticket2 (id=0x01) before ticket1 (id=0x02) @@ -293,8 +288,9 @@ describe("Generator", () => { // Slot 10 is NOT in contest period (10 >= contestLength=10) const timeSlot = tryAsTimeSlot(10); + const mockId = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ - ticket1, + { ticket: ticket1, id: mockId }, ]); // No tickets should be included outside contest period @@ -302,6 +298,134 @@ describe("Generator", () => { deepEqual(tickets.length, 0); }); + it("should filter out tickets already in ticketsAccumulator", async () => { + // Build a state that already has ticket with id=0x01 in its accumulator + const accumulatedId = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); + const accumulatedTicket = Ticket.create({ + id: accumulatedId, + attempt: tryAsTicketAttempt(0, tinyChainSpec), + }); + + const state = { + ...createMockState(0), + ticketsAccumulator: asKnownSize([accumulatedTicket]), + }; + const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); + const statesDb = createMockStatesDb(state); + + const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + + const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig1.raw[0] = 1; + const sig2 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig2.raw[0] = 2; + + const ticketAlreadyAccumulated = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig1.asOpaque(), + }); + const ticketNew = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig2.asOpaque(), + }); + + // id=0x01 is already in accumulator, id=0x02 is new + const idAccumulated = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); + const idNew = Bytes.fill(HASH_SIZE, 0x02).asOpaque(); + + const validatorIndex = tryAsValidatorIndex(0); + const timeSlot = tryAsTimeSlot(1); // inside contest period + + const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ + { ticket: ticketAlreadyAccumulated, id: idAccumulated }, + { ticket: ticketNew, id: idNew }, + ]); + + // Only the new ticket (not in accumulator) should be included + const tickets = block.extrinsic.tickets as unknown as SignedTicket[]; + deepEqual(tickets.length, 1); + deepEqual(tickets[0].signature, sig2.asOpaque()); + }); + + it("should deduplicate tickets by ID", async () => { + const state = createMockState(0); + const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); + const statesDb = createMockStatesDb(state); + + const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + + const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig1.raw[0] = 1; + const sig2 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig2.raw[0] = 2; + + // Two different SignedTicket objects but with the same ID (e.g. duplicate from reorg) + const ticketA = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig1.asOpaque(), + }); + const ticketB = SignedTicket.create({ + attempt: tryAsTicketAttempt(0, tinyChainSpec), + signature: sig2.asOpaque(), + }); + const duplicateId = Bytes.fill(HASH_SIZE, 0x05).asOpaque(); + + const validatorIndex = tryAsValidatorIndex(0); + const timeSlot = tryAsTimeSlot(1); + + const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ + { ticket: ticketA, id: duplicateId }, + { ticket: ticketB, id: duplicateId }, // same ID — should be deduplicated + ]); + + const tickets = block.extrinsic.tickets as unknown as SignedTicket[]; + deepEqual(tickets.length, 1); + // First occurrence is kept after sort (both have same ID, ticketA comes first) + deepEqual(tickets[0].signature, sig1.asOpaque()); + }); + + it("should include at most maxTicketsPerExtrinsic tickets", async () => { + // tinyChainSpec.maxTicketsPerExtrinsic = 3 + const state = createMockState(0); + const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); + const statesDb = createMockStatesDb(state); + + const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + + // Create 4 tickets — only 3 should be included (lowest IDs win) + const makeTicket = (sigByte: number, idByte: number) => { + const sig = Bytes.zero(BANDERSNATCH_PROOF_BYTES); + sig.raw[0] = sigByte; + return { + ticket: SignedTicket.create({ attempt: tryAsTicketAttempt(0, tinyChainSpec), signature: sig.asOpaque() }), + id: Bytes.fill(HASH_SIZE, idByte).asOpaque(), + sig: sig.asOpaque(), + }; + }; + + const t1 = makeTicket(1, 0x01); // lowest ID + const t2 = makeTicket(2, 0x02); + const t3 = makeTicket(3, 0x03); + const t4 = makeTicket(4, 0x04); // highest ID — should be excluded + + const validatorIndex = tryAsValidatorIndex(0); + const timeSlot = tryAsTimeSlot(1); + + const block = await generator.nextBlock(validatorIndex, MOCK_BANDERSNATCH_SECRET, MOCK_SEAL_PAYLOAD, timeSlot, [ + { ticket: t4.ticket, id: t4.id }, // pass out-of-order to verify sorting + { ticket: t2.ticket, id: t2.id }, + { ticket: t3.ticket, id: t3.id }, + { ticket: t1.ticket, id: t1.id }, + ]); + + const tickets = block.extrinsic.tickets as unknown as SignedTicket[]; + deepEqual(tickets.length, 3); // maxTicketsPerExtrinsic = 3 + // Should include the 3 lowest IDs, sorted ascending + deepEqual(tickets[0].signature, t1.sig); + deepEqual(tickets[1].signature, t2.sig); + deepEqual(tickets[2].signature, t3.sig); + }); + it("should create block with epoch marker at epoch boundary", async () => { // tinyChainSpec.epochLength = 12, so: // - timeslot 11 is last slot of epoch 0 diff --git a/packages/workers/block-authorship/generator.ts b/packages/workers/block-authorship/generator.ts index ffb6cc782..ef12656da 100644 --- a/packages/workers/block-authorship/generator.ts +++ b/packages/workers/block-authorship/generator.ts @@ -1,5 +1,6 @@ import { Block, + type EntropyHash, encodeUnsealedHeader, Header, reencodeAsView, @@ -69,7 +70,7 @@ export class Generator { bandersnatchSecret: BandersnatchSecretSeed, sealPayload: BlockSealInput, timeSlot: TimeSlot, - pendingTickets: SignedTicket[] = [], + pendingTickets: { ticket: SignedTicket; id: EntropyHash }[] = [], ): Promise { const newBlock = await this.nextBlock(validatorIndex, bandersnatchSecret, sealPayload, timeSlot, pendingTickets); return reencodeAsView(Block.Codec, newBlock, this.chainSpec); @@ -102,40 +103,37 @@ export class Generator { return entropyHashResult; } - private async prepareTicketsExtrinsic( - pendingTickets: SignedTicket[], + /** + * Selects tickets to include in the extrinsic from the pending pool. + * + * Tickets were already verified at receipt time (IDs pre-computed). This method: + * 1. Filters out tickets whose IDs are already in `state.ticketsAccumulator` (already processed). + * 2. Sorts remaining tickets by ID ascending (required by Safrole). + * 3. Deduplicates by ID (pool dedup is best-effort; reorgs can produce duplicates). + * 4. Returns at most `chainSpec.maxTicketsPerExtrinsic` tickets. + * + * Called only during the contest period (slotInEpoch < contestLength). + */ + private prepareTicketsExtrinsic( + pendingTickets: { ticket: SignedTicket; id: EntropyHash }[], state: ReturnType["lastState"], - ): Promise { + ): SignedTicket[] { if (pendingTickets.length === 0) { return []; } - const verificationResults = await bandersnatchVrf.verifyTickets( - this.bandersnatch, - state.designatedValidatorData.length, - state.epochRoot, - pendingTickets, - state.entropy[2], - ); - - // Build a set of ticket IDs already in the state accumulator for fast lookup + // Tickets are already verified at receipt time — just filter, sort and slice. + // Build a set of ticket IDs already in the state accumulator for fast lookup. const accumulatedIds = HashSet.from(state.ticketsAccumulator.map((t) => t.id)); - // Combine tickets with their IDs, filter out invalid ones and those already accumulated - const withIds = pendingTickets - .map((ticket, i) => ({ - ticket, - id: verificationResults[i].entropyHash, - isValid: verificationResults[i].isValid, - })) - .filter(({ isValid, id }) => isValid && !accumulatedIds.has(id)); - - // Sort by ID ascending (Ordering.value is -1/0/1, compatible with Array.sort) - withIds.sort((a, b) => a.id.compare(b.id).value); - - // Deduplicate by ID - const deduped: typeof withIds = []; - for (const item of withIds) { + const filtered = pendingTickets.filter(({ id }) => !accumulatedIds.has(id)); + + // Sort by ID ascending + filtered.sort((a, b) => a.id.compare(b.id).value); + + // Deduplicate by ID (pool dedup is best-effort; state may produce duplicates across reorgs) + const deduped: typeof filtered = []; + for (const item of filtered) { if (deduped.length === 0 || !deduped[deduped.length - 1].id.isEqualTo(item.id)) { deduped.push(item); } @@ -149,7 +147,7 @@ export class Generator { bandersnatchSecret: BandersnatchSecretSeed, sealPayload: BlockSealInput, timeSlot: TimeSlot, - pendingTickets: SignedTicket[] = [], + pendingTickets: { ticket: SignedTicket; id: EntropyHash }[] = [], ) { this.metrics.recordBlockAuthoringStarted(timeSlot); const startTime = now(); diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index 991143d1c..a0c1991c6 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -117,11 +117,48 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return tryAsValidatorIndex(index); } + // Per-epoch cache for Tickets mode: index corresponds to position in sealingKeySeries.tickets. + // null entry means none of our keys match that slot. + // Rebuilt once per epoch via buildTicketAuthorshipCache(). + let ticketAuthorshipCache: Array<{ key: ValidatorKeys; sealPayload: BlockSealInput } | null> | null = null; + + /** + * Precomputes which slots we are the author of for the current epoch (Tickets mode). + * + * Iterates over every ticket in sealingKeySeries once and runs getVrfOutputHash for + * each of our keys. Stores the result indexed by ticket position so getAuthorInfo + * can do a O(1) array lookup per slot instead of O(keys) VRF calls. + * + * Called once at the start of each epoch when isNewEpoch = true. + */ + async function buildTicketAuthorshipCache(sealingKeySeries: SafroleSealingKeys, entropy: EntropyHash) { + if (sealingKeySeries.kind !== SafroleSealingKeysKind.Tickets) { + ticketAuthorshipCache = null; + return; + } + const cache: Array<{ key: ValidatorKeys; sealPayload: BlockSealInput } | null> = []; + for (const ticket of sealingKeySeries.tickets) { + const payload = BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([ticket.attempt])); + let found: { key: ValidatorKeys; sealPayload: BlockSealInput } | null = null; + for (const key of keys) { + const result = await bandersnatchVrf.getVrfOutputHash(bandersnatch, key.bandersnatchSecret, payload); + if (result.isOk && ticket.id.isEqualTo(result.ok)) { + found = { key, sealPayload: asOpaqueType(payload) }; + break; + } + } + cache.push(found); + } + ticketAuthorshipCache = cache; + const ours = cache.filter(Boolean).length; + logger.info`Built ticket authorship cache: ${ours}/${cache.length} slots assigned to us this epoch.`; + } + /** * Returns the validator key and seal payload for the current slot, or null if we are not the author. * * Keys mode (fallback): matches our key against the slot's assigned bandersnatch key. - * Tickets mode: finds the key whose VRF output matches the slot's ticket ID. + * Tickets mode: uses precomputed cache (built once per epoch) for O(1) lookup per slot. */ async function getAuthorInfo( sealingKeySeries: SafroleSealingKeys, @@ -151,6 +188,16 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return null; } + // Fast path: use precomputed cache (available after first isNewEpoch iteration) + if (ticketAuthorshipCache !== null) { + const cached = ticketAuthorshipCache[index] ?? null; + if (cached === null) { + return null; + } + return { ...cached, logId: `ticket ${ticket.id} (attempt ${ticket.attempt})` }; + } + + // Slow path: compute VRF on the fly (first slot of epoch, before cache is ready) const payload = BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([ticket.attempt])); for (const key of keys) { const result = await bandersnatchVrf.getVrfOutputHash(bandersnatch, key.bandersnatchSecret, payload); @@ -208,27 +255,84 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return Result.ok(state.sealingKeySeries); } - // Ticket pool: epochIndex -> SignedTicket[] - const ticketPool = new Map(); + // Ticket pool: epochIndex -> {ticket, id}[] + // IDs (entropyHash) are computed at receipt time via verifyTickets(), enabling O(1) dedup by ID. + const ticketPool = new Map(); + const ticketIdSets = new Map>(); - function addToPool(epochIndex: number, tickets: SignedTicket[]) { - // Clear old epochs when epoch changes + /** + * Adds pre-verified tickets to the in-memory ticket pool for the given epoch. + * + * Clears the pool when the epoch changes (we only ever need tickets for one epoch at a time). + * Deduplicates by ticket ID using a HashSet for O(1) lookup — prevents double-counting + * tickets received from multiple peers or via both CE-131 and CE-132 paths. + */ + function addToPool(epochIndex: number, verifiedTickets: { ticket: SignedTicket; id: EntropyHash }[]) { if (ticketPool.size > 0 && !ticketPool.has(epochIndex)) { ticketPool.clear(); + ticketIdSets.clear(); } const existing = ticketPool.get(epochIndex) ?? []; - for (const ticket of tickets) { - if (!existing.some((t) => t.signature.isEqualTo(ticket.signature))) { - existing.push(ticket); + let idSet = ticketIdSets.get(epochIndex); + if (idSet === undefined) { + idSet = HashSet.new(); + ticketIdSets.set(epochIndex, idSet); + } + for (const entry of verifiedTickets) { + if (!idSet.has(entry.id)) { + existing.push(entry); + idSet.insert(entry.id); } } ticketPool.set(epochIndex, existing); } - // Receive tickets from peers (via jam-network worker) - networkingComms.setOnReceivedTickets(async ({ epochIndex, tickets }) => { - logger.log`Received ${tickets.length} tickets from peers for epoch ${epochIndex}`; - addToPool(epochIndex, tickets); + /** + * Returns the correct tickets entropy for verification given the current state. + * + * When `state` is from epoch E-1 (i.e. we haven't produced epoch E's first block yet), + * the ticket entropy for epoch E is at index 1 (not yet shifted). + * After the epoch transition it moves to index 2. + */ + function getTicketEntropy(epochIndex: number, state: State): EntropyHash { + const stateEpoch = Math.floor(state.timeslot / chainSpec.epochLength); + return epochIndex > stateEpoch ? state.entropy[1] : state.entropy[2]; + } + + /** + * Verifies tickets against the ring commitment and current epoch entropy, then adds valid + * ones to the pool with their computed IDs. + * + * Called both for own generated tickets and for tickets relayed from peers. + * Verification computes the ticket ID (entropyHash) which is then used for + * deduplication in the pool and later when building the extrinsic. + */ + async function verifyAndAddToPool(epochIndex: number, tickets: SignedTicket[], state: State): Promise { + const results = await bandersnatchVrf.verifyTickets( + bandersnatch, + state.designatedValidatorData.length, + state.epochRoot, + tickets, + getTicketEntropy(epochIndex, state), + ); + const verified = tickets + .map((ticket, i) => ({ ticket, id: results[i].entropyHash })) + .filter((_, i) => results[i].isValid); + addToPool(epochIndex, verified); + return verified.length > 0; + } + + // Receive a single ticket from peers (via jam-network worker). + // Returns true if the ticket passed validation so jam-network can decide whether to redistribute it. + networkingComms.setOnReceivedTickets(async ({ epochIndex, ticket }) => { + logger.log`Received ticket from peer for epoch ${epochIndex}`; + const hash = blocks.getBestHeaderHash(); + const state = states.getState(hash); + if (state === null) { + logger.warn`Cannot verify received ticket: no state available`; + return false; + } + return await verifyAndAddToPool(epochIndex, [ticket], state); }); const isFastForward = config.workerParams.isFastForward; @@ -299,8 +403,8 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC } else { logger.log`Generated ${ticketsResult.ok.length} tickets for epoch ${epoch}. Distributing...`; - // Add own tickets to pool - addToPool(epoch, ticketsResult.ok); + // Verify own tickets to get IDs, then add to pool + await verifyAndAddToPool(epoch, ticketsResult.ok, state); // Send directly to network worker (bypasses main thread) await networkingComms.sendTickets({ epochIndex: epoch, tickets: ticketsResult.ok }); @@ -318,6 +422,10 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC if (isNewEpoch) { logEpochBlockCreation(epoch, selingKeySeriesResult.ok); + // Build authorship cache for Tickets mode once per epoch. + // entropy[2] here is the epoch-E entropy (pre-transition state), same value + // that will be at entropy[3] after the transition block is applied. + await buildTicketAuthorshipCache(selingKeySeriesResult.ok, state.entropy[2]); } const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; @@ -337,7 +445,7 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC key.bandersnatchSecret, sealPayload, timeSlot, - currentEpochTickets, + currentEpochTickets, // {ticket, id}[] — already verified ); counter += 1; lastGeneratedSlot = timeSlot; diff --git a/packages/workers/comms-authorship-network/protocol.ts b/packages/workers/comms-authorship-network/protocol.ts index af239c48f..00dee55eb 100644 --- a/packages/workers/comms-authorship-network/protocol.ts +++ b/packages/workers/comms-authorship-network/protocol.ts @@ -1,6 +1,6 @@ import { codec } from "@typeberry/codec"; import { type Api, createProtocol, type Internal } from "@typeberry/workers-api"; -import { TicketsMessage } from "./tickets-message.js"; +import { ReceivedTicketMessage, TicketsMessage } from "./tickets-message.js"; /** * Port name for authorship-network direct communication. @@ -21,11 +21,13 @@ export const protocol = createProtocol("authorship-network", { response: codec.nothing, }, }, - // Messages from jam-network to block-authorship + // Messages from jam-network to block-authorship (one ticket per relay). + // Response indicates whether the ticket passed validation — used by jam-network + // to decide whether to redistribute the ticket to other peers. fromWorker: { receivedTickets: { - request: TicketsMessage.Codec, - response: codec.nothing, + request: ReceivedTicketMessage.Codec, + response: codec.bool, }, }, }); diff --git a/packages/workers/comms-authorship-network/tickets-message.ts b/packages/workers/comms-authorship-network/tickets-message.ts index c77468d30..768394edc 100644 --- a/packages/workers/comms-authorship-network/tickets-message.ts +++ b/packages/workers/comms-authorship-network/tickets-message.ts @@ -20,3 +20,22 @@ export class TicketsMessage extends WithDebug { super(); } } + +/** Single-ticket message sent from jam-network to block-authorship (one ticket per peer relay). */ +export class ReceivedTicketMessage extends WithDebug { + static Codec = codec.Class(ReceivedTicketMessage, { + epochIndex: codec.u32.asOpaque(), + ticket: SignedTicket.Codec, + }); + + static create({ epochIndex, ticket }: CodecRecord) { + return new ReceivedTicketMessage(epochIndex, ticket); + } + + private constructor( + public readonly epochIndex: Epoch, + public readonly ticket: SignedTicket, + ) { + super(); + } +} diff --git a/packages/workers/jam-network/main.ts b/packages/workers/jam-network/main.ts index d378b9337..faf113e34 100644 --- a/packages/workers/jam-network/main.ts +++ b/packages/workers/jam-network/main.ts @@ -61,9 +61,10 @@ export async function main( } }); - // Relay tickets received from peers back to block-authorship - network.ticketTask.setOnTicketReceived((epochIndex, ticket) => { - authorshipComms.sendReceivedTickets({ epochIndex, tickets: [ticket] }); + // Relay tickets received from peers back to block-authorship (one ticket at a time). + // Returns the validation result so ticket-distribution knows whether to redistribute. + network.ticketTask.setOnTicketReceived(async (epochIndex, ticket) => { + return await authorshipComms.sendReceivedTickets({ epochIndex, ticket }); }); await network.network.start(); From 3a92d45f5441531a4e2124762e47e433b780b592 Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Fri, 27 Mar 2026 20:17:24 +0100 Subject: [PATCH 04/11] fix: eslint error --- packages/jam/jamnp-s/tasks/ticket-distribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index 8cfda72d1..4241ad498 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -157,7 +157,7 @@ export class TicketDistributionTask { private onTicketReceived(epochIndex: Epoch, ticket: SignedTicket) { logger.trace`Received ticket for epoch ${epochIndex}, attempt ${ticket.attempt}`; - if (this.onTicketReceivedCallback) { + if (this.onTicketReceivedCallback !== null) { // Validate first; only redistribute if valid to avoid spreading tampered tickets. this.onTicketReceivedCallback(epochIndex, ticket).then((isValid) => { if (isValid) { From d88aa98d1cc27dbe8ecb20440ea13fd3626861d0 Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Fri, 27 Mar 2026 20:18:41 +0100 Subject: [PATCH 05/11] fix: tests --- packages/jam/jamnp-s/tasks/ticket-distribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index 4241ad498..68839b189 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -144,7 +144,7 @@ export class TicketDistributionTask { } } - private onTicketReceivedCallback?: (epochIndex: Epoch, ticket: SignedTicket) => Promise; + private onTicketReceivedCallback: ((epochIndex: Epoch, ticket: SignedTicket) => Promise) | null = null; /** * Register a callback that validates a received ticket. From 21901a30cdc722e1a6842017f24b2924338dbb3e Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Sat, 28 Mar 2026 14:06:05 +0100 Subject: [PATCH 06/11] fix: build errors --- .../workers/block-authorship/generator.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/workers/block-authorship/generator.test.ts b/packages/workers/block-authorship/generator.test.ts index 72d7a7a71..1852b0703 100644 --- a/packages/workers/block-authorship/generator.test.ts +++ b/packages/workers/block-authorship/generator.test.ts @@ -239,11 +239,11 @@ describe("Generator", () => { sig2.raw[0] = 2; const ticket1 = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig1.asOpaque(), }); const ticket2 = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig2.asOpaque(), }); @@ -280,7 +280,7 @@ describe("Generator", () => { const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); const ticket1 = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig1.asOpaque(), }); @@ -303,7 +303,7 @@ describe("Generator", () => { const accumulatedId = Bytes.fill(HASH_SIZE, 0x01).asOpaque(); const accumulatedTicket = Ticket.create({ id: accumulatedId, - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), }); const state = { @@ -321,11 +321,11 @@ describe("Generator", () => { sig2.raw[0] = 2; const ticketAlreadyAccumulated = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig1.asOpaque(), }); const ticketNew = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig2.asOpaque(), }); @@ -361,11 +361,11 @@ describe("Generator", () => { // Two different SignedTicket objects but with the same ID (e.g. duplicate from reorg) const ticketA = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig1.asOpaque(), }); const ticketB = SignedTicket.create({ - attempt: tryAsTicketAttempt(0, tinyChainSpec), + attempt: tryAsTicketAttempt(0), signature: sig2.asOpaque(), }); const duplicateId = Bytes.fill(HASH_SIZE, 0x05).asOpaque(); @@ -397,7 +397,7 @@ describe("Generator", () => { const sig = Bytes.zero(BANDERSNATCH_PROOF_BYTES); sig.raw[0] = sigByte; return { - ticket: SignedTicket.create({ attempt: tryAsTicketAttempt(0, tinyChainSpec), signature: sig.asOpaque() }), + ticket: SignedTicket.create({ attempt: tryAsTicketAttempt(0), signature: sig.asOpaque() }), id: Bytes.fill(HASH_SIZE, idByte).asOpaque(), sig: sig.asOpaque(), }; From 4f5e97bb9b4ef17c8fef0c12eb5a5609ba381989 Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Sun, 29 Mar 2026 10:38:03 +0200 Subject: [PATCH 07/11] fix: review changes --- .../jam/jamnp-s/tasks/ticket-distribution.ts | 18 +++++++++++------- packages/workers/block-authorship/main.ts | 4 ++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index 68839b189..d723f5cd2 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -159,13 +159,17 @@ export class TicketDistributionTask { logger.trace`Received ticket for epoch ${epochIndex}, attempt ${ticket.attempt}`; if (this.onTicketReceivedCallback !== null) { // Validate first; only redistribute if valid to avoid spreading tampered tickets. - this.onTicketReceivedCallback(epochIndex, ticket).then((isValid) => { - if (isValid) { - this.addTicket(epochIndex, ticket); - } else { - logger.warn`Dropping invalid ticket for epoch ${epochIndex} (validation failed)`; - } - }); + this.onTicketReceivedCallback(epochIndex, ticket) + .then((isValid) => { + if (isValid) { + this.addTicket(epochIndex, ticket); + } else { + logger.warn`Dropping invalid ticket for epoch ${epochIndex} (validation failed)`; + } + }) + .catch((error) => { + logger.error`Error validating ticket for epoch ${epochIndex}, attempt ${ticket.attempt}: ${error}`; + }); } else { this.addTicket(epochIndex, ticket); } diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index deb3f08c6..5b34790a3 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -315,6 +315,10 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC tickets, getTicketEntropy(epochIndex, state), ); + if (results.length !== tickets.length) { + logger.error`verifyTickets returned ${results.length} results for ${tickets.length} tickets`; + return false; + } const verified = tickets .map((ticket, i) => ({ ticket, id: results[i].entropyHash })) .filter((_, i) => results[i].isValid); From dcea3e1e0d829705013c16b5b4b92adac6c94b0c Mon Sep 17 00:00:00 2001 From: Mateusz Sikora Date: Sun, 29 Mar 2026 11:04:26 +0200 Subject: [PATCH 08/11] fix: review changes --- .../jam/jamnp-s/tasks/ticket-distribution.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index d723f5cd2..903be38b4 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -113,8 +113,15 @@ export class TicketDistributionTask { * Deduplicates tickets based on signature. */ addTicket(epochIndex: Epoch, ticket: SignedTicket) { - // Check if epoch changed - if so, clear old tickets - if (this.currentEpoch !== null && this.currentEpoch !== epochIndex) { + // Drop tickets for older epochs (can happen when a delayed validation callback completes + // after the epoch has already advanced — accepting it would roll back currentEpoch). + if (this.currentEpoch !== null && epochIndex < this.currentEpoch) { + logger.warn`[addTicket] Ignoring ticket for old epoch ${epochIndex} (current: ${this.currentEpoch})`; + return; + } + + // Epoch advanced — clear old tickets + if (this.currentEpoch !== null && epochIndex > this.currentEpoch) { logger.log`[addTicket] Epoch changed from ${this.currentEpoch} to ${epochIndex}, clearing ${this.pendingTickets.length} old tickets`; this.pendingTickets = []; // Note: We don't need to clear aux data for all peers here. @@ -159,7 +166,10 @@ export class TicketDistributionTask { logger.trace`Received ticket for epoch ${epochIndex}, attempt ${ticket.attempt}`; if (this.onTicketReceivedCallback !== null) { // Validate first; only redistribute if valid to avoid spreading tampered tickets. - this.onTicketReceivedCallback(epochIndex, ticket) + // Wrap with Promise.resolve().then() to catch both sync throws and async rejections. + const cb = this.onTicketReceivedCallback; + Promise.resolve() + .then(() => cb(epochIndex, ticket)) .then((isValid) => { if (isValid) { this.addTicket(epochIndex, ticket); From a78503a92124a323be0ce836e8aaf4916f4276d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Drwi=C4=99ga?= Date: Thu, 14 May 2026 17:29:08 +0200 Subject: [PATCH 09/11] Fix build. --- package-lock.json | 320 +++++++++--------- .../block-authorship/generator.test.ts | 45 ++- 2 files changed, 203 insertions(+), 162 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd072cd08..31f4db904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1674,58 +1674,58 @@ } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.67.0.tgz", - "integrity": "sha512-dE0/otVr/2XpOCJ5T82rUww1hdniN0vQZmt7JxlGQTe6h3bvKWfFnkBgKASzXRUqD5VAtcC3W8YeOsLPJ3OspA==", + "version": "0.67.3", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.67.3.tgz", + "integrity": "sha512-sRzw/T1JU7CCATGxnnKhHbWMlwMH1qO62+4/znfsJTg24ATP5qNKFkt8B/JD7HAQ/0ceMeyQin9KOBnjkLkCvA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/instrumentation-amqplib": "^0.55.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.60.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.64.0", + "@opentelemetry/instrumentation-amqplib": "^0.56.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.61.1", + "@opentelemetry/instrumentation-aws-sdk": "^0.64.1", "@opentelemetry/instrumentation-bunyan": "^0.54.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.54.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.54.1", "@opentelemetry/instrumentation-connect": "^0.52.0", "@opentelemetry/instrumentation-cucumber": "^0.24.0", - "@opentelemetry/instrumentation-dataloader": "^0.26.0", + "@opentelemetry/instrumentation-dataloader": "^0.26.1", "@opentelemetry/instrumentation-dns": "^0.52.0", - "@opentelemetry/instrumentation-express": "^0.57.0", - "@opentelemetry/instrumentation-fastify": "^0.53.0", + "@opentelemetry/instrumentation-express": "^0.57.1", + "@opentelemetry/instrumentation-fastify": "^0.53.1", "@opentelemetry/instrumentation-fs": "^0.28.0", "@opentelemetry/instrumentation-generic-pool": "^0.52.0", "@opentelemetry/instrumentation-graphql": "^0.56.0", "@opentelemetry/instrumentation-grpc": "^0.208.0", - "@opentelemetry/instrumentation-hapi": "^0.55.0", + "@opentelemetry/instrumentation-hapi": "^0.55.1", "@opentelemetry/instrumentation-http": "^0.208.0", - "@opentelemetry/instrumentation-ioredis": "^0.56.0", - "@opentelemetry/instrumentation-kafkajs": "^0.18.0", - "@opentelemetry/instrumentation-knex": "^0.53.0", - "@opentelemetry/instrumentation-koa": "^0.57.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.53.0", - "@opentelemetry/instrumentation-memcached": "^0.52.0", - "@opentelemetry/instrumentation-mongodb": "^0.61.0", - "@opentelemetry/instrumentation-mongoose": "^0.55.0", - "@opentelemetry/instrumentation-mysql": "^0.54.0", - "@opentelemetry/instrumentation-mysql2": "^0.55.0", + "@opentelemetry/instrumentation-ioredis": "^0.57.0", + "@opentelemetry/instrumentation-kafkajs": "^0.18.1", + "@opentelemetry/instrumentation-knex": "^0.53.1", + "@opentelemetry/instrumentation-koa": "^0.57.1", + "@opentelemetry/instrumentation-lru-memoizer": "^0.53.1", + "@opentelemetry/instrumentation-memcached": "^0.52.1", + "@opentelemetry/instrumentation-mongodb": "^0.62.0", + "@opentelemetry/instrumentation-mongoose": "^0.55.1", + "@opentelemetry/instrumentation-mysql": "^0.55.0", + "@opentelemetry/instrumentation-mysql2": "^0.55.1", "@opentelemetry/instrumentation-nestjs-core": "^0.55.0", - "@opentelemetry/instrumentation-net": "^0.52.0", - "@opentelemetry/instrumentation-openai": "^0.6.0", - "@opentelemetry/instrumentation-oracledb": "^0.34.0", - "@opentelemetry/instrumentation-pg": "^0.61.0", - "@opentelemetry/instrumentation-pino": "^0.55.0", - "@opentelemetry/instrumentation-redis": "^0.57.0", + "@opentelemetry/instrumentation-net": "^0.53.0", + "@opentelemetry/instrumentation-openai": "^0.7.1", + "@opentelemetry/instrumentation-oracledb": "^0.34.1", + "@opentelemetry/instrumentation-pg": "^0.61.2", + "@opentelemetry/instrumentation-pino": "^0.55.1", + "@opentelemetry/instrumentation-redis": "^0.57.2", "@opentelemetry/instrumentation-restify": "^0.54.0", "@opentelemetry/instrumentation-router": "^0.53.0", "@opentelemetry/instrumentation-runtime-node": "^0.22.0", - "@opentelemetry/instrumentation-socket.io": "^0.55.0", - "@opentelemetry/instrumentation-tedious": "^0.27.0", + "@opentelemetry/instrumentation-socket.io": "^0.55.1", + "@opentelemetry/instrumentation-tedious": "^0.28.0", "@opentelemetry/instrumentation-undici": "^0.19.0", "@opentelemetry/instrumentation-winston": "^0.53.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.31.11", - "@opentelemetry/resource-detector-aws": "^2.8.0", - "@opentelemetry/resource-detector-azure": "^0.16.0", - "@opentelemetry/resource-detector-container": "^0.7.11", - "@opentelemetry/resource-detector-gcp": "^0.43.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.32.0", + "@opentelemetry/resource-detector-aws": "^2.9.0", + "@opentelemetry/resource-detector-azure": "^0.17.0", + "@opentelemetry/resource-detector-container": "^0.8.0", + "@opentelemetry/resource-detector-gcp": "^0.44.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-node": "^0.208.0" }, @@ -1997,13 +1997,14 @@ } }, "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.55.0.tgz", - "integrity": "sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.56.0.tgz", + "integrity": "sha512-/orV2zO2K7iGa1TR6lbs170LNNDbeTC6E3JF1EeB+okJ3rB5tl1gHFSjoqEDkQYFprNs5CPitqU8Y4l4S2Pkmg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.208.0" + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -2013,9 +2014,9 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-lambda": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.60.0.tgz", - "integrity": "sha512-8S0kBnVy/GdYy0/dBhdZglIMhbv2OYiTcv2uPdAog2p+ndF+XpsR+J3fXnksYybE1SFV6TLkkEVJrWIU2E2zBg==", + "version": "0.61.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.61.1.tgz", + "integrity": "sha512-leISmqN7/KSCYAKEVOAnQ0NUCa3rigB7ShCVLnYrHr6+7CXPef7C+nvowElMcYTid8egiHKgApR/FaNdlBda3A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2030,9 +2031,9 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.64.0.tgz", - "integrity": "sha512-8+Y8IcUfME5jD03LISBcd9sFipgOon2uAoiLKSCroiGD6MPuwMzqlVvhlKSzq7uxwtZIhR6CTmjCpLsCHum59A==", + "version": "0.64.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.64.1.tgz", + "integrity": "sha512-A8joPAuHwvwrkG5UpH7OYhzkeYznNBiG3o1TKoZ7yvyXU/q4CNxnZ7vzZBEpt9OocptCe6X/YyBENFSa0axqiw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2064,9 +2065,9 @@ } }, "node_modules/@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.54.0.tgz", - "integrity": "sha512-nOjBx4EZVMaAE3pfM1DBOwoxtskZyzLfsqVAmrrCyBgULyJ7pfF5T1S/08u4v/ba61vOihk32WclyYEKnWmx6A==", + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.54.1.tgz", + "integrity": "sha512-wVGI4YrWmaNNtNjg84KTl8sHebG7jm3PHvmZxPl2V/aSskAyQMSxgJZpnv1dmBmJuISc+a8H8daporljbscCcQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0" @@ -2113,9 +2114,9 @@ } }, "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.0.tgz", - "integrity": "sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA==", + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.1.tgz", + "integrity": "sha512-S2JAM6lV16tMravuPLd3tJCC6ySb5a//5KgJeXutbTVb/UbSTXcnHSdEtMaAvE2KbazVWyWzcoytLRy6AUOwsw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0" @@ -2143,9 +2144,9 @@ } }, "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.0.tgz", - "integrity": "sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg==", + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.1.tgz", + "integrity": "sha512-r+ulPbvgG8rGgFFWbJWJpTh7nMzsEYH7rBFNWdFs7ZfVAtgpFijMkRtU7DecIo6ItF8Op+RxogSuk/083W8HKw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2160,9 +2161,9 @@ } }, "node_modules/@opentelemetry/instrumentation-fastify": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.53.0.tgz", - "integrity": "sha512-bNsoCpe/cmrLWH6T4FEkFx403mW40PWtpYCraadbncQVE9UOeQOYdI3+J5UbciiyR92d1MFVF9HLAv8zA/yXzA==", + "version": "0.53.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.53.1.tgz", + "integrity": "sha512-tTa84J9rcrl4iTHdJDwirrNbM4prgJH+MF0iMlVLu++6gZg8TTfmYYqDiKPWBgdXB4M+bnlCkvgag36uV34uwA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2239,9 +2240,9 @@ } }, "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.0.tgz", - "integrity": "sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.1.tgz", + "integrity": "sha512-Pm1HCHnnijUOGXd+nyJp96CfU8Lb6XdT6H6YvvmXO/NHMb6tV+EjzDRBr9sZ/XQjka9zLCz7jR0js7ut0IJAyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2274,13 +2275,14 @@ } }, "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.56.0.tgz", - "integrity": "sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg==", + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.57.0.tgz", + "integrity": "sha512-o/PYGPbfFbS0Sq8EEQC8YUgDMiTGvwoMejPjV2d466yJoii+BUpffGejVQN0hC5V5/GT29m1B1jL+3yruNxwDw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/redis-common": "^0.38.2" + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -2290,9 +2292,9 @@ } }, "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.0.tgz", - "integrity": "sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.1.tgz", + "integrity": "sha512-qM9hk7BIsVWqWJsrCa1fAEcEfutVvwhHO9kk4vpwaTGYR+lPWRk2r5+nEPcM+sIiYBmQNJCef5tEjQpKxTpP0A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2306,9 +2308,9 @@ } }, "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.0.tgz", - "integrity": "sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g==", + "version": "0.53.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.1.tgz", + "integrity": "sha512-tIW3gqVC8d9CCE+oxPO63WNvC+5PKC/LrPrYWFobii5afUpHJV+0pfyt08okAFBHztzT0voMOEPGkLKoacZRXQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2322,9 +2324,9 @@ } }, "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.0.tgz", - "integrity": "sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg==", + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.1.tgz", + "integrity": "sha512-XPjdzgXvMG3YSZvsSgOj0Je0fsmlaBYIFFGJqUn1HRpbrVjdpP45eXI+6yUp48J8N5Qss32WDD5f+2tmV7Xvsg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2339,9 +2341,9 @@ } }, "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.0.tgz", - "integrity": "sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw==", + "version": "0.53.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.1.tgz", + "integrity": "sha512-L93bPJKFzrObD4FvKpsavYEFTzXFKMmAeRHz7J4lUFc7TPZLouxX3PYW1+YGr/bT1y24H9NLNX66l7BW1s75QA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0" @@ -2354,9 +2356,9 @@ } }, "node_modules/@opentelemetry/instrumentation-memcached": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.52.0.tgz", - "integrity": "sha512-aBeEX0vLXwaXx96jQsrS6GAshzp5Kj027M/a0UQj7YzAOZXAa3ZJ65gryHoFlFmMgi3UAfThWIhahajG1FuQTQ==", + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.52.1.tgz", + "integrity": "sha512-qg92SyWAypSZmX3Lhm2wz4BsovKarkWg9OHm4DPW6fGzmk40eB5voQIuctrBAfsml6gr+vbg4VEBcC1AKRvzzQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2371,9 +2373,9 @@ } }, "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.61.0.tgz", - "integrity": "sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ==", + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.62.0.tgz", + "integrity": "sha512-hcEEW26ToGVpQGblXk9m3p2cXkBu9j2bcyeevS/ahujr1WodfrItmMldWCEJkmN4+4uMo9pb6jAMhm6bZIMnig==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0" @@ -2386,9 +2388,9 @@ } }, "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.0.tgz", - "integrity": "sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.1.tgz", + "integrity": "sha512-M2MusLn/31YOt176Y6qXJQcpDuZPmq/fqQ9vIaKb4x/qIJ3oYO2lT45SUMFmZpODEhrpYXgGaEKwG6TGXhlosA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2402,12 +2404,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.54.0.tgz", - "integrity": "sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A==", + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.55.0.tgz", + "integrity": "sha512-tEGaVMzqAlwhoDomaUWOP2H4KkK16m18qq+TZoyvcSe9O21UxnYFWQa87a4kmc7N4Q6Q70L/YhwDt+fC+NDRBA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0", "@types/mysql": "2.15.27" }, "engines": { @@ -2418,9 +2421,9 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.0.tgz", - "integrity": "sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.1.tgz", + "integrity": "sha512-/cw7TzEmeaCQ5xi+FwrCWQUlY8v9RXjN5tqtb0D1sgBedfiV6DvW+dlMl1jo6Nkx9eSHmGcDy9IjyR0frHpKLg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2451,12 +2454,13 @@ } }, "node_modules/@opentelemetry/instrumentation-net": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.52.0.tgz", - "integrity": "sha512-POT0FudTQTsTs9Xa8Uo5z0gGV1T3EEvy3GNas4Lr5aIMxe5xz/XlHci8xNZ/lzwjTY7KfYsXvkzxRBovDVtH5Q==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.53.0.tgz", + "integrity": "sha512-d8tU5z0fx28z622RwVwU4zfNt40EKxzEcQcaPzch/CqpkKNAlvBOW/1K9OkjNdydpsKqxpMkbjvo3tY6PD1EMA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.208.0" + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -2466,9 +2470,9 @@ } }, "node_modules/@opentelemetry/instrumentation-openai": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.6.0.tgz", - "integrity": "sha512-FmmAi+gwuAyoWkxuyqpFlBCi7YG36L3HLnLVE0gf2iZV+AQlVQgd9kP8SxywSPV1L9BqTwJJ8mDIUiIBp9zSAQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.7.1.tgz", + "integrity": "sha512-QDnnAYxByJoJ3jMly/EwRbXhnfZpGigfBcHyPcgWEMR4bfawJZhdOdFi1GVcC4ImdS7fGaYQOTX1WW24mftISg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "^0.208.0", @@ -2483,9 +2487,9 @@ } }, "node_modules/@opentelemetry/instrumentation-oracledb": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-oracledb/-/instrumentation-oracledb-0.34.0.tgz", - "integrity": "sha512-eHNRO4mKgvFfPfSi+Y2GNrWl+YOOnnhVoII9vlCcAroEJ0i/IC6sBsDm18LKYXnRjz1zNnX31Sn0a00S1rKaNA==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-oracledb/-/instrumentation-oracledb-0.34.1.tgz", + "integrity": "sha512-RI5EV3ZIkHA748dPm4hwLkUnqYU/rrBcq+qA5cNks0dZaAgsu46XMA/MEPcubrBSsgaG1NAIG78P9NLZs2gN/A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2500,9 +2504,9 @@ } }, "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.0.tgz", - "integrity": "sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw==", + "version": "0.61.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.2.tgz", + "integrity": "sha512-l1tN4dX8Ig1bKzMu81Q1EBXWFRy9wqchXbeHDRniJsXYND5dC8u1Uhah7wz1zZta3fbBWflP2mJZcDPWNsAMRg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2520,9 +2524,9 @@ } }, "node_modules/@opentelemetry/instrumentation-pino": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.55.0.tgz", - "integrity": "sha512-+powYgQcZvGD/JJ0zaXB/2e2rK/WS41GDAq4KlKv26gT5rjWc70Pxvk2OP0d/XAWlLxpRAxOEAP0ggVAuVYNbA==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.55.1.tgz", + "integrity": "sha512-rt35H5vvP9KA1xrMrJGsnqwcVxyt8dher04pR64gvX4rxLwsmijUF1cEMbPZ2O8jXpeV8nAIzGHBnWEYp5ILNA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "^0.208.0", @@ -2537,9 +2541,9 @@ } }, "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.0.tgz", - "integrity": "sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.2.tgz", + "integrity": "sha512-vD1nzOUDOPjnvDCny7fmRSX/hMTFzPUCZKADF5tQ5DvBqlOEV/de/tOkwvIwo9YX956EBMT+8qSjhd7qPXFkRw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", @@ -2602,9 +2606,9 @@ } }, "node_modules/@opentelemetry/instrumentation-socket.io": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.55.0.tgz", - "integrity": "sha512-j/ceXFREnYKIO5+qBPlbigiMYnYhyEz9y8hkWSzMIUA6lnirdEf/viGI+q1VpjqB/Fl87X4ejWl+taQGBYIB+A==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.55.1.tgz", + "integrity": "sha512-KQaOvZlw7NpA/VDRJdm45FIdzt4hXrDhvtmLU5a2AttcTI9e/VpVg4Y/LPOXM+o29VkKxETZzJfRlOJEIHl+uQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0" @@ -2617,12 +2621,13 @@ } }, "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.27.0.tgz", - "integrity": "sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.28.0.tgz", + "integrity": "sha512-nQ9k1Bdk2yG4SPRuHZ+QVcc3YMm2sfsBV1MQIc/Y/OcN83Q+jA7gXgYgYIblQ1wI+/RtKlJpdl6hobAXuj+pSA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0", "@types/tedious": "^4.0.14" }, "engines": { @@ -2751,18 +2756,18 @@ } }, "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz", + "integrity": "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" } }, "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.31.11", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.31.11.tgz", - "integrity": "sha512-R/asn6dAOWMfkLeEwqHCUz0cNbb9oiHVyd11iwlypeT/p9bR1lCX5juu5g/trOwxo62dbuFcDbBdKCJd3O2Edg==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.32.0.tgz", + "integrity": "sha512-W+n4ZIbNndOaW6xlGeW7afKQeCMNlOHsSflGRLkzjnSfAl2tWJU5Mhr6hf/t6m8uhic71psHx/CoFQMsRQKnvQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2776,9 +2781,9 @@ } }, "node_modules/@opentelemetry/resource-detector-aws": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-2.8.0.tgz", - "integrity": "sha512-L8K5L3bsDKboX7sDofZyRonyK8dfS+CF7ho8YbZ6OrH+d5uyRBsrjuokPzcju1jP2ZzgtpYzhLwzi9zPXyRLlA==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-2.18.0.tgz", + "integrity": "sha512-wyMM4UoRuHvI2KjqnTzvyW8Yv7MKRGA+I78Xti6gTEw7hBhqXU1SRo+f9KrsQfeeiOn+TkDuvxavuaAQbD3i6g==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2793,9 +2798,9 @@ } }, "node_modules/@opentelemetry/resource-detector-azure": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.16.0.tgz", - "integrity": "sha512-7ZIgPGsI5/sp4nXXUUyyQ8grg6brJV1U/itQWmZID72Nhvm4k/MhYpjZC80HFId47pMUGkoM3wxbZHfunLSnIw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.17.0.tgz", + "integrity": "sha512-JGNPW+Om8MNiVOK1Jl5jg3znGJQP7YeGsgRQiegftqEZj0th8e1Uf6U5s6H672KBT442WDGOG0a4du5xJgJB5w==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2810,9 +2815,9 @@ } }, "node_modules/@opentelemetry/resource-detector-container": { - "version": "0.7.11", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.7.11.tgz", - "integrity": "sha512-XUxnGuANa/EdxagipWMXKYFC7KURwed9/V0+NtYjFmwWHzV9/J4IYVGTK8cWDpyUvAQf/vE4sMa3rnS025ivXQ==", + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.9.tgz", + "integrity": "sha512-Xd2C4HjW9hl75iqZT7tQNy2yRBUqNucq2O9+e0FJRNkbiItInYVMzc0S0KDXcx/vZBwNmlrKS3R0uLCU9ULsGA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -2826,9 +2831,9 @@ } }, "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.43.0.tgz", - "integrity": "sha512-QBrljIppRyMLjEJdx+nKid5FyCQCh4TK2jNSHVRsJio1qnPoPy18J6rD3Pbx6VF0/Z5vwLD+E3PHe/Bi6vE0Rw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.44.0.tgz", + "integrity": "sha512-sj9WSSjMyZJDP7DSmfQpsfivM2sQECwhjAmK6V97uVAeJiXSMiPhfo3fZi0Hpu+GQQ1Wb09qQIkwkMjwr0MH/g==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^2.0.0", @@ -3171,9 +3176,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -3199,9 +3204,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -3217,9 +3222,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -3905,9 +3910,9 @@ "link": true }, "node_modules/@types/aws-lambda": { - "version": "8.10.159", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", - "integrity": "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==", + "version": "8.10.161", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", + "integrity": "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==", "license": "MIT" }, "node_modules/@types/blake2b": { @@ -7177,9 +7182,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "license": "MIT" }, "node_modules/pg-types": { @@ -7237,9 +7242,9 @@ } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7277,22 +7282,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -8263,6 +8268,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/packages/workers/block-authorship/generator.test.ts b/packages/workers/block-authorship/generator.test.ts index 75fd5a2b9..f3965016a 100644 --- a/packages/workers/block-authorship/generator.test.ts +++ b/packages/workers/block-authorship/generator.test.ts @@ -237,7 +237,14 @@ describe("Generator", () => { const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); const statesDb = createMockStatesDb(state); - const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + const generator = Generator.new({ + chainSpec: tinyChainSpec, + bandersnatch, + keccakHasher, + blake2b, + blocks: blocksDb, + states: statesDb, + }); // Create two tickets with different signatures const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); @@ -283,7 +290,14 @@ describe("Generator", () => { const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); const statesDb = createMockStatesDb(state); - const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + const generator = Generator.new({ + chainSpec: tinyChainSpec, + bandersnatch, + keccakHasher, + blake2b, + blocks: blocksDb, + states: statesDb, + }); const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); const ticket1 = SignedTicket.create({ @@ -320,7 +334,14 @@ describe("Generator", () => { const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); const statesDb = createMockStatesDb(state); - const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + const generator = Generator.new({ + chainSpec: tinyChainSpec, + bandersnatch, + keccakHasher, + blake2b, + blocks: blocksDb, + states: statesDb, + }); const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); sig1.raw[0] = 1; @@ -359,7 +380,14 @@ describe("Generator", () => { const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); const statesDb = createMockStatesDb(state); - const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + const generator = Generator.new({ + chainSpec: tinyChainSpec, + bandersnatch, + keccakHasher, + blake2b, + blocks: blocksDb, + states: statesDb, + }); const sig1 = Bytes.zero(BANDERSNATCH_PROOF_BYTES); sig1.raw[0] = 1; @@ -397,7 +425,14 @@ describe("Generator", () => { const blocksDb = createMockBlocksDb(MOCK_PARENT_HASH); const statesDb = createMockStatesDb(state); - const generator = new Generator(tinyChainSpec, bandersnatch, keccakHasher, blake2b, blocksDb, statesDb); + const generator = Generator.new({ + chainSpec: tinyChainSpec, + bandersnatch, + keccakHasher, + blake2b, + blocks: blocksDb, + states: statesDb, + }); // Create 4 tickets — only 3 should be included (lowest IDs win) const makeTicket = (sigByte: number, idByte: number) => { From 1bd80ebb9a4f7907f9af51ffe3d726d4ae812136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Drwi=C4=99ga?= Date: Fri, 15 May 2026 11:55:25 +0200 Subject: [PATCH 10/11] Apply review suggestions --- .../jam/jamnp-s/tasks/ticket-distribution.ts | 1 - packages/workers/block-authorship/main.ts | 104 ++++++++---------- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/packages/jam/jamnp-s/tasks/ticket-distribution.ts b/packages/jam/jamnp-s/tasks/ticket-distribution.ts index da9621072..ef809dc28 100644 --- a/packages/jam/jamnp-s/tasks/ticket-distribution.ts +++ b/packages/jam/jamnp-s/tasks/ticket-distribution.ts @@ -116,7 +116,6 @@ export class TicketDistributionTask { // Drop tickets for older epochs (can happen when a delayed validation callback completes // after the epoch has already advanced — accepting it would roll back currentEpoch). if (this.currentEpoch !== null && epochIndex < this.currentEpoch) { - logger.warn`[addTicket] Ignoring ticket for old epoch ${epochIndex} (current: ${this.currentEpoch})`; return; } diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index 19ef0d491..4196f9cb4 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -10,6 +10,7 @@ import { } from "@typeberry/block"; import type { SignedTicket } from "@typeberry/block/tickets.js"; import { BytesBlob } from "@typeberry/bytes"; +import { HashDictionary } from "@typeberry/collections/hash-dictionary.js"; import { HashSet } from "@typeberry/collections/hash-set.js"; import type { NetworkingComms } from "@typeberry/comms-authorship-network"; import { type BandersnatchKey, type Ed25519Key, initWasm } from "@typeberry/crypto"; @@ -51,6 +52,12 @@ type ValidatorPublicKeys = { ed25519Public: Ed25519Key; }; +type SealData = { + key: ValidatorKeys; + sealPayload: BlockSealInput; + logId?: string; +}; + type ValidatorKeys = ValidatorPrivateKeys & ValidatorPublicKeys; export async function main(config: Config, comms: GeneratorInternal, networkingComms: NetworkingComms) { @@ -99,13 +106,15 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC logger.info`Block authorship validator keys: ${keys.map(({ bandersnatchPublic }, index) => `\n ${index}: ${bandersnatchPublic.toString()}`)}`; if (initialState !== null) { - const initialKeys = await getSealingKeySeries( - startTimeSlot % chainSpec.epochLength === 0, - startTimeSlot, - initialState, - ); + const isEpochStart = startTimeSlot % chainSpec.epochLength === 0; + const initialKeys = await getSealingKeySeries(isEpochStart, startTimeSlot, initialState); if (initialKeys.isOk) { logEpochBlockCreation(tryAsEpoch(Math.floor(startTimeSlot / chainSpec.epochLength)), initialKeys.ok); + // Build the cache eagerly so the first slot of a session doesn't need an + // on-the-fly VRF scan. After this, `buildTicketAuthorshipCache` is only + // re-run on epoch boundaries. + const initialEntropy = isEpochStart ? initialState.entropy[2] : initialState.entropy[3]; + await buildTicketAuthorshipCache(initialKeys.ok, initialEntropy); } } @@ -127,52 +136,55 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC // Per-epoch cache for Tickets mode: index corresponds to position in sealingKeySeries.tickets. // null entry means none of our keys match that slot. // Rebuilt once per epoch via buildTicketAuthorshipCache(). - let ticketAuthorshipCache: Array<{ key: ValidatorKeys; sealPayload: BlockSealInput } | null> | null = null; + let ticketAuthorshipCache: Array | null = null; /** * Precomputes which slots we are the author of for the current epoch (Tickets mode). - * - * Iterates over every ticket in sealingKeySeries once and runs getVrfOutputHash for - * each of our keys. Stores the result indexed by ticket position so getAuthorInfo - * can do a O(1) array lookup per slot instead of O(keys) VRF calls. - * - * Called once at the start of each epoch when isNewEpoch = true. */ async function buildTicketAuthorshipCache(sealingKeySeries: SafroleSealingKeys, entropy: EntropyHash) { if (sealingKeySeries.kind !== SafroleSealingKeysKind.Tickets) { ticketAuthorshipCache = null; return; } - const cache: Array<{ key: ValidatorKeys; sealPayload: BlockSealInput } | null> = []; - for (const ticket of sealingKeySeries.tickets) { - const payload = BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([ticket.attempt])); - let found: { key: ValidatorKeys; sealPayload: BlockSealInput } | null = null; + + const ownTickets = new HashDictionary(); + for (let attempt = 0; attempt < chainSpec.ticketsPerValidator; attempt++) { + const payload = getTicketSealPayload(entropy, attempt); for (const key of keys) { const result = await bandersnatchVrf.getVrfOutputHash(bandersnatch, key.bandersnatchSecret, payload); - if (result.isOk && ticket.id.isEqualTo(result.ok)) { - found = { key, sealPayload: asOpaqueType(payload) }; - break; + if (result.isOk) { + ownTickets.set(result.ok.asOpaque(), { key, sealPayload: asOpaqueType(payload) }); } } - cache.push(found); } + + const cache = sealingKeySeries.tickets.map((ticket) => ownTickets.get(ticket.id.asOpaque()) ?? null); ticketAuthorshipCache = cache; const ours = cache.filter(Boolean).length; logger.info`Built ticket authorship cache: ${ours}/${cache.length} slots assigned to us this epoch.`; } + function getTicketSealPayload(entropy: EntropyHash, attempt: number): BytesBlob { + return BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([attempt])); + } + + function getFallbackSealPayload(entropy: EntropyHash): BlockSealInput { + return asOpaqueType(BytesBlob.blobFromParts(JAM_FALLBACK_SEAL, entropy.raw)); + } + /** * Returns the validator key and seal payload for the current slot, or null if we are not the author. * * Keys mode (fallback): matches our key against the slot's assigned bandersnatch key. - * Tickets mode: uses precomputed cache (built once per epoch) for O(1) lookup per slot. + * Tickets mode: O(1) lookup against the per-epoch authorship cache (built eagerly at + * startup and on every epoch transition, so we never fall back to on-the-fly VRF). */ - async function getAuthorInfo( + function getSealData( sealingKeySeries: SafroleSealingKeys, keys: ValidatorKeys[], timeSlot: TimeSlot, entropy: EntropyHash, - ): Promise<{ key: ValidatorKeys; sealPayload: BlockSealInput; logId: string } | null> { + ): SealData | null { if (sealingKeySeries.kind === SafroleSealingKeysKind.Keys) { const indexForCurrentSlot = timeSlot % sealingKeySeries.keys.length; const sealingKey = sealingKeySeries.keys[indexForCurrentSlot]; @@ -180,9 +192,10 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC if (key === null) { return null; } + return { key, - sealPayload: asOpaqueType(BytesBlob.blobFromParts(JAM_FALLBACK_SEAL, entropy.raw)), + sealPayload: getFallbackSealPayload(entropy), logId: `key ${key.bandersnatchPublic}`, }; } @@ -190,33 +203,12 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC // Tickets mode: each slot is sealed by the validator who can produce the VRF output // matching the ticket's ID for that slot. const index = timeSlot % sealingKeySeries.tickets.length; - const ticket = sealingKeySeries.tickets.at(index); - if (ticket === undefined) { + const ticket = sealingKeySeries.tickets.at(index) ?? null; + const cached = ticketAuthorshipCache?.at(index) ?? null; + if (ticket === null || cached === null) { return null; } - - // Fast path: use precomputed cache (available after first isNewEpoch iteration) - if (ticketAuthorshipCache !== null) { - const cached = ticketAuthorshipCache[index] ?? null; - if (cached === null) { - return null; - } - return { ...cached, logId: `ticket ${ticket.id} (attempt ${ticket.attempt})` }; - } - - // Slow path: compute VRF on the fly (first slot of epoch, before cache is ready) - const payload = BytesBlob.blobFromParts(JAM_TICKET_SEAL, entropy.raw, new Uint8Array([ticket.attempt])); - for (const key of keys) { - const result = await bandersnatchVrf.getVrfOutputHash(bandersnatch, key.bandersnatchSecret, payload); - if (result.isOk && ticket.id.isEqualTo(result.ok)) { - return { - key, - sealPayload: asOpaqueType(payload), - logId: `ticket ${ticket.id} (attempt ${ticket.attempt})`, - }; - } - } - return null; + return { ...cached, logId: `ticket ${ticket.id} (attempt ${ticket.attempt})` }; } function isEpochChanged(lastTimeslot: TimeSlot, currentTimeslot: TimeSlot): boolean { @@ -280,8 +272,8 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC ticketIdSets.clear(); } const existing = ticketPool.get(epochIndex) ?? []; - let idSet = ticketIdSets.get(epochIndex); - if (idSet === undefined) { + let idSet = ticketIdSets.get(epochIndex) ?? null; + if (idSet === null) { idSet = HashSet.new(); ticketIdSets.set(epochIndex, idSet); } @@ -353,7 +345,7 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC while (!isFinished) { const hash = blocks.getBestHeaderHash(); const state = states.getState(hash); - const currentValidatorData = state?.currentValidatorData; + const currentValidatorData = state?.currentValidatorData ?? null; if (state === null) { continue; @@ -439,16 +431,16 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC } const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; - const authorInfo = await getAuthorInfo(selingKeySeriesResult.ok, keys, timeSlot, entropy); + const sealData = getSealData(selingKeySeriesResult.ok, keys, timeSlot, entropy); - if (authorInfo !== null && currentValidatorData !== undefined) { - const { key, sealPayload } = authorInfo; + if (sealData !== null && currentValidatorData !== null) { + const { key, sealPayload } = sealData; const validatorIndex = getValidatorIndex(key, currentValidatorData); if (validatorIndex === null) { continue; } - logger.log`Attempting to create a block using ${authorInfo.logId} located at validator index ${validatorIndex}.`; + logger.log`Attempting to create a block using ${sealData.logId} located at validator index ${validatorIndex}.`; const currentEpochTickets = ticketPool.get(epoch) ?? []; const newBlock = await generator.nextBlockView( validatorIndex, From 2aff3c98afe96300f40cdc8a7b28850a484b5bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Drwi=C4=99ga?= Date: Fri, 15 May 2026 17:58:17 +0200 Subject: [PATCH 11/11] Fix E2E issues --- packages/workers/block-authorship/main.ts | 36 +++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/workers/block-authorship/main.ts b/packages/workers/block-authorship/main.ts index 4196f9cb4..413f4a3d8 100644 --- a/packages/workers/block-authorship/main.ts +++ b/packages/workers/block-authorship/main.ts @@ -105,6 +105,14 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC const initialState = states.getState(initialHash); logger.info`Block authorship validator keys: ${keys.map(({ bandersnatchPublic }, index) => `\n ${index}: ${bandersnatchPublic.toString()}`)}`; + + // Per-epoch cache for Tickets mode: index corresponds to position in sealingKeySeries.tickets. + // null entry means none of our keys match that slot. + // Rebuilt once per epoch via buildTicketAuthorshipCache(). + // Declared here (before the eager startup build below) so its TDZ doesn't fire + // when `buildTicketAuthorshipCache` runs during initialisation. + let ticketAuthorshipCache: Array | null = null; + if (initialState !== null) { const isEpochStart = startTimeSlot % chainSpec.epochLength === 0; const initialKeys = await getSealingKeySeries(isEpochStart, startTimeSlot, initialState); @@ -133,11 +141,6 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC return tryAsValidatorIndex(index); } - // Per-epoch cache for Tickets mode: index corresponds to position in sealingKeySeries.tickets. - // null entry means none of our keys match that slot. - // Rebuilt once per epoch via buildTicketAuthorshipCache(). - let ticketAuthorshipCache: Array | null = null; - /** * Precomputes which slots we are the author of for the current epoch (Tickets mode). */ @@ -422,15 +425,24 @@ export async function main(config: Config, comms: GeneratorInternal, networkingC continue; } - if (isNewEpoch) { - logEpochBlockCreation(epoch, selingKeySeriesResult.ok); - // Build authorship cache for Tickets mode once per epoch. - // entropy[2] here is the epoch-E entropy (pre-transition state), same value - // that will be at entropy[3] after the transition block is applied. - await buildTicketAuthorshipCache(selingKeySeriesResult.ok, state.entropy[2]); + // On a new epoch, `state.entropy[2]` is the epoch-E entropy (pre-transition); + // mid-epoch, it has already shifted to `entropy[3]`. + const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; + + // Rebuild the authorship cache on each epoch boundary, and also catch the case + // where the startup prebuild was skipped (e.g. initialState was null or the + // initial sealing-key transition errored) so we don't silently miss Tickets-mode + // slots until the next epoch boundary. + const needsCacheRebuild = + isNewEpoch || + (selingKeySeriesResult.ok.kind === SafroleSealingKeysKind.Tickets && ticketAuthorshipCache === null); + if (needsCacheRebuild) { + if (isNewEpoch) { + logEpochBlockCreation(epoch, selingKeySeriesResult.ok); + } + await buildTicketAuthorshipCache(selingKeySeriesResult.ok, entropy); } - const entropy = isNewEpoch ? state.entropy[2] : state.entropy[3]; const sealData = getSealData(selingKeySeriesResult.ok, keys, timeSlot, entropy); if (sealData !== null && currentValidatorData !== null) {