fix(native-delta): декодировать нативные строки ABI вместо JSON.parse #7

Merged
claude merged 2 commits from dev into main 2026-06-03 09:07:56 +00:00
10 changed files with 129 additions and 89 deletions
+37 -62
View File
@@ -5,17 +5,16 @@ name: Release
# существующим git-тегом v<version>) — тогда:
# 1. Публикуем workspace-пакеты в npm
# 2. Создаём git-тег v<version> и пушим его
# 3. Создаём GitHub Release с changelog'ом (changelogithub)
# 4. Собираем и пушим Docker-образ в docker.io (Docker Hub)
# 3. Создаём Gitea Release с changelog'ом через Gitea API
# Если версия не менялась — workflow no-op (без ошибки).
#
# Требуемые secrets в настройках репозитория:
# DOCKERHUB_USERNAME — Docker Hub логин с доступом к организации dicoop
# DOCKERHUB_TOKEN — PAT с правами Read,Write из hub.docker.com/settings/security
# GITHUB_TOKEN — выдаётся автоматически GitHub Actions
#
# npm publish работает через trusted publishing (OIDC) — токен не нужен:
# настроен на стороне npm для @coopenomics/parser2 под этот workflow.
# NPM_TOKEN — npm access token с правами publish на @coopenomics/*
# (Gitea Actions не выдаёт OIDC id-token, поэтому npm
# trusted publishing/provenance здесь недоступны —
# используем классический auth через NODE_AUTH_TOKEN)
# GITHUB_TOKEN — выдаётся автоматически Gitea Actions; используется для
# создания релиза через ${GITHUB_SERVER_URL}/api/v1/...
on:
push:
@@ -26,8 +25,7 @@ concurrency:
cancel-in-progress: false
permissions:
contents: write # tag + GitHub Release
id-token: write # npm trusted publishing (OIDC) + sigstore provenance
contents: write # tag + Gitea Release
jobs:
release:
@@ -39,7 +37,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # нужно для git tag проверки и changelogithub
fetch-depth: 0 # нужно для git tag проверки и git log changelog
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
@@ -71,15 +69,15 @@ jobs:
pnpm --filter @coopenomics/coopos-ship-reader build
pnpm --filter @coopenomics/parser2 build
# npm trusted publishing: OIDC через id-token: write, без NPM_TOKEN.
# Настроено на стороне npm: @coopenomics/parser2/access → Trusted Publisher
# → GitHub Actions (repo=coopenomics/parser2, workflow=release.yml).
# Provenance=true — sigstore подпишет каждый publish.
# Auth через NODE_AUTH_TOKEN: actions/setup-node по registry-url прописал
# //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} в ~/.npmrc, npm/lerna
# читают токен из env. Provenance не включаем — требует OIDC id-token,
# которого Gitea Actions не выдаёт (Trusted Publisher тоже не сработает).
- name: Publish via Lerna (from-package)
if: steps.ver.outputs.released == 'true'
run: pnpm exec lerna publish from-package --yes --no-private
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Tag release
@@ -90,52 +88,29 @@ jobs:
git tag "v${{ steps.ver.outputs.version }}"
git push origin "v${{ steps.ver.outputs.version }}"
- name: Create GitHub Release
# changelogithub знает только github.com и падает на git.coopenomics.world
# ("Can not parse GitHub repo from url ..."). Создаём релиз напрямую через
# Gitea REST API: GITHUB_SERVER_URL и GITHUB_REPOSITORY проставляются
# раннером, GITHUB_TOKEN имеет права contents:write на тот же репо.
# Changelog — git log между предыдущим v-тегом и текущим.
- name: Create Gitea Release
if: steps.ver.outputs.released == 'true'
run: npx changelogithub
run: |
VERSION="${{ steps.ver.outputs.version }}"
PREV=$(git tag --list 'v*' --sort=-v:refname | grep -vx "v${VERSION}" | head -1)
if [ -n "$PREV" ]; then
CHANGELOG=$(git log "${PREV}..v${VERSION}" --pretty=format:'- %s')
else
CHANGELOG=$(git log "v${VERSION}" --pretty=format:'- %s')
fi
BODY=$(jq -n \
--arg tag "v${VERSION}" \
--arg body "$CHANGELOG" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')
curl -fsS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
name: Build & push Docker image
runs-on: ubuntu-22.04
needs: release
if: needs.release.outputs.released == 'true'
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.release.outputs.version }}
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @coopenomics/coopos-ship-reader build
- run: pnpm --filter @coopenomics/parser2 build
- uses: docker/setup-buildx-action@v3
# Docker Hub auth: требует secrets DOCKERHUB_USERNAME + DOCKERHUB_TOKEN
# (Access Token из hub.docker.com/settings/security с правами Read,Write)
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: dicoop/parser2
tags: |
type=semver,pattern={{version}},value=v${{ needs.release.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.release.outputs.version }}
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
+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)
})
})