Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71f7a22436 | |||
| 5319209cb2 | |||
| 30475c055f | |||
| 39eabb737b | |||
| d71a2d696d | |||
| f6360132f0 | |||
| 16bd87cca1 | |||
| 677dc35bde | |||
| 05d413b37f | |||
| 37efb4834e | |||
| d575da529b | |||
| 9f21c61a75 | |||
| fce498ce45 |
+45
-13
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
@@ -85,30 +83,64 @@ jobs:
|
||||
# передачу команды контейнеру. Монтируем config.ini (SHiP на 8080) и genesis.json.
|
||||
- name: Start blockchain node
|
||||
run: |
|
||||
docker run -d --name blockchain \
|
||||
-p 8888:8888 -p 8080:8080 -p 9876:9876 \
|
||||
-v "${GITHUB_WORKSPACE}/packages/parser2/test/integration/node-config:/mnt/dev/config" \
|
||||
# Раннер self-hosted и персистентный: упавший прогон оставляет контейнер
|
||||
# с фиксированным именем blockchain, и следующий старт падает с exit 125
|
||||
# (Conflict, name already in use). Сносим остаток перед стартом.
|
||||
docker rm -f blockchain 2>/dev/null || true
|
||||
# Конфиг кладём в контейнер через docker cp, а НЕ через bind-mount (-v).
|
||||
# Раннер работает по схеме docker-in-docker (docker.sock хоста проброшен
|
||||
# в job-контейнер), поэтому пути bind-mount резолвятся на ФС ХОСТА, а не
|
||||
# внутри job-контейнера: ни mktemp, ни workspace хостовому демону не видны
|
||||
# → монтируется ПУСТОЙ каталог (nodeos пишет туда protocol_features, но
|
||||
# genesis.json/config.ini отсутствуют → 'genesis file does not exist').
|
||||
# docker cp передаёт файлы tar-стримом по API и от путей хоста не зависит.
|
||||
# /mnt/dev/config в образе нет (его создавал сам mount), а docker cp не
|
||||
# создаёт родительские каталоги — поэтому config-dir = /mnt/cfg (родитель
|
||||
# /mnt существует). protocol_features узел пишет туда же, в свежесозданный
|
||||
# контейнер — никакого мусора между прогонами.
|
||||
docker create --name blockchain \
|
||||
dicoop/blockchain:v5.1.0-dev \
|
||||
/bin/bash -c '/usr/local/bin/nodeos -d /mnt/dev/data -p eosio --config-dir /mnt/dev/config --genesis-json /mnt/dev/config/genesis.json --disable-replay-opts'
|
||||
/bin/bash -c 'mkdir -p /mnt/data && exec /usr/local/bin/nodeos -d /mnt/data -p eosio --config-dir /mnt/cfg --genesis-json /mnt/cfg/genesis.json --disable-replay-opts'
|
||||
docker cp "${GITHUB_WORKSPACE}/packages/parser2/test/integration/node-config" blockchain:/mnt/cfg
|
||||
# Сеть в DinD: порт-маппинг -p публикует на ХОСТЕ, а шаги job'а крутятся
|
||||
# внутри job-контейнера — его localhost хостовых портов не видит (поэтому
|
||||
# localhost:8888 был недостижим). redis-сервис доступен по localhost лишь
|
||||
# потому, что его сеть настраивает сам Gitea. Свою ноду подключаем в ту же
|
||||
# сеть, что и job-контейнер, и адресуем по её IP на этой сети (надёжнее
|
||||
# DNS — работает и на bridge); IP пробрасываем в env последующих шагов.
|
||||
SELF=$(cat /etc/hostname)
|
||||
NET=$(docker inspect "$SELF" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' | grep -v '^bridge$' | head -1)
|
||||
[ -z "$NET" ] && NET=bridge
|
||||
docker network connect "$NET" blockchain
|
||||
docker start blockchain
|
||||
NODE_HOST=$(docker inspect blockchain --format "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}")
|
||||
echo "Нода в сети '$NET' с IP $NODE_HOST"
|
||||
echo "NODE_HOST=$NODE_HOST" >> "$GITHUB_ENV"
|
||||
sleep 3
|
||||
- name: Wait for Chain API
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:8888/v1/chain/get_info > /dev/null 2>&1; then
|
||||
# Бюджет 120 попыток × (≤2с curl + 2с sleep) ≈ до 8 мин: на нагруженном
|
||||
# раннере nodeos поднимается медленно (наблюдали >6 мин до get_info), а
|
||||
# curl без --max-time подвисал на ещё-не-отвечающем порту и съедал бюджет.
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf --max-time 2 "http://${NODE_HOST}:8888/v1/chain/get_info" > /dev/null 2>&1; then
|
||||
echo "Chain API ready after $i attempts"
|
||||
exit 0
|
||||
fi
|
||||
echo " attempt $i/60..."
|
||||
sleep 2
|
||||
echo " attempt $i/120..."
|
||||
sleep 4
|
||||
done
|
||||
echo "ERROR: Chain API did not become ready. Container logs:"
|
||||
docker logs blockchain | tail -100
|
||||
exit 1
|
||||
- run: pnpm --filter @coopenomics/parser2 test:integration
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379
|
||||
CHAIN_URL: http://localhost:8888
|
||||
SHIP_URL: ws://localhost:8080
|
||||
# В Gitea/act job крутится в контейнере, а services: — контейнеры-соседи
|
||||
# в той же сети, доступные ПО ИМЕНИ СЕРВИСА (redis), а не через localhost
|
||||
# (localhost сработал бы на GitHub-хостед раннере, но не в DinD).
|
||||
REDIS_URL: redis://redis:6379
|
||||
CHAIN_URL: http://${{ env.NODE_HOST }}:8888
|
||||
SHIP_URL: ws://${{ env.NODE_HOST }}:8080
|
||||
- name: Show blockchain logs on failure
|
||||
if: failure()
|
||||
run: docker logs blockchain | tail -200
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -240,6 +240,28 @@ docker run --rm --network host \
|
||||
- [Redis key taxonomy](docs/redis-key-taxonomy.md)
|
||||
- [Disaster recovery / fork scenarios](docs/disaster-recovery.md)
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
### Схлопывание дельт create+remove внутри одного блока
|
||||
|
||||
Парсер **не увидит** жизненный цикл строки таблицы, у которой `emplace` и `erase` произошли **внутри одного блока** (даже в разных транзакциях). Это поведение унаследовано от upstream-плагина `state_history_plugin` (SHiP) ноджеса: на уровне `chainbase::undo_index::on_remove` строка, созданная в текущей undo session и тут же удалённая, физически уничтожается без записи в `_removed_values` — это by-design оптимизация для отката блока (нечего откатывать), но она же делает event невидимым для SHiP-снимка состояния. SHiP пакует только то, что есть в undo session, и не имеет шанса увидеть транзиентную пару.
|
||||
|
||||
**Симптомы:**
|
||||
- На цепочке таблица пустая (получена и тут же удалена).
|
||||
- ACTION-логи и `getclearance`/`apprvappndx`-подобные действия видны нормально — экшены идут параллельным потоком и не зависят от undo.
|
||||
- В Redis stream / БД-индексе нет ни `present:true`, ни `present:false` события для конкретного `primary_key`.
|
||||
|
||||
**Когда это всплывает на практике:**
|
||||
- Контракт делает `emplace + erase` одной сущности в одной транзакции (anti-pattern, но встречается).
|
||||
- Несколько коротких транзакций над одной сущностью пакуются производителем блоков в один блок (массовый импорт, auto-approve, bulk-операции, миграции, batch-сценарии).
|
||||
|
||||
**Workaround на стороне приложения:**
|
||||
- Разнести `emplace` и `erase` по разным блокам — в seed/bulk-сценариях добавить sleep ≥ 1 × `block_time` (≈500 мс для EOSIO) между транзакциями. В production-flow ручного approve'а это не нужно — между нажатием кнопки и второй транзакцией всегда проходит более одного блока.
|
||||
- Если транзиентные пары — часть нормального дизайна контракта, рассмотреть переписывание на `modify(status="approved")` вместо `erase`: SHiP корректно эмиттит дельту изменения статуса.
|
||||
|
||||
**Системный фикс (планируется):**
|
||||
Drop-in патч `chainbase` + `state_history_plugin` в нашем форке `~/coopos` (uENOSIO) — отдельный список `_transient_removed_values` в undo_index + паковка пар `[present:true, present:false]` в `table_delta_v0` без изменения wire-формата. Wire-совместимо со всеми существующими SHiP-клиентами; после деплоя форка парсер автоматически начнёт видеть транзиентные пары без изменений в коде. Технические детали и план работ — в задаче `f3-shipchainbase-patch-vidimost-tranzientnykh-delt-createremove-vnutri-bloka.md` проекта parser2 в `_blago`.
|
||||
|
||||
## Разработка
|
||||
|
||||
### Структура монорепы
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/parser2",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.2",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -94,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) {
|
||||
@@ -136,13 +144,19 @@ export class BlockProcessor {
|
||||
block_num: blockNum,
|
||||
block_time: blockTime,
|
||||
block_id: blockId,
|
||||
transaction_id: trace.transactionId,
|
||||
account: trace.account,
|
||||
name: trace.name,
|
||||
authorization: [...trace.authorization],
|
||||
data,
|
||||
action_ordinal: trace.actionOrdinal,
|
||||
creator_action_ordinal: trace.creatorActionOrdinal,
|
||||
global_sequence: trace.globalSequence,
|
||||
receipt: trace.receipt,
|
||||
context_free: trace.contextFree,
|
||||
elapsed: trace.elapsed,
|
||||
console: trace.console,
|
||||
account_ram_deltas: [...trace.accountRamDeltas],
|
||||
}
|
||||
|
||||
actionEvents.push({ ...partial, event_id: computeEventId(partial) })
|
||||
@@ -231,8 +245,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',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* повторные доставки.
|
||||
*/
|
||||
|
||||
import type { ActionAuthorization, ActionReceipt } from '@coopenomics/coopos-ship-reader'
|
||||
import type { ActionAuthorization, ActionReceipt, AccountRamDelta } from '@coopenomics/coopos-ship-reader'
|
||||
|
||||
/** Событие вызова смарт-контракта (inline action). */
|
||||
export interface ActionEvent {
|
||||
@@ -20,6 +20,8 @@ export interface ActionEvent {
|
||||
/** ISO-8601 время блока из трассировки транзакции. */
|
||||
block_time: string
|
||||
block_id: string
|
||||
/** Хеш транзакции, в которой исполнено действие. */
|
||||
transaction_id: string
|
||||
/** Аккаунт-владелец контракта (account). */
|
||||
account: string
|
||||
/** Имя действия (action name). */
|
||||
@@ -29,10 +31,20 @@ export interface ActionEvent {
|
||||
data: Record<string, unknown>
|
||||
/** Порядковый номер действия внутри транзакции (1-based). */
|
||||
action_ordinal: number
|
||||
/** Порядковый номер родительского (создавшего) действия; 0 — если действие верхнего уровня. */
|
||||
creator_action_ordinal: number
|
||||
/** Глобальная уникальная последовательность — монотонный счётчик действий в цепи. */
|
||||
global_sequence: bigint
|
||||
/** Квитанция об исполнении; null если нет трассировки. */
|
||||
receipt: ActionReceipt | null
|
||||
/** Является ли действие context-free. */
|
||||
context_free: boolean
|
||||
/** Время исполнения действия в микросекундах. */
|
||||
elapsed: number
|
||||
/** Консольный вывод действия (eosio::print и т.п.). */
|
||||
console: string
|
||||
/** Изменения RAM по аккаунтам в результате действия. */
|
||||
account_ram_deltas: AccountRamDelta[]
|
||||
}
|
||||
|
||||
/** Изменение строки в пользовательской таблице смарт-контракта (contract_row delta). */
|
||||
|
||||
@@ -51,14 +51,20 @@ 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',
|
||||
authorization: [{ actor: 'alice', permission: 'active' }],
|
||||
actRaw: new Uint8Array([1, 2, 3]),
|
||||
actionOrdinal: i + 1,
|
||||
creatorActionOrdinal: 0,
|
||||
globalSequence: BigInt(100 + i),
|
||||
receipt: null,
|
||||
contextFree: false,
|
||||
elapsed: 0,
|
||||
console: '',
|
||||
accountRamDeltas: [],
|
||||
blockNum,
|
||||
blockId: 'a'.repeat(64),
|
||||
blockTime: '2024-01-01T00:00:00.000',
|
||||
@@ -170,14 +176,20 @@ 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',
|
||||
authorization: [],
|
||||
actRaw: new Uint8Array([1]),
|
||||
actionOrdinal: 1,
|
||||
creatorActionOrdinal: 0,
|
||||
globalSequence: BigInt(1),
|
||||
receipt: null,
|
||||
contextFree: false,
|
||||
elapsed: 0,
|
||||
console: '',
|
||||
accountRamDeltas: [],
|
||||
blockNum: 500,
|
||||
blockId: 'c'.repeat(64),
|
||||
blockTime: '2024-01-01T00:00:00.000',
|
||||
@@ -204,14 +216,20 @@ 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',
|
||||
authorization: [],
|
||||
actRaw: new Uint8Array([1]),
|
||||
actionOrdinal: 1,
|
||||
creatorActionOrdinal: 0,
|
||||
globalSequence: BigInt(2),
|
||||
receipt: null,
|
||||
contextFree: false,
|
||||
elapsed: 0,
|
||||
console: '',
|
||||
accountRamDeltas: [],
|
||||
blockNum: 501,
|
||||
blockId: 'c'.repeat(64),
|
||||
blockTime: '2024-01-01T00:00:00.000',
|
||||
@@ -238,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,
|
||||
@@ -268,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,
|
||||
@@ -323,9 +343,10 @@ 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, globalSequence: BigInt(1), receipt: null, 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, globalSequence: BigInt(2), receipt: null, 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: 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) },
|
||||
],
|
||||
deltas: [
|
||||
{ name: 'contract_row', present: true, rowRaw: new Uint8Array([1]), code: 'eosio.token', scope: 'alice', table: 'accounts', primaryKey: '1' },
|
||||
@@ -355,7 +376,8 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
|
||||
head: { blockNum: 5, blockId: 'd'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 5, blockId: 'd'.repeat(64) },
|
||||
prevBlock: null,
|
||||
traces: [{ account: 'eosio', name: 'newaccount', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, globalSequence: BigInt(10), receipt: null, blockNum: 5, blockId: 'd'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'e'.repeat(64) }],
|
||||
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' },
|
||||
{ name: 'permission' as never, present: true, rowRaw: new Uint8Array([1]) },
|
||||
@@ -375,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]) }],
|
||||
}
|
||||
|
||||
@@ -15,13 +15,19 @@ function makeAction(override: Partial<WithoutId<Extract<ParserEvent, { kind: 'ac
|
||||
block_num: 100,
|
||||
block_time: '2024-01-01T00:00:00.000',
|
||||
block_id: BLOCK_ID,
|
||||
transaction_id: 'tx0000000000000000000000000000000000000000000000000000000000000000',
|
||||
account: 'eosio.token',
|
||||
name: 'transfer',
|
||||
authorization: [],
|
||||
data: {},
|
||||
action_ordinal: 1,
|
||||
creator_action_ordinal: 0,
|
||||
global_sequence: 12345n,
|
||||
receipt: null,
|
||||
context_free: false,
|
||||
elapsed: 0,
|
||||
console: '',
|
||||
account_ram_deltas: [],
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,19 @@ const actionEvent: ParserEvent = {
|
||||
kind: 'action',
|
||||
event_id: 'id1',
|
||||
...BLOCK,
|
||||
transaction_id: 'tx0000000000000000000000000000000000000000000000000000000000000000',
|
||||
account: 'eosio',
|
||||
name: 'updateauth',
|
||||
authorization: [],
|
||||
data: { from: 'alice', permission: 'active' },
|
||||
action_ordinal: 1,
|
||||
creator_action_ordinal: 0,
|
||||
global_sequence: 100n,
|
||||
receipt: null,
|
||||
context_free: false,
|
||||
elapsed: 0,
|
||||
console: '',
|
||||
account_ram_deltas: [],
|
||||
}
|
||||
|
||||
const deltaEvent: ParserEvent = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/coopos-ship-reader",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains",
|
||||
"license": "MIT",
|
||||
"author": "Coopenomics contributors",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ABI, Serializer, Bytes } from '@wharfkit/antelope'
|
||||
import { ShipProtocolError } from './errors.js'
|
||||
import type { BlockPosition, ShipBlock, ShipTrace, ShipDelta, ActionReceipt, ActionAuthorization } from './types/ship.js'
|
||||
import type { BlockPosition, ShipBlock, ShipTrace, ShipDelta, ActionReceipt, ActionAuthorization, AuthSequence, AccountRamDelta } from './types/ship.js'
|
||||
|
||||
export type ShipAbi = ABI
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -61,7 +62,12 @@ interface RawActionTrace {
|
||||
}
|
||||
receipt: RawActionReceipt | null
|
||||
action_ordinal: number
|
||||
global_sequence: string
|
||||
creator_action_ordinal?: number
|
||||
global_sequence?: string
|
||||
context_free?: boolean
|
||||
elapsed?: string | number
|
||||
console?: string
|
||||
account_ram_deltas?: Array<{ account: string; delta: string | number }>
|
||||
}
|
||||
|
||||
interface RawActionReceipt {
|
||||
@@ -69,6 +75,9 @@ interface RawActionReceipt {
|
||||
act_digest: string
|
||||
global_sequence: string
|
||||
recv_sequence: string
|
||||
// auth_sequence — массив пар (account, sequence). wharfkit может отдать как
|
||||
// объект {account, sequence} или как кортеж [account, sequence].
|
||||
auth_sequence?: Array<{ account: string; sequence: string | number } | [string, string | number]>
|
||||
code_sequence: number
|
||||
abi_sequence: number
|
||||
}
|
||||
@@ -105,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 объект (не строку) —
|
||||
@@ -119,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<bytes> с сериализованным 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)
|
||||
|
||||
@@ -142,17 +167,28 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number,
|
||||
const receiptData = Array.isArray(at.receipt)
|
||||
? (at.receipt[1] as RawActionTrace['receipt'])
|
||||
: at.receipt
|
||||
const authSequence: AuthSequence[] = (receiptData?.auth_sequence ?? []).map(pair =>
|
||||
Array.isArray(pair)
|
||||
? { account: String(pair[0]), sequence: String(pair[1]) }
|
||||
: { account: String(pair.account), sequence: String(pair.sequence) },
|
||||
)
|
||||
const receipt: ActionReceipt | null = receiptData
|
||||
? ({
|
||||
receiver: String(receiptData.receiver),
|
||||
actDigest: String(receiptData.act_digest),
|
||||
globalSequence: BigInt(receiptData.global_sequence),
|
||||
recvSequence: BigInt(receiptData.recv_sequence),
|
||||
authSequence,
|
||||
codeSequence: receiptData.code_sequence,
|
||||
abiSequence: receiptData.abi_sequence,
|
||||
} as ActionReceipt)
|
||||
: null
|
||||
|
||||
const accountRamDeltas: AccountRamDelta[] = (at.account_ram_deltas ?? []).map(d => ({
|
||||
account: String(d.account),
|
||||
delta: Number(d.delta),
|
||||
}))
|
||||
|
||||
// action_trace_v1 не имеет top-level global_sequence (только в receipt).
|
||||
// Fallback: если at.global_sequence undefined — берём из receipt.
|
||||
const topGlobalSeq = at.global_sequence ?? receiptData?.global_sequence
|
||||
@@ -162,8 +198,13 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number,
|
||||
authorization,
|
||||
actRaw: at.act.data instanceof Bytes ? at.act.data.array : Uint8Array.from([]),
|
||||
actionOrdinal: at.action_ordinal,
|
||||
creatorActionOrdinal: at.creator_action_ordinal ?? 0,
|
||||
globalSequence: topGlobalSeq !== undefined ? BigInt(topGlobalSeq) : 0n,
|
||||
receipt,
|
||||
contextFree: at.context_free ?? false,
|
||||
elapsed: at.elapsed !== undefined ? Number(at.elapsed) : 0,
|
||||
console: at.console ?? '',
|
||||
accountRamDeltas,
|
||||
blockNum: thisBlock.blockNum,
|
||||
blockId: thisBlock.blockId,
|
||||
blockTime,
|
||||
@@ -204,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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -23,6 +23,8 @@ export type {
|
||||
BlockPosition,
|
||||
ActionReceipt,
|
||||
ActionAuthorization,
|
||||
AuthSequence,
|
||||
AccountRamDelta,
|
||||
} from './types/ship.js'
|
||||
|
||||
export type {
|
||||
|
||||
@@ -8,11 +8,22 @@ export interface ActionAuthorization {
|
||||
readonly permission: string
|
||||
}
|
||||
|
||||
export interface AuthSequence {
|
||||
readonly account: string
|
||||
readonly sequence: string
|
||||
}
|
||||
|
||||
export interface AccountRamDelta {
|
||||
readonly account: string
|
||||
readonly delta: number
|
||||
}
|
||||
|
||||
export interface ActionReceipt {
|
||||
readonly receiver: string
|
||||
readonly actDigest: string
|
||||
readonly globalSequence: bigint
|
||||
readonly recvSequence: bigint
|
||||
readonly authSequence: readonly AuthSequence[]
|
||||
readonly codeSequence: number
|
||||
readonly abiSequence: number
|
||||
}
|
||||
@@ -23,8 +34,13 @@ export interface ShipTrace {
|
||||
readonly authorization: readonly ActionAuthorization[]
|
||||
readonly actRaw: Uint8Array
|
||||
readonly actionOrdinal: number
|
||||
readonly creatorActionOrdinal: number
|
||||
readonly globalSequence: bigint
|
||||
readonly receipt: ActionReceipt | null
|
||||
readonly contextFree: boolean
|
||||
readonly elapsed: number
|
||||
readonly console: string
|
||||
readonly accountRamDeltas: readonly AccountRamDelta[]
|
||||
readonly blockNum: number
|
||||
readonly blockId: string
|
||||
readonly blockTime: string
|
||||
@@ -46,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[]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]', () => {
|
||||
|
||||
Reference in New Issue
Block a user