From 5319209cb27a8fe1dfb827e1828c72e564ebaa1c Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 11:57:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(block-time):=20=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20on-chain=20timestamp=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=B7=20signed=5Fblock=20header,=20=D0=BD=D0=B5=20wal?= =?UTF-8?q?l-clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block_time во всех событиях равнялся времени запуска парсера (new Date()), одинаковому для всех блоков — point-in-time запросы («был ли ключ активен в момент T») были невозможны. ship-reader: decodeBlocksResult теперь декодит r.block (fetch_block:true уже запрашивал, но байты игнорились) как block_header и берёт timestamp; ShipBlock получает блок-уровневое поле blockTime; убран new Date()-сид в BlockStream. parser2: BlockProcessor берёт block.blockTime — общий для всех событий блока, включая trace-less genesis; фейковый new Date()-фолбэк убран, пустое время логируется warn'ом. ship-reader 0.3.0→0.3.1, parser2 1.1.1→1.1.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/parser2/package.json | 2 +- packages/parser2/src/core/BlockProcessor.ts | 9 ++- .../parser2/test/unit/blockProcessor.test.ts | 8 +++ packages/ship-reader/package.json | 2 +- packages/ship-reader/src/BlockStream.ts | 3 +- packages/ship-reader/src/ShipProtocol.ts | 21 +++++- packages/ship-reader/src/types/ship.ts | 2 + .../test/unit/shipProtocol.test.ts | 68 ++++++++++++++++--- 8 files changed, 96 insertions(+), 19 deletions(-) diff --git a/packages/parser2/package.json b/packages/parser2/package.json index 10863e2..04192c6 100644 --- a/packages/parser2/package.json +++ b/packages/parser2/package.json @@ -1,6 +1,6 @@ { "name": "@coopenomics/parser2", - "version": "1.1.1", + "version": "1.1.2", "description": "Universal EOSIO/Antelope SHiP-to-Redis blockchain indexer (parser)", "license": "MIT", "author": "Coopenomics contributors", diff --git a/packages/parser2/src/core/BlockProcessor.ts b/packages/parser2/src/core/BlockProcessor.ts index a925338..2e1650b 100644 --- a/packages/parser2/src/core/BlockProcessor.ts +++ b/packages/parser2/src/core/BlockProcessor.ts @@ -97,8 +97,13 @@ export class BlockProcessor { const blockNum = block.thisBlock.blockNum const blockId = block.thisBlock.blockId - // blockTime берём из первой трассировки; если трассировок нет — текущее время - const blockTime = block.traces[0]?.blockTime ?? new Date().toISOString() + // block_time — on-chain время блока (signed_block.timestamp), общее для всех событий + // блока, включая trace-less блоки (genesis). НЕ wall-clock: подставлять new Date() + // нельзя — это ломает point-in-time запросы. Пустое = block не запрошен у SHiP. + const blockTime = block.blockTime + if (!blockTime) { + this.log.warn({ block_num: blockNum }, 'block_time missing from ship block (fetch_block disabled?)') + } // ── Фаза 1: Action traces ───────────────────────────────────────────────── for (const trace of block.traces) { diff --git a/packages/parser2/test/unit/blockProcessor.test.ts b/packages/parser2/test/unit/blockProcessor.test.ts index 6f98c38..1e3b8a3 100644 --- a/packages/parser2/test/unit/blockProcessor.test.ts +++ b/packages/parser2/test/unit/blockProcessor.test.ts @@ -51,6 +51,7 @@ function makeBlock(blockNum = 1, numTraces = 0, numDeltas = 0): ShipBlock { head: blockPosition, lastIrreversible: blockPosition, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: Array.from({ length: numTraces }, (_, i) => ({ account: 'eosio.token', name: 'transfer', @@ -175,6 +176,7 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => { head: { blockNum: 500, blockId: 'c'.repeat(64) }, lastIrreversible: { blockNum: 500, blockId: 'c'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [{ account: 'eosio', name: 'setabi', @@ -214,6 +216,7 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => { head: { blockNum: 501, blockId: 'c'.repeat(64) }, lastIrreversible: { blockNum: 501, blockId: 'c'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [{ account: 'eosio', name: 'setabi', @@ -253,6 +256,7 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => { head: { blockNum: 600, blockId: 'e'.repeat(64) }, lastIrreversible: { blockNum: 600, blockId: 'e'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [], deltas: [{ name: 'account' as never, @@ -283,6 +287,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => { head: { blockNum: 10, blockId: 'a'.repeat(64) }, lastIrreversible: { blockNum: 10, blockId: 'a'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [], deltas: nativeTableNames.map(name => ({ name: name as never, @@ -338,6 +343,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => { head: { blockNum: 20, blockId: 'b'.repeat(64) }, lastIrreversible: { blockNum: 20, blockId: 'b'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [ { account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, creatorActionOrdinal: 0, globalSequence: BigInt(1), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) }, { account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 2, creatorActionOrdinal: 0, globalSequence: BigInt(2), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) }, @@ -370,6 +376,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => { head: { blockNum: 5, blockId: 'd'.repeat(64) }, lastIrreversible: { blockNum: 5, blockId: 'd'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [{ account: 'eosio', name: 'newaccount', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, creatorActionOrdinal: 0, globalSequence: BigInt(10), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 5, blockId: 'd'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'e'.repeat(64) }], deltas: [ { name: 'contract_row', present: true, rowRaw: new Uint8Array([1]), code: 'eosio', scope: 'eosio', table: 'global', primaryKey: '0' }, @@ -390,6 +397,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => { head: { blockNum: 700, blockId: 'f'.repeat(64) }, lastIrreversible: { blockNum: 700, blockId: 'f'.repeat(64) }, prevBlock: null, + blockTime: '2024-06-01T12:00:00.000', traces: [], deltas: [{ name: 'account' as never, present: true, rowRaw: new Uint8Array([1, 2, 3]) }], } diff --git a/packages/ship-reader/package.json b/packages/ship-reader/package.json index f41e9d6..9b87117 100644 --- a/packages/ship-reader/package.json +++ b/packages/ship-reader/package.json @@ -1,6 +1,6 @@ { "name": "@coopenomics/coopos-ship-reader", - "version": "0.3.0", + "version": "0.3.1", "description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains", "license": "MIT", "author": "Coopenomics contributors", diff --git a/packages/ship-reader/src/BlockStream.ts b/packages/ship-reader/src/BlockStream.ts index 9374e54..017461b 100644 --- a/packages/ship-reader/src/BlockStream.ts +++ b/packages/ship-reader/src/BlockStream.ts @@ -79,7 +79,6 @@ export async function* createBlockStream( } let blockNum = opts.startBlock - let blockTime = new Date().toISOString() while (true) { const msg = await nextMessage() @@ -91,7 +90,7 @@ export async function* createBlockStream( const [type, raw] = decodeResult(new Uint8Array(msg), abi) if (type !== 'get_blocks_result_v0') continue - const block = decodeBlocksResult(raw, abi, blockNum, '', blockTime) + const block = decodeBlocksResult(raw, abi, blockNum, '') blockNum = block.thisBlock.blockNum yield block diff --git a/packages/ship-reader/src/ShipProtocol.ts b/packages/ship-reader/src/ShipProtocol.ts index d5d8f31..4ba57c4 100644 --- a/packages/ship-reader/src/ShipProtocol.ts +++ b/packages/ship-reader/src/ShipProtocol.ts @@ -43,6 +43,7 @@ interface RawBlocksResult { last_irreversible: RawBlockPos this_block: RawBlockPos | null prev_block: RawBlockPos | null + block: Bytes | null traces: Bytes | null deltas: Bytes | null } @@ -113,7 +114,7 @@ export function decodeStatusResult(raw: unknown): { chainId: string; head: Block } } -export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number, blockId: string, blockTime: string): ShipBlock { +export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number, blockId: string): ShipBlock { const r = raw as RawBlocksResult // ABI decoder возвращает checksum256 как Checksum256 объект (не строку) — @@ -127,6 +128,22 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number, ? { blockNum: r.prev_block.block_num, blockId: String(r.prev_block.block_id) } : null + // Время блока берём из on-chain header (signed_block.timestamp), а НЕ из wall-clock. + // r.block — optional с сериализованным signed_block, его база — block_header, + // первое поле которого timestamp. Декодим как block_header: читаются только + // header-поля, тело блока (transactions) не десериализуем. + let blockTime = '' + if (r.block && r.block.length > 0) { + try { + const headerDecoded = Serializer.decode({ data: r.block.array, type: 'block_header', abi }) + const header = Serializer.objectify(headerDecoded) as { timestamp?: string } + blockTime = header.timestamp ? String(header.timestamp) : '' + } catch { + // Не смогли распарсить header — оставляем пустым, downstream залогирует. + blockTime = '' + } + } + const txTraces = decodeVector<[string, RawTransactionTrace]>(r.traces, 'transaction_trace', abi) const tableDeltaVariants = decodeVector<[string, RawTableDelta]>(r.deltas, 'table_delta', abi) @@ -228,5 +245,5 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number, } } - return { thisBlock, head, lastIrreversible, prevBlock, traces, deltas } + return { thisBlock, head, lastIrreversible, prevBlock, blockTime, traces, deltas } } diff --git a/packages/ship-reader/src/types/ship.ts b/packages/ship-reader/src/types/ship.ts index b7371d1..94b598a 100644 --- a/packages/ship-reader/src/types/ship.ts +++ b/packages/ship-reader/src/types/ship.ts @@ -62,6 +62,8 @@ export interface ShipBlock { readonly head: BlockPosition readonly lastIrreversible: BlockPosition readonly prevBlock: BlockPosition | null + /** On-chain время блока (signed_block.timestamp), ISO-строка. '' если block не запрошен. */ + readonly blockTime: string readonly traces: readonly ShipTrace[] readonly deltas: readonly ShipDelta[] } diff --git a/packages/ship-reader/test/unit/shipProtocol.test.ts b/packages/ship-reader/test/unit/shipProtocol.test.ts index a259c72..0fd9e31 100644 --- a/packages/ship-reader/test/unit/shipProtocol.test.ts +++ b/packages/ship-reader/test/unit/shipProtocol.test.ts @@ -123,6 +123,13 @@ function makeShipAbi(): ABI { { name: 'name', type: 'string' }, { name: 'rows', type: 'row[]' }, ] }, + // Упрощённый block_header: реальный signed_block начинается с этих байт, + // decodeBlocksResult декодит r.block как 'block_header' и читает timestamp. + // Достаточно первых двух полей — self-consistent encode/decode тем же ABI. + { name: 'block_header', base: '', fields: [ + { name: 'timestamp', type: 'block_timestamp_type' }, + { name: 'producer', type: 'name' }, + ] }, ], actions: [], tables: [], @@ -186,7 +193,7 @@ describe('ShipProtocol — decodeBlocksResult (no traces / no deltas)', () => { traces: null, deltas: null, } - const block = decodeBlocksResult(raw, abi, 100, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 100, 'c'.repeat(64)) expect(block.traces).toHaveLength(0) expect(block.deltas).toHaveLength(0) expect(block.thisBlock.blockNum).toBe(100) @@ -202,7 +209,7 @@ describe('ShipProtocol — decodeBlocksResult (no traces / no deltas)', () => { traces: null, deltas: null, } - const block = decodeBlocksResult(raw, abi, 42, 'fallback-id', '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 42, 'fallback-id') expect(block.thisBlock.blockNum).toBe(42) expect(block.thisBlock.blockId).toBe('fallback-id') expect(block.prevBlock).toBeNull() @@ -219,7 +226,7 @@ describe('ShipProtocol — decodeBlocksResult (no traces / no deltas)', () => { traces: null, deltas: null, } - const block = decodeBlocksResult(raw, abi, 100, 'fallback', '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 100, 'fallback') expect(typeof block.thisBlock.blockId).toBe('string') expect(typeof block.head.blockId).toBe('string') expect(typeof block.lastIrreversible.blockId).toBe('string') @@ -316,7 +323,7 @@ describe('ShipProtocol — decodeBlocksResult (real decoded traces)', () => { deltas: null, } - const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) expect(block.traces).toHaveLength(1) const t = block.traces[0]! @@ -346,7 +353,7 @@ describe('ShipProtocol — decodeBlocksResult (real decoded traces)', () => { traces: tracesBytes, deltas: null, } - const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) const r = block.traces[0]!.receipt! expect(typeof r.receiver).toBe('string') expect(typeof r.actDigest).toBe('string') @@ -367,7 +374,7 @@ describe('ShipProtocol — decodeBlocksResult (real decoded traces)', () => { traces: tracesBytes, deltas: null, } - const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) expect(block.traces[0]!.globalSequence).toBe(42n) }) @@ -382,7 +389,7 @@ describe('ShipProtocol — decodeBlocksResult (real decoded traces)', () => { traces: tracesBytes, deltas: null, } - const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) expect(block.traces[0]!.receipt).toBeNull() // Без receipt и top-level global_sequence — fallback на 0n expect(block.traces[0]!.globalSequence).toBe(0n) @@ -399,7 +406,7 @@ describe('ShipProtocol — decodeBlocksResult (real decoded traces)', () => { traces: tracesBytes, deltas: null, } - const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) expect(block.traces[0]!.actRaw).toBeInstanceOf(Uint8Array) }) }) @@ -435,7 +442,7 @@ describe('ShipProtocol — decodeBlocksResult (deltas)', () => { traces: null, deltas: tableDelta, } - const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64)) expect(block.deltas).toHaveLength(1) const d = block.deltas[0]! expect(d.name).toBe('contract_row') @@ -470,7 +477,7 @@ describe('ShipProtocol — decodeBlocksResult (deltas)', () => { traces: null, deltas: tableDelta, } - const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64)) expect(block.deltas).toHaveLength(1) const d = block.deltas[0]! expect(d.name).toBe('permission') @@ -499,12 +506,51 @@ describe('ShipProtocol — decodeBlocksResult (deltas)', () => { traces: null, deltas: tableDelta, } - const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64), '2024-01-01T00:00:00.000') + const block = decodeBlocksResult(raw, abi, 300, 'c'.repeat(64)) // Некорректная row пропущена без throw expect(block.deltas).toHaveLength(0) }) }) +describe('ShipProtocol — decodeBlocksResult (block_time из header)', () => { + // Регресс-гард на баг #2: block_time брался из wall-clock (new Date()), + // одинаковый для всех блоков. Теперь — из on-chain signed_block.timestamp. + it('extracts on-chain block_time from signed_block header (not wall-clock)', () => { + const abi = makeShipAbi() + const headerBytes = Serializer.encode({ + object: { timestamp: '2026-05-31T18:01:22.000', producer: 'eosio' }, + type: 'block_header', + abi, + }) + const raw = { + head: { block_num: 200, block_id: 'a'.repeat(64) }, + last_irreversible: { block_num: 190, block_id: 'b'.repeat(64) }, + this_block: { block_num: 200, block_id: 'c'.repeat(64) }, + prev_block: null, + block: headerBytes, + traces: null, + deltas: null, + } + const block = decodeBlocksResult(raw, abi, 200, 'c'.repeat(64)) + expect(block.blockTime).toContain('2026-05-31T18:01:22') + }) + + it('block_time is empty when block not fetched (fetch_block disabled)', () => { + const abi = makeShipAbi() + const raw = { + head: { block_num: 100, block_id: 'a'.repeat(64) }, + last_irreversible: { block_num: 90, block_id: 'b'.repeat(64) }, + this_block: { block_num: 100, block_id: 'c'.repeat(64) }, + prev_block: null, + block: null, + traces: null, + deltas: null, + } + const block = decodeBlocksResult(raw, abi, 100, 'c'.repeat(64)) + expect(block.blockTime).toBe('') + }) +}) + describe('ShipProtocol — defence against wharfkit returning typed objects', () => { // Регрессионный тест: если кто-то удалит String() обёртки — эти тесты упадут. it('all trace string fields survive JSON.stringify roundtrip without [object Object]', () => { -- 2.52.0