fix(native-delta): декодировать нативные строки ABI, а не JSON.parse
CI / Typecheck (pull_request) Has been cancelled
CI / Unit tests (pull_request) Has been cancelled
CI / Integration tests (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled

deserializeNativeDelta делал JSON.parse(rowRaw), но rowRaw нативных таблиц —
это ABI-сериализованные байты state_history, а не JSON. Каждая native-строка
всегда падала на десериализации, а BlockProcessor глотал ошибку в catch {} →
поток native-delta событий был всегда пустой (permission/account/resource_*).

Фикс:
- WharfkitDeserializer.deserializeNativeDelta декодит через Serializer.decode
  с ship-ABI (type = имя таблицы, это variant *_v0) и распаковывает variant
  [typeName, row] → плоскую строку.
- Deserializer/streamNativeDeltas/ShipClient прокидывают ship-ABI до метода
  (ShipClient.abi getter); адаптер parser2 передаёт this.client.abi.
- BlockProcessor больше не глушит ошибку молча — warn с block_num/table/err.
- Тесты native-rows переписаны под ABI-байты + регресс-гард «JSON теперь бросает».

Проверено на live mono-ai-1: 30/30 native-дельт декодятся (было 0/90).
ship-reader 0.2.0→0.3.0 (breaking: +abi в Deserializer/streamNativeDeltas),
parser2 1.1.0→1.1.1 (багфикс, публичный API не менялся).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-03 09:06:13 +00:00
parent d71a2d696d
commit 39eabb737b
9 changed files with 92 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/parser2",
"version": "1.1.0",
"version": "1.1.1",
"description": "Universal EOSIO/Antelope SHiP-to-Redis blockchain indexer (parser)",
"license": "MIT",
"author": "Coopenomics contributors",
@@ -86,6 +86,6 @@ export class ShipReaderAdapter implements ChainClient {
* содержит hardcoded-схемы нативных таблиц (permission, account, …).
*/
deserializeNativeDelta(delta: ShipDelta): ShipNativeDeltaEvent {
return this.client.deserializer.deserializeNativeDelta(delta)
return this.client.deserializer.deserializeNativeDelta(delta, this.client.abi)
}
}
+10 -2
View File
@@ -29,6 +29,7 @@ import { computeEventId } from '../events/eventId.js'
import type { AbiBootstrapper } from '../abi/AbiBootstrapper.js'
import type { AbiStore } from '../abi/AbiStore.js'
import type { ChainClient } from '../ports/ChainClient.js'
import { rootLogger, type Logger } from '../logger.js'
interface BlockProcessorOptions {
/** Идентификатор цепи — проставляется в каждое событие. */
@@ -68,6 +69,7 @@ export class BlockProcessor {
private abiBootstrapper: AbiBootstrapper
private abiStore: AbiStore
private chainClient: ChainClient
private log: Logger
constructor(opts: BlockProcessorOptions) {
this.chainId = opts.chainId
@@ -75,6 +77,7 @@ export class BlockProcessor {
this.abiBootstrapper = opts.abiBootstrapper
this.abiStore = opts.abiStore
this.chainClient = opts.chainClient
this.log = rootLogger.child({ component: 'BlockProcessor', chain_id: opts.chainId })
this.queue = new PQueue({ concurrency: 1 })
}
@@ -237,8 +240,13 @@ export class BlockProcessor {
present: native.present,
}
nativeDeltaEvents.push({ ...partial, event_id: computeEventId(partial) })
} catch {
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок
} catch (err) {
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок,
// но молча терять их нельзя — иначе регрессии десериализации невидимы.
this.log.warn(
{ block_num: blockNum, table: delta.name, err: err instanceof Error ? err.message : String(err) },
'native delta deserialize failed',
)
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/coopos-ship-reader",
"version": "0.2.0",
"version": "0.3.0",
"description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains",
"license": "MIT",
"author": "Coopenomics contributors",
+3 -1
View File
@@ -1,3 +1,4 @@
import type { ABI } from '@wharfkit/antelope'
import type { ShipDelta } from './types/ship.js'
import type { NativeDeltaEvent } from './native-tables/index.js'
import { isNativeTableName } from './native-tables/index.js'
@@ -10,9 +11,10 @@ export function filterNativeDeltas(deltas: readonly ShipDelta[]): ShipDelta[] {
export function* streamNativeDeltas(
deltas: readonly ShipDelta[],
deserializer: WharfkitDeserializer,
abi: ABI,
): Generator<NativeDeltaEvent> {
for (const delta of deltas) {
if (!isNativeTableName(delta.name)) continue
yield deserializer.deserializeNativeDelta(delta)
yield deserializer.deserializeNativeDelta(delta, abi)
}
}
+9
View File
@@ -21,6 +21,15 @@ export class ShipClient {
this.deserializer = new WharfkitDeserializer()
}
/**
* SHiP-ABI (state_history), доступен после connect().
* Нужен для десериализации нативных дельт (их строки сериализованы этим ABI).
*/
get abi(): ShipAbi {
if (!this.shipAbi) throw new ShipConnectionError('Call connect() first')
return this.shipAbi
}
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) return
@@ -5,6 +5,6 @@ import type { NativeDeltaEvent } from '../native-tables/index.js'
export interface Deserializer {
deserializeAction<T = Record<string, unknown>>(trace: ShipTrace, abi: ABI): Action<T>
deserializeContractRow<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): Delta<T>
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta): NativeDeltaEvent<T>
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): NativeDeltaEvent<T>
readonly name: 'wharfkit'
}
@@ -57,13 +57,18 @@ export class WharfkitDeserializer implements Deserializer {
}
}
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta): NativeDeltaEvent<T> {
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): NativeDeltaEvent<T> {
if (!isNativeTableName(delta.name)) {
throw new UnknownNativeTableError(delta.name)
}
const table = delta.name as NativeTableName
try {
const data = JSON.parse(Buffer.from(delta.rowRaw).toString('utf8')) as T
// rowRaw нативных таблиц — это ABI-сериализованные байты state_history,
// а НЕ JSON. Тип строки = имя таблицы: в ship-ABI это variant (*_v0).
const decoded = Serializer.decode({ data: delta.rowRaw, type: table, abi })
const objectified = Serializer.objectify(decoded as ABISerializable)
// variant оформлен как [typeName, row] — распаковываем саму строку.
const data = (Array.isArray(objectified) ? objectified[1] : objectified) as T
const lookup_key = computeLookupKey(table, data as never)
const present: boolean = delta.present
return { present, table, data, lookup_key }
@@ -1,11 +1,50 @@
import { describe, it, expect } from 'vitest'
import { ABI, Serializer } from '@wharfkit/antelope'
import { WharfkitDeserializer } from '../../src/deserializers/WharfkitDeserializer.js'
import { computeLookupKey } from '../../src/native-tables/index.js'
import { isNativeTableName, NATIVE_TABLE_NAMES } from '../../src/native-tables/types.js'
import { UnknownNativeTableError } from '../../src/errors.js'
import { UnknownNativeTableError, DeserializationError } from '../../src/errors.js'
import type { ShipDelta } from '../../src/types/ship.js'
import type { NativePermissionRow, NativePermissionLinkRow } from '../../src/native-tables/types.js'
/**
* Мини-ABI в стиле state_history: каждая нативная таблица — это variant (*_v0).
* Достаточно полей, нужных computeLookupKey. Кодируем и декодируем одним ABI —
* схема самосогласована, повторять реальный EOSIO целиком не требуется.
*/
function makeShipAbi(): ABI {
return ABI.from({
version: 'eosio::abi/1.1',
types: [],
structs: [
{ name: 'permission_v0', base: '', fields: [
{ name: 'owner', type: 'name' },
{ name: 'name', type: 'name' },
] },
{ name: 'account_v0', base: '', fields: [
{ name: 'name', type: 'name' },
{ name: 'creation_date', type: 'string' },
{ name: 'abi', type: 'string' },
] },
],
actions: [],
tables: [],
variants: [
{ name: 'permission', types: ['permission_v0'] },
{ name: 'account', types: ['account_v0'] },
],
})
}
const SHIP_ABI = makeShipAbi()
/** Дельта с ABI-сериализованным rowRaw (как реально приходит из SHiP). */
function abiDelta(table: string, obj: Record<string, unknown>, present = true): ShipDelta {
const encoded = Serializer.encode({ object: [`${table}_v0`, obj], type: table, abi: SHIP_ABI })
return { name: table, present, rowRaw: encoded.array }
}
/** Дельта с JSON-байтами — раньше «работала» из-за бага JSON.parse, теперь должна падать. */
function jsonDelta(name: string, data: unknown, present = true): ShipDelta {
return {
name,
@@ -64,36 +103,38 @@ describe('computeLookupKey', () => {
describe('WharfkitDeserializer — deserializeNativeDelta', () => {
const deser = new WharfkitDeserializer()
it('deserializes permission delta with correct lookup_key', () => {
const permData: NativePermissionRow = {
owner: 'alice', name: 'active', parent: 'owner',
last_updated: '2024-01-01T00:00:00.000',
auth: { threshold: 1, keys: [], accounts: [], waits: [] },
}
const delta = jsonDelta('permission', permData)
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta)
it('deserializes ABI-encoded permission delta with correct lookup_key', () => {
const delta = abiDelta('permission', { owner: 'alice', name: 'active' })
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta, SHIP_ABI)
expect(event.table).toBe('permission')
expect(event.lookup_key).toBe('alice:active')
expect(event.present).toBe(true)
expect(event.data.owner).toBe('alice')
expect(String(event.data.owner)).toBe('alice')
})
it('unwraps the *_v0 variant into a flat row (not a [name, row] tuple)', () => {
const delta = abiDelta('permission', { owner: 'alice', name: 'active' })
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta, SHIP_ABI)
expect(Array.isArray(event.data)).toBe(false)
expect(String(event.data.name)).toBe('active')
})
it('present field is boolean (not string)', () => {
const delta = jsonDelta('account', { name: 'bob', creation_date: '', abi: '' }, false)
const event = deser.deserializeNativeDelta(delta)
const delta = abiDelta('account', { name: 'bob', creation_date: '2024-01-01T00:00:00.000', abi: '' }, false)
const event = deser.deserializeNativeDelta(delta, SHIP_ABI)
expect(typeof event.present).toBe('boolean')
expect(event.present).toBe(false)
})
it('throws UnknownNativeTableError for unknown table', () => {
const delta: ShipDelta = { name: 'my_custom_table', present: true, rowRaw: new Uint8Array([]) }
expect(() => deser.deserializeNativeDelta(delta)).toThrow(UnknownNativeTableError)
expect(() => deser.deserializeNativeDelta(delta, SHIP_ABI)).toThrow(UnknownNativeTableError)
})
it('all NATIVE_TABLE_NAMES tables deserialize without throwing (smoke test)', () => {
for (const table of NATIVE_TABLE_NAMES) {
const delta = jsonDelta(table, { name: 'test' })
expect(() => deser.deserializeNativeDelta(delta)).not.toThrow()
}
it('throws DeserializationError on non-ABI (JSON) bytes — no silent JSON.parse', () => {
// Регресс-гард на исходный баг: нативные строки декодятся ABI, а не JSON.parse.
// JSON-байты — не валидная ABI-сериализация, метод обязан бросить.
const delta = jsonDelta('permission', { owner: 'alice', name: 'active' })
expect(() => deser.deserializeNativeDelta(delta, SHIP_ABI)).toThrow(DeserializationError)
})
})