diff --git a/execution_chain/core/chain/forked_chain/chain_serialize.nim b/execution_chain/core/chain/forked_chain/chain_serialize.nim index 23cc79517f..6ff05468b7 100644 --- a/execution_chain/core/chain/forked_chain/chain_serialize.nim +++ b/execution_chain/core/chain/forked_chain/chain_serialize.nim @@ -15,10 +15,10 @@ import eth/common/blocks_rlp, ./chain_desc, ./chain_branch, - ./chain_private, ../../../db/core_db, ../../../db/fcu_db, ../../../db/storage_types, + ../../../db/tx_frame_db, ../../../utils/utils logScope: @@ -46,9 +46,12 @@ type # ------------------------------------------------------------------------------ proc append(w: var RlpWriter, b: BlockRef) = - let fullBlk = b.txFrame.getEthBlock(b.hash).expect("block body must be in txFrame during serialize") + # Only the header is persisted in the block-index entry. The full block + # body lives in the per-block txFrame blob (written separately under + # txFrameKey(b.hash)) and is no longer needed at deserialize time since + # we restore the txFrame directly instead of re-executing the block. w.startList(3) - w.append(fullBlk) + w.append(b.header) w.append(b.hash) let parentIndex = if b.parent.isNil: 0'u else: b.parent.index + 1'u @@ -80,9 +83,7 @@ proc append(w: var RlpWriter, fc: ForkedChainRef) = proc read(rlp: var Rlp, T: type BlockRef): T {.raises: [RlpError].} = rlp.tryEnterList() result = T() - var blk: Block - rlp.read(blk) # Parse full block from RLP (old format) - result.header = blk.header # Store only header in BlockRef + rlp.read(result.header) rlp.read(result.hash) rlp.read(result.index) @@ -122,49 +123,12 @@ proc getState(db: CoreDbTxRef): Opt[FcState] = err() -proc replayBlock(fc: ForkedChainRef; - parent: BlockRef, - blk: BlockRef, - fullBlk: Block): Result[void, string] = - let - parentFrame = parent.txFrame - txFrame = parentFrame.txFrameBegin() - blockAccessList = ?fc.baseTxFrame.getBlockAccessList(blk.hash) - - # Set finalized to true in order to skip the stateroot check when replaying the - # block because the blocks should have already been checked previously during - # the initial block execution. - fc.processBlock( - parent, - txFrame, - fullBlk, - blockAccessList, - blk.hash, - finalized = true - ).isOkOr: - txFrame.dispose() - return err(error) - - # After processing the block the BAL should now be stored in the txFrame in - # memory so we can delete the copy on disk - if blockAccessList.isSome(): - fc.baseTxFrame.deleteBlockAccessList(blk.hash) - - # Checkpoint creates a snapshot of ancestor changes in txFrame - it is an - # expensive operation, specially when creating a new branch (ie when blk - # is being applied to a block that is currently not a head). - txFrame.checkpoint(blk.header.number, skipSnapshot = false) - - blk.txFrame = txFrame - - ok() - -proc replayBranch(fc: ForkedChainRef; - parent: BlockRef; - head: BlockRef; - bodies: Table[Hash32, Block]; - ): Result[void, string] = - +proc loadBranchTxFrames(parent: BlockRef; + head: BlockRef; + srcBase: CoreDbTxRef): Result[void, string] = + ## Walk the branch from `parent` (exclusive, txFrame already set) up to + ## `head` (inclusive), materialising each block's txFrame from its + ## persisted blob as a child of the previous frame. var blocks = newSeqOfCap[BlockRef](head.number - parent.number) for it in ancestors(head): if it.number > parent.number: @@ -172,22 +136,27 @@ proc replayBranch(fc: ForkedChainRef; else: break - var parent = parent + var p = parent for i in countdown(blocks.len-1, 0): - bodies.withValue(blocks[i].hash, fullBlk): - ?fc.replayBlock(parent, blocks[i], fullBlk) - do: - return err("block body not found for hash: " & $blocks[i].hash) - parent = blocks[i] + let b = blocks[i] + let frame = srcBase.loadTxFrameAsChild(p.txFrame, b.hash).valueOr: + return err($error) + b.txFrame = frame + # The blob has been materialised into memory; drop the on-disk copy so + # it doesn't accumulate across restart/prune cycles. The delete sits in + # srcBase's in-memory delta and commits on the next baseTxFrame persist + # during normal chain operation. + srcBase.deleteTxFrame(b.hash).isOkOr: + return err($error) + p = b ok() -proc replay(fc: ForkedChainRef; bodies: Table[Hash32, Block]): Result[void, string] = +proc loadAllTxFrames(fc: ForkedChainRef): Result[void, string] = # Should have no parent doAssert fc.base.parent.isNil - # Receipts for base block are loaded from database - # see `receiptsByBlockHash` + # Base block shares its txFrame with the on-disk base fc.base.txFrame = fc.baseTxFrame # Base block always have finalized marker @@ -196,7 +165,7 @@ proc replay(fc: ForkedChainRef; bodies: Table[Hash32, Block]): Result[void, stri for head in fc.heads: for it in ancestors(head): if it.txFrame.isNil.not: - ?fc.replayBranch(it, head, bodies) + ?loadBranchTxFrames(it, head, fc.baseTxFrame) break ok() @@ -234,11 +203,11 @@ proc serialize*(fc: ForkedChainRef, txFrame: CoreDbTxRef): Result[void, CoreDbEr for b in fc.hashToBlock.values: ?txFrame.put(blockIndexKey(b.index), rlp.encode(b)) - # Move the BAL from the block txFrame into the target (base) txFrame - let bal = b.txFrame.getBlockAccessList(b.hash).valueOr: - Opt.none(BlockAccessListRef) - if bal.isSome(): - txFrame.persistBlockAccessList(b.hash, bal.get()) + # Persist the per-block txFrame delta (Aristo + KVT) so deserialize can + # restore the in-memory frame without re-executing the block. The base + # block shares its frame with the on-disk base and needs no blob. + if b != fc.base: + ?txFrame.storeTxFrame(b.txFrame, b.hash) info "Blocks DAG written to database", base=fc.base.number, @@ -259,9 +228,7 @@ proc deserialize*(fc: ForkedChainRef): Result[void, string] = return err("Cannot find previous FC state in database") let prevBase = fc.base - var - bodies: Table[Hash32, Block] - blocks = newSeq[BlockRef](state.numBlocks) + var blocks = newSeq[BlockRef](state.numBlocks) # Sanity Checks for the FC state if state.latest > state.numBlocks or @@ -281,15 +248,7 @@ proc deserialize*(fc: ForkedChainRef): Result[void, string] = for i in 0.. 64: - info "Please wait until DAG finish loading..." - if fc.base.hash != prevBase.hash: fc.reset(prevBase) return err("loaded baseHash != baseHash") @@ -336,11 +292,11 @@ proc deserialize*(fc: ForkedChainRef): Result[void, string] = b.parent = parentCandidate fc.hashToBlock[b.hash] = b - fc.replay(bodies).isOkOr: + fc.loadAllTxFrames().isOkOr: fc.reset(prevBase) return err(error) - # All blocks should have replayed + # All blocks should have their txFrame loaded for b in blocks: if b.txFrame.isNil: fc.reset(prevBase) diff --git a/execution_chain/db/aristo/aristo_desc/desc_error.nim b/execution_chain/db/aristo/aristo_desc/desc_error.nim index 5258698a2b..725a75f9af 100644 --- a/execution_chain/db/aristo/aristo_desc/desc_error.nim +++ b/execution_chain/db/aristo/aristo_desc/desc_error.nim @@ -93,6 +93,10 @@ type PartTrkLinkExpected PartTrkRlpError + # TxFrame blobify/deblobify + DeblobTxFrameVersion + DeblobTxFrameTruncated + # RocksDB backend RdbBeCantCreateTmpDir RdbBeDriverDelAdmError diff --git a/execution_chain/db/aristo/aristo_tx_blobify.nim b/execution_chain/db/aristo/aristo_tx_blobify.nim new file mode 100644 index 0000000000..c8414f80a4 --- /dev/null +++ b/execution_chain/db/aristo/aristo_tx_blobify.nim @@ -0,0 +1,304 @@ +# nimbus-eth1 +# Copyright (c) 2026 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or +# http://www.apache.org/licenses/LICENSE-2.0) +# * MIT license ([LICENSE-MIT](LICENSE-MIT) or +# http://opensource.org/licenses/MIT) +# at your option. This file may not be copied, modified, or distributed +# except according to those terms. + +## Aristo DB -- transaction frame serialisation +## ============================================= +## +## Serialises the delta recorded in a single `AristoTxRef` (sTab, kMap, +## accLeaves, stoLeaves, vTop, blockNumber) to a flat byte sequence so the +## frame can be stored in the KVT database and restored without replaying +## blocks. +## +## Wire format (all multi-byte integers big-endian): +## +## version : 1 byte = 0x01 +## vTop : 8 bytes +## blockNumber : 1 flag byte (0/1) + 8 value bytes +## sTab_count : 4 bytes +## per sTab entry : +## rvid_len : 1 byte +## rvid_blob : rvid_len bytes (from blobify(rvid).data) +## is_nil : 1 byte (0 = deletion marker, 1 = present) +## if present: +## blob_len : 2 bytes +## blob : blob_len bytes (from blobifyTo(vtx, key, data)) +## accLeaves_count: 4 bytes +## per accLeaf entry: +## hash : 32 bytes +## is_nil : 1 byte +## if present: +## blob_len : 2 bytes +## blob : blob_len bytes (from blobifyTo(vtxRef, VOID, data)) +## stoLeaves_count: 4 bytes +## per stoLeaf entry: +## +## +## The kMap is implicitly embedded: `blobifyTo(vtx, key, data)` encodes the +## HashKey inside the blob when it is valid; `deblobify(record, HashKey)` +## recovers it. No separate kMap section is needed. +## +## ### rvid_blob +## +## `RootedVertexID` is encoded using the existing `blobify(rvid: RootedVertexID): RVidBuf` +## from `aristo_blobify.nim`. It is a compact variable-length big-endian encoding: +## +## ``` +## root_len : 1 byte (number of significant bytes in root VertexID) +## root : root_len bytes +## [vid] : remaining bytes, omitted when root == vid +## ``` +## +## Maximum size: 17 bytes. `rvid_len` records the actual length. +## +## ### vtx_blob +## +## Vertices are encoded using the existing `blobifyTo(vtx: VertexRef, key: HashKey, data: var VertexBuf)` +## from `aristo_blobify.nim`. This format is shared with the Aristo RocksDB backend. +## +## The `HashKey` for a vertex is looked up in `kMap`; if absent, `VOID_HASH_KEY` is used. +## The key is embedded inside `vtx_blob` for branch nodes (indicated by a bit in the last byte), +## so **no separate `kMap` section is needed** — `kMap` is implicitly reconstructed on decode +## via `deblobify(blob, HashKey)`. +## +## Vertex type encoding (last byte of vtx_blob): +## +## ``` +## bits [7:6] meaning +## 00 Branch — no embedded hash key +## 10 Branch — 32-byte hash key prepended +## 01 Leaf (AccLeaf or StoLeaf, distinguished by payload mask) +## ``` +## +## AccLeaf and StoLeaf blobs are produced by `blobifyTo(AccLeafRef, ...)` and `blobifyTo(StoLeafRef, ...)` +## respectively. The path prefix (`pfx: NibblesBuf`) is encoded as a hex-prefix byte sequence appended +## before the type byte. +## +## Maximum vtx_blob size: 117 bytes (`MAX_VERTEX_BLOB_SIZE`). +## +## ### leaf_blob (accLeaves / stoLeaves) +## +## Account and storage leaves stored in the `accLeaves` / `stoLeaves` caches are serialized +## as full `VertexRef` values using the same `blobifyTo(vtx, VOID_HASH_KEY, data)` call +## (no embedded hash key since these are cache entries, not trie nodes requiring a key). +## On decode, `deblobify(blob, VertexRef)` reconstructs the `AccLeafRef` or `StoLeafRef` +## including the `pfx` (path prefix) field. +## +## ### nil entries +## +## A `nil` value in `sTab`, `accLeaves`, or `stoLeaves` is a deletion marker +## (the key was explicitly set to nil in this frame to shadow a non-nil value in a parent frame). +## These are serialized with `is_nil = 0x00` and no following blob, and restored as `nil` on decode. +## + +{.push raises: [].} + +import + std/tables, + stew/endians2, + results, + ./aristo_desc, + ./aristo_blobify + +export results + +const TX_FRAME_VERSION = 0x01'u8 + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +template readU8(data: openArray[byte]; pos: var int): byte = + if pos >= data.len: return err(DeblobTxFrameTruncated) + let v = data[pos] + inc pos + v + +template readU16BE(data: openArray[byte]; pos: var int): uint16 = + if pos + 1 >= data.len: return err(DeblobTxFrameTruncated) + let v = uint16.fromBytesBE(data.toOpenArray(pos, pos + 1)) + pos += 2 + v + +template readU32BE(data: openArray[byte]; pos: var int): uint32 = + if pos + 3 >= data.len: return err(DeblobTxFrameTruncated) + let v = uint32.fromBytesBE(data.toOpenArray(pos, pos + 3)) + pos += 4 + v + +template readU64BE(data: openArray[byte]; pos: var int): uint64 = + if pos + 7 >= data.len: return err(DeblobTxFrameTruncated) + let v = uint64.fromBytesBE(data.toOpenArray(pos, pos + 7)) + pos += 8 + v + +const MaxInitTableHint = 1 shl 16 + ## Cap for untrusted-count → `initTable` capacity hints. See `tableHint`. + +template tableHint(count: uint32): int = + ## Bound the capacity passed to `initTable` when `count` is read from an + ## untrusted blob. Avoids the `int(uint32)` sign-flip on 32-bit platforms + ## (which would feed a negative `Natural` to `initTable` and raise + ## `RangeDefect`) and prevents a hostile blob from forcing a multi-billion + ## bucket pre-allocation on any platform. Realistic per-frame deltas are + ## a few thousand entries — orders of magnitude below the cap. The + ## per-entry parse loop catches any actual data truncation, so + ## under-allocating buckets is purely a performance hint. + int(min(count, MaxInitTableHint.uint32)) + +# ------------------------------------------------------------------------------ +# Public: serialise +# ------------------------------------------------------------------------------ + +proc blobifyTxFrame*(tx: AristoTxRef): seq[byte] = + var buf: seq[byte] + + buf.add TX_FRAME_VERSION + buf.add tx.vTop.uint64.toBytesBE + + buf.add (if tx.blockNumber.isSome: 0x01'u8 else: 0x00'u8) + buf.add tx.blockNumber.valueOr(0'u64).toBytesBE + + buf.add tx.sTab.len.uint32.toBytesBE + for rvid, vtx in tx.sTab: + let rvidb = blobify(rvid) + buf.add rvidb.len.byte + buf.add rvidb.data() + + if vtx.isNil: + buf.add 0x00'u8 + else: + buf.add 0x01'u8 + let key = tx.kMap.getOrDefault(rvid, VOID_HASH_KEY) + var vtxBuf: VertexBuf + vtx.blobifyTo(key, vtxBuf) + buf.add vtxBuf.len.uint16.toBytesBE + buf.add vtxBuf.data() + + buf.add tx.accLeaves.len.uint32.toBytesBE + for accPath, leaf in tx.accLeaves: + buf.add accPath.data + if leaf.isNil: + buf.add 0x00'u8 + else: + buf.add 0x01'u8 + var vtxBuf: VertexBuf + VertexRef(leaf).blobifyTo(VOID_HASH_KEY, vtxBuf) + buf.add vtxBuf.len.uint16.toBytesBE + buf.add vtxBuf.data() + + buf.add tx.stoLeaves.len.uint32.toBytesBE + for stoPath, leaf in tx.stoLeaves: + buf.add stoPath.data + if leaf.isNil: + buf.add 0x00'u8 + else: + buf.add 0x01'u8 + var vtxBuf: VertexBuf + VertexRef(leaf).blobifyTo(VOID_HASH_KEY, vtxBuf) + buf.add vtxBuf.len.uint16.toBytesBE + buf.add vtxBuf.data() + + buf + +# ------------------------------------------------------------------------------ +# Public: deserialise +# ------------------------------------------------------------------------------ + +type TxFrameData* = object + vTop*: VertexID + blockNumber*: Opt[uint64] + sTab*: Table[RootedVertexID, VertexRef] + kMap*: Table[RootedVertexID, HashKey] + accLeaves*: Table[Hash32, AccLeafRef] + stoLeaves*: Table[Hash32, StoLeafRef] + +proc deblobifyTxFrame*( + data: openArray[byte] +): Result[TxFrameData, AristoError] = + var pos = 0 + + let version = readU8(data, pos) + if version != TX_FRAME_VERSION: + return err(DeblobTxFrameVersion) + + var res: TxFrameData + res.vTop = VertexID(readU64BE(data, pos)) + + let bnFlag = readU8(data, pos) + let bnVal = readU64BE(data, pos) + res.blockNumber = if bnFlag != 0: Opt.some(bnVal) else: Opt.none(uint64) + + let sTabCount = readU32BE(data, pos) + res.sTab = initTable[RootedVertexID, VertexRef](tableHint(sTabCount)) + res.kMap = initTable[RootedVertexID, HashKey](tableHint(sTabCount)) + for _ in 0 ..< sTabCount: + let rvidLen = int(readU8(data, pos)) + if pos + rvidLen - 1 >= data.len: + return err(DeblobTxFrameTruncated) + let rvid = ?deblobify(data.toOpenArray(pos, pos + rvidLen - 1), RootedVertexID) + pos += rvidLen + + if readU8(data, pos) == 0x00: + res.sTab[rvid] = nil + else: + let blobLen = int(readU16BE(data, pos)) + if pos + blobLen - 1 >= data.len: + return err(DeblobTxFrameTruncated) + let blobEnd = pos + blobLen - 1 + let vtx = ?deblobify(data.toOpenArray(pos, blobEnd), VertexRef) + let key = deblobify(data.toOpenArray(pos, blobEnd), HashKey) + pos += blobLen + res.sTab[rvid] = vtx + key.isErrOr: + res.kMap[rvid] = value + + let accCount = readU32BE(data, pos) + res.accLeaves = initTable[Hash32, AccLeafRef](tableHint(accCount)) + for _ in 0 ..< accCount: + if pos + 31 >= data.len: + return err(DeblobTxFrameTruncated) + let h = Hash32.copyFrom(data.toOpenArray(pos, pos + 31)) + pos += 32 + if readU8(data, pos) == 0x00: + res.accLeaves[h] = nil + else: + let blobLen = int(readU16BE(data, pos)) + if pos + blobLen - 1 >= data.len: + return err(DeblobTxFrameTruncated) + let vtx = ?deblobify(data.toOpenArray(pos, pos + blobLen - 1), VertexRef) + pos += blobLen + if vtx.vType != AccLeaf: + return err(DeblobUnknown) + res.accLeaves[h] = AccLeafRef(vtx) + + let stoCount = readU32BE(data, pos) + res.stoLeaves = initTable[Hash32, StoLeafRef](tableHint(stoCount)) + for _ in 0 ..< stoCount: + if pos + 31 >= data.len: + return err(DeblobTxFrameTruncated) + let h = Hash32.copyFrom(data.toOpenArray(pos, pos + 31)) + pos += 32 + if readU8(data, pos) == 0x00: + res.stoLeaves[h] = nil + else: + let blobLen = int(readU16BE(data, pos)) + if pos + blobLen - 1 >= data.len: + return err(DeblobTxFrameTruncated) + let vtx = ?deblobify(data.toOpenArray(pos, pos + blobLen - 1), VertexRef) + pos += blobLen + if vtx.vType != StoLeaf: + return err(DeblobUnknown) + res.stoLeaves[h] = StoLeafRef(vtx) + + ok res + +# ------------------------------------------------------------------------------ +# End +# ------------------------------------------------------------------------------ diff --git a/execution_chain/db/kvt/kvt_tx_blobify.nim b/execution_chain/db/kvt/kvt_tx_blobify.nim new file mode 100644 index 0000000000..e4fb6ebcc0 --- /dev/null +++ b/execution_chain/db/kvt/kvt_tx_blobify.nim @@ -0,0 +1,112 @@ +# nimbus-eth1 +# Copyright (c) 2026 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or +# http://www.apache.org/licenses/LICENSE-2.0) +# * MIT license ([LICENSE-MIT](LICENSE-MIT) or +# http://opensource.org/licenses/MIT) +# at your option. This file may not be copied, modified, or distributed +# except according to those terms. + +## KVT -- transaction frame serialisation +## ======================================= +## +## Serialises the pending key-value delta of a `KvtTxRef` (its `sTab`) to a +## flat byte sequence for database storage and restoration. +## +## Wire format (all multi-byte integers big-endian): +## +## version : 1 byte = 0x01 +## sTab_count : 4 bytes +## per entry : +## key_len : 2 bytes +## key : key_len bytes +## val_len : 4 bytes +## val : val_len bytes + +{.push raises: [].} + +import + std/tables, + stew/endians2, + results, + ./[kvt_desc] + +export results + +const + KVT_TX_FRAME_VERSION = 0x01'u8 + + MaxInitTableHint = 1 shl 16 + ## Cap for untrusted-count → `initTable` capacity hints. See `tableHint`. + +template tableHint(count: uint32): int = + ## Bound the capacity passed to `initTable` when `count` is read from an + ## untrusted blob. Avoids the `int(uint32)` sign-flip on 32-bit platforms + ## (which would feed a negative `Natural` to `initTable` and raise + ## `RangeDefect`) and prevents a hostile blob from forcing a multi-billion + ## bucket pre-allocation on any platform. Realistic per-frame deltas are + ## a few thousand entries — orders of magnitude below the cap. The + ## per-entry parse loop catches any actual data truncation, so + ## under-allocating buckets is purely a performance hint. + int(min(count, MaxInitTableHint.uint32)) + +# ------------------------------------------------------------------------------ +# Public: serialise +# ------------------------------------------------------------------------------ + +proc blobifyKvtTxFrame*(tx: KvtTxRef): seq[byte] = + var buf: seq[byte] + buf.add KVT_TX_FRAME_VERSION + buf.add tx.sTab.len.uint32.toBytesBE + for k, v in tx.sTab: + buf.add k.len.uint16.toBytesBE + buf.add k + buf.add v.len.uint32.toBytesBE + buf.add v + buf + +# ------------------------------------------------------------------------------ +# Public: deserialise +# ------------------------------------------------------------------------------ + +proc deblobifyKvtTxFrame*( + data: openArray[byte] +): Result[Table[seq[byte], seq[byte]], KvtError] = + if data.len < 5: + return err(DataInvalid) + if data[0] != KVT_TX_FRAME_VERSION: + return err(DataInvalid) + + var pos = 1 + let count = uint32.fromBytesBE(data.toOpenArray(1, 4)) + pos = 5 + + var sTab = initTable[seq[byte], seq[byte]](tableHint(count)) + + for _ in 0 ..< count: + if pos + 1 >= data.len: + return err(DataInvalid) + let kLen = int(uint16.fromBytesBE(data.toOpenArray(pos, pos + 1))) + pos += 2 + if pos + kLen - 1 >= data.len: + return err(DataInvalid) + let k = @(data.toOpenArray(pos, pos + kLen - 1)) + pos += kLen + + if pos + 3 >= data.len: + return err(DataInvalid) + let vLen = int(uint32.fromBytesBE(data.toOpenArray(pos, pos + 3))) + pos += 4 + if pos + vLen - 1 >= data.len: + return err(DataInvalid) + let v = @(data.toOpenArray(pos, pos + vLen - 1)) + pos += vLen + + sTab[k] = v + + ok sTab + +# ------------------------------------------------------------------------------ +# End +# ------------------------------------------------------------------------------ diff --git a/execution_chain/db/storage_types.nim b/execution_chain/db/storage_types.nim index f29447dc68..f71f3995d5 100644 --- a/execution_chain/db/storage_types.nim +++ b/execution_chain/db/storage_types.nim @@ -32,6 +32,7 @@ type blockAccessList = 13 tail = 14 prunerState = 15 + txFrame = 16 DbKey* = object # The first byte stores the key type. The rest are key-specific values @@ -77,6 +78,11 @@ func prunerStateKey*(): DbKey {.inline.} = result.data[0] = byte ord(prunerState) result.dataEndPos = 1 +func txFrameKey*(h: Hash32): DbKey {.inline.} = + result.data[0] = byte ord(txFrame) + result.data[1 .. 32] = h.data + result.dataEndPos = 32 + func slotHashToSlotKey*(h: Hash32): DbKey {.inline.} = result.data[0] = byte ord(slotHashToSlot) result.data[1 .. 32] = h.data() diff --git a/execution_chain/db/tx_frame_db.nim b/execution_chain/db/tx_frame_db.nim new file mode 100644 index 0000000000..88da957322 --- /dev/null +++ b/execution_chain/db/tx_frame_db.nim @@ -0,0 +1,153 @@ +# nimbus-eth1 +# Copyright (c) 2026 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or +# http://www.apache.org/licenses/LICENSE-2.0) +# * MIT license ([LICENSE-MIT](LICENSE-MIT) or +# http://opensource.org/licenses/MIT) +# at your option. This file may not be copied, modified, or distributed except +# according to those terms. + +## CoreDb tx-frame database persistence +## ===================================== +## +## Stores the delta of a `CoreDbTxRef` (Aristo sTab/kMap/leaves + KVT sTab) +## into the KVT database under a block-hash key, enabling startup restore +## without replaying blocks -- analogous to `fcState` load in `fcu_db.nim`. +## +## Storage key : `txFrameKey(blockHash)` (DBKeyKind.txFrame = 16) +## Value layout (big-endian lengths): +## aristo_blob_len : 4 bytes +## aristo_blob : aristo_blob_len bytes +## kvt_blob_len : 4 bytes +## kvt_blob : kvt_blob_len bytes +## +## Serialization Process +## ===================== +## +## 1. After a block is finalized and checkpointed, call +## `storeTxFrame(target, src, blockHash)` where `src` is the block's +## per-frame delta and `target` is the shared staging frame that will be +## persisted. +## 2. Internally: +## - `blobifyTxFrame(src.aTx)` walks `sTab`, `kMap`, `accLeaves`, `stoLeaves` and produces the Aristo blob. +## - `blobifyKvtTxFrame(src.kTx)` walks `sTab` and produces the KVT blob. +## - The two blobs are length-prefixed and concatenated. +## - The result is written to KVT via `target.put(txFrameKey(blockHash), combinedBlob)`. +## 3. On the next `persist` call the entry is flushed to RocksDB alongside the block's trie changes. +## +## Deserialization Process (startup restore) +## ========================================= +## +## 1. On startup, the chain walks each branch block-by-block and calls +## `loadTxFrameAsChild(srcBase, parent, blockHash)` to materialise the +## per-block frame as a child of the previous frame in the branch. +## 2. Internally: +## - Read the combined blob from `srcBase`'s KVT. +## - Parse the 4-byte Aristo length, decode the Aristo blob via `deblobifyTxFrame`. +## - Parse the 4-byte KVT length, decode the KVT blob via `deblobifyKvtTxFrame`. +## - Create a new `CoreDbTxRef` via `parent.txFrameBegin()`. +## - Populate `aTx.sTab`, `aTx.kMap`, `aTx.accLeaves`, `aTx.stoLeaves`, `aTx.vTop`, `aTx.blockNumber` from +## the decoded Aristo data. +## - Populate `kTx.sTab` from the decoded KVT data. +## 3. Return the populated frame. The caller attaches it to the processing pipeline (checkpoint, snapshot, etc.) as +## the warm frame for the next block. +## +## Practical range: +## ================ +## - Empty block: < 5 KB +## - Average mainnet block: 500–700 KB +## - Dense DeFi block: 1–3 MB + +{.push raises: [].} + +import + stew/endians2, + eth/common/hashes, + results, + ./core_db/[base, base_desc], + ./aristo/aristo_tx_blobify, + ./kvt/[kvt_desc, kvt_tx_blobify], + ./storage_types + +export base, base_desc, results + +# ------------------------------------------------------------------------------ +# Public functions +# ------------------------------------------------------------------------------ + +proc storeTxFrame*( + target: CoreDbTxRef; + src: CoreDbTxRef; + blockHash: Hash32; + ): CoreDbRc[void] = + ## Serialise the Aristo and KVT deltas of `src` and write the result to + ## KVT under `txFrameKey(blockHash)` into `target`. Used by the chain + ## persistence layer to write each block's frame into the base frame. + let + aristoBlob = blobifyTxFrame(src.aTx) + kvtBlob = blobifyKvtTxFrame(src.kTx) + + var blob = newSeqOfCap[byte](8 + aristoBlob.len + kvtBlob.len) + blob.add aristoBlob.len.uint32.toBytesBE + blob.add aristoBlob + blob.add kvtBlob.len.uint32.toBytesBE + blob.add kvtBlob + + target.put(txFrameKey(blockHash).toOpenArray, blob) + +proc loadTxFrameAsChild*( + srcBase: CoreDbTxRef; + parent: CoreDbTxRef; + blockHash: Hash32; + ): CoreDbRc[CoreDbTxRef] = + ## Read the stored delta for `blockHash` from `srcBase`'s KVT and return + ## a new `CoreDbTxRef` rooted as a child of `parent`, with the stored + ## delta applied. Used by the chain persistence layer to materialise + ## per-block frames in the chain hierarchy without re-executing blocks. + let blob = srcBase.get(txFrameKey(blockHash).toOpenArray).valueOr: + return err(error) + + if blob.len < 8: + return err(DataInvalid.toError("blob too short")) + + # Length fields are read as uint32 and all size arithmetic is performed in + # uint64 to avoid truncation or signed overflow on 32-bit platforms. + let + blobLen = uint64(blob.len) + aLen = uint64(uint32.fromBytesBE(blob.toOpenArray(0, 3))) + if blobLen < 4'u64 + aLen + 4'u64: + return err(DataInvalid.toError("aristo region truncated")) + let kOff = 4'u64 + aLen + let kLen = uint64(uint32.fromBytesBE(blob.toOpenArray(int(kOff), int(kOff) + 3))) + if blobLen < kOff + 4'u64 + kLen: + return err(DataInvalid.toError("kvt region truncated")) + + let aData = deblobifyTxFrame(blob.toOpenArray(4, int(4'u64 + aLen) - 1)).valueOr: + return err(error.toError("aristo deblobify")) + + let kData = deblobifyKvtTxFrame( + blob.toOpenArray(int(kOff + 4'u64), int(kOff + 4'u64 + kLen) - 1)).valueOr: + return err(error.toError("kvt deblobify")) + + let frame = parent.txFrameBegin() + frame.aTx.sTab = aData.sTab + frame.aTx.kMap = aData.kMap + frame.aTx.accLeaves = aData.accLeaves + frame.aTx.stoLeaves = aData.stoLeaves + frame.aTx.vTop = aData.vTop + frame.aTx.blockNumber = aData.blockNumber + frame.kTx.sTab = kData + + ok frame + +proc deleteTxFrame*( + db: CoreDbTxRef; + blockHash: Hash32; + ): CoreDbRc[void] = + ## Remove the stored frame entry for `blockHash`. + db.del(txFrameKey(blockHash).toOpenArray) + +# ------------------------------------------------------------------------------ +# End +# ------------------------------------------------------------------------------ diff --git a/tests/all_tests.nim b/tests/all_tests.nim index 810b63e134..f12cb2e9ba 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -40,6 +40,7 @@ import test_snap, test_transaction_json, test_txpool, + test_tx_frame_blobify, test_networking, test_pooled_tx, test_stateless_witness_types, diff --git a/tests/test_tx_frame_blobify.nim b/tests/test_tx_frame_blobify.nim new file mode 100644 index 0000000000..fbca303de0 --- /dev/null +++ b/tests/test_tx_frame_blobify.nim @@ -0,0 +1,521 @@ +# nimbus-eth1 +# Copyright (c) 2026 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or +# http://www.apache.org/licenses/LICENSE-2.0) +# * MIT license ([LICENSE-MIT](LICENSE-MIT) or +# http://opensource.org/licenses/MIT) +# at your option. This file may not be copied, modified, or distributed except +# according to those terms. + +import + std/[strutils, tables], + pkg/chronos, + stew/endians2, + eth/common/hashes, + results, + ../execution_chain/common, + ../execution_chain/conf, + ../execution_chain/core/chain/forked_chain, + ../execution_chain/core/chain/forked_chain/chain_serialize, + ../execution_chain/core/tx_pool, + ../execution_chain/core/pooled_txs, + ../execution_chain/transaction, + ../execution_chain/db/aristo/[aristo_desc, aristo_tx_blobify], + ../execution_chain/db/kvt/[kvt_desc, kvt_tx_blobify], + ../execution_chain/db/[storage_types, tx_frame_db, ledger], + ../execution_chain/db/core_db/memory_only, + ../hive_integration/tx_sender, + unittest2 + +proc buildTxFrameBlob(aBlob, kBlob: openArray[byte]): seq[byte] = + result = newSeqOfCap[byte](8 + aBlob.len + kBlob.len) + result.add aBlob.len.uint32.toBytesBE + for b in aBlob: result.add b + result.add kBlob.len.uint32.toBytesBE + for b in kBlob: result.add b + +const + genesisFile = "tests/customgenesis/cancun123.json" + feeRecipient = address"0000000000000000000000000000000000000212" + recipient = address"00000000000000000000000000000000000000aa" + +type + TestEnv = object + config: ExecutionClientConf + com : CommonRef + chain : ForkedChainRef + xp : TxPoolRef + sender: TxSender + +proc setupEnv(): TestEnv = + let + config = makeConfig(@["--network:" & genesisFile]) + sender = TxSender.new(config.networkParams, 5) + com = CommonRef.new( + newCoreDbRef DefaultDbMemory, + config.networkId, + config.networkParams) + chain = ForkedChainRef.init(com) + TestEnv( + config: config, + com: com, + chain: chain, + xp: TxPoolRef.new(chain), + sender: sender) + +suite "TxFrame blobify round-trip": + + test "storage_types: txFrameKey has correct discriminator": + let h = Hash32.fromHex("0x" & "ab".repeat(32)) + let k = txFrameKey(h) + check k.dataEndPos == 32 + check k.data[0] == byte(16) # DBKeyKind.txFrame = 16 + check k.data[1 .. 32] == h.data + + test "aristo_tx_blobify: empty frame round-trip": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + let blob = blobifyTxFrame(frame.aTx) + let rc = deblobifyTxFrame(blob) + check rc.isOk + let d = rc.value + check d.vTop == frame.aTx.vTop + check d.blockNumber == frame.aTx.blockNumber + check d.sTab.len == 0 + check d.accLeaves.len == 0 + check d.stoLeaves.len == 0 + frame.dispose() + + test "kvt_tx_blobify: empty frame round-trip": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + let blob = blobifyKvtTxFrame(frame.kTx) + let rc = deblobifyKvtTxFrame(blob) + check rc.isOk + check rc.value.len == 0 + frame.dispose() + + test "aristo_tx_blobify: wrong version returns error": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + var blob = blobifyTxFrame(frame.aTx) + blob[0] = 0xFF'u8 + let rc = deblobifyTxFrame(blob) + check rc.isErr + check rc.error == DeblobTxFrameVersion + frame.dispose() + + test "kvt_tx_blobify: wrong version returns error": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + var blob = blobifyKvtTxFrame(frame.kTx) + blob[0] = 0xFF'u8 + let rc = deblobifyKvtTxFrame(blob) + check rc.isErr + check rc.error == DataInvalid + frame.dispose() + + test "aristo_tx_blobify: blockNumber round-trip": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + frame.aTx.blockNumber = Opt.some(42'u64) + let blob = blobifyTxFrame(frame.aTx) + let rc = deblobifyTxFrame(blob) + check rc.isOk + check rc.value.blockNumber == Opt.some(42'u64) + frame.dispose() + + test "kvt_tx_blobify: single entry round-trip": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + frame.kTx.sTab[@[1'u8, 2, 3]] = @[0xDE'u8, 0xAD, 0xBE, 0xEF] + let blob = blobifyKvtTxFrame(frame.kTx) + let rc = deblobifyKvtTxFrame(blob) + check rc.isOk + check rc.value.len == 1 + check rc.value[@[1'u8, 2, 3]] == @[0xDE'u8, 0xAD, 0xBE, 0xEF] + frame.dispose() + + test "forked-chain importBlock txFrame round-trip with transactions": + let env = setupEnv() + let + com = env.com + chain = env.chain + xp = env.xp + mx = env.sender + acc = mx.getAccount(0) + + xp.feeRecipient = feeRecipient + xp.prevRandao = default(Bytes32) + xp.timestamp = EthTime.now() + + # --- Block 1: assemble + import three real transactions --- + for i in 0..<3: + let ptx = mx.makeTx( + BaseTx( + gasLimit : 75000, + recipient: Opt.some(recipient), + amount : 100.u256), + acc, i.AccountNonce) + check xp.addTx(ptx).isOk + + let bundle1Rc = xp.assembleBlock() + check bundle1Rc.isOk + let blk1 = bundle1Rc.get.blk + check blk1.transactions.len == 3 + check (waitFor chain.importBlock(blk1)).isOk + xp.removeNewBlockTxs(blk1) + + let + blk1Hash = blk1.header.computeBlockHash + txFrame1 = chain.txFrame(blk1Hash) + + # --- Capture pre-state from the populated txFrame --- + let + preSTabLen = txFrame1.aTx.sTab.len + preKMapLen = txFrame1.aTx.kMap.len + preAccLeavesLen = txFrame1.aTx.accLeaves.len + preStoLeavesLen = txFrame1.aTx.stoLeaves.len + preVTop = txFrame1.aTx.vTop + preBlockNumber = txFrame1.aTx.blockNumber + preKvtLen = txFrame1.kTx.sTab.len + preRecipientBalance = LedgerRef.init(txFrame1).getBalance(recipient) + preSenderBalance = LedgerRef.init(txFrame1).getBalance(acc.address) + check preSTabLen > 0 + check preAccLeavesLen >= 2 # at least sender + recipient + check preKvtLen > 0 + check preRecipientBalance == 300.u256 # 3 txs * 100 wei + check txFrame1.getBlockHeader(blk1Hash).isOk + + # --- Round-trip both halves through blobify/deblobify --- + let aBlob = blobifyTxFrame(txFrame1.aTx) + let kBlob = blobifyKvtTxFrame(txFrame1.kTx) + check aBlob.len > 1 + check kBlob.len > 1 + + let restored = com.db.baseTxFrame().txFrameBegin() + + let aRc = deblobifyTxFrame(aBlob) + check aRc.isOk + let aData = aRc.value + restored.aTx.sTab = aData.sTab + restored.aTx.kMap = aData.kMap + restored.aTx.accLeaves = aData.accLeaves + restored.aTx.stoLeaves = aData.stoLeaves + restored.aTx.vTop = aData.vTop + restored.aTx.blockNumber = aData.blockNumber + + let kRc = deblobifyKvtTxFrame(kBlob) + check kRc.isOk + restored.kTx.sTab = kRc.value + + # --- Round-trip equality assertions --- + check restored.aTx.sTab.len == preSTabLen + check restored.aTx.kMap.len == preKMapLen + check restored.aTx.accLeaves.len == preAccLeavesLen + check restored.aTx.stoLeaves.len == preStoLeavesLen + check restored.aTx.vTop == preVTop + check restored.aTx.blockNumber == preBlockNumber + check restored.kTx.sTab.len == preKvtLen + + # --- Functional reads on restored frame --- + check LedgerRef.init(restored).getBalance(recipient) == preRecipientBalance + check LedgerRef.init(restored).getBalance(acc.address) == preSenderBalance + check restored.getBlockHeader(blk1Hash).isOk + # Each of blk1's transactions should be retrievable from the restored frame + for idx in 0 ..< blk1.transactions.len: + let txRc = restored.getTransactionByIndex(blk1.header.txRoot, idx.uint16) + check txRc.isOk + + # --- Block 2: two more transactions on top of the chain (which sits on + # the same state we just round-tripped) --- + for i in 3..<5: + let ptx = mx.makeTx( + BaseTx( + gasLimit : 75000, + recipient: Opt.some(recipient), + amount : 100.u256), + acc, i.AccountNonce) + check xp.addTx(ptx).isOk + + xp.timestamp = xp.timestamp + 1 + let bundle2Rc = xp.assembleBlock() + check bundle2Rc.isOk + let blk2 = bundle2Rc.get.blk + check blk2.transactions.len == 2 + check (waitFor chain.importBlock(blk2)).isOk + + let + blk2Hash = blk2.header.computeBlockHash + txFrame2 = chain.txFrame(blk2Hash) + check LedgerRef.init(txFrame2).getBalance(recipient) == + preRecipientBalance + 200.u256 # blk2: 2 txs * 100 wei + check txFrame2.getBlockHeader(blk2Hash).isOk + check txFrame2.aTx.accLeaves.len > 0 + + restored.dispose() + + test "forked-chain serialize/deserialize uses txFrame blobs": + let env = setupEnv() + let + com = env.com + chain = env.chain + xp = env.xp + mx = env.sender + acc = mx.getAccount(0) + + xp.feeRecipient = feeRecipient + xp.prevRandao = default(Bytes32) + xp.timestamp = EthTime.now() + + # --- blk1: 3 transactions --- + for i in 0..<3: + let ptx = mx.makeTx( + BaseTx( + gasLimit : 75000, + recipient: Opt.some(recipient), + amount : 100.u256), + acc, i.AccountNonce) + check xp.addTx(ptx).isOk + + let bundle1 = xp.assembleBlock().get + let blk1 = bundle1.blk + check blk1.transactions.len == 3 + check (waitFor chain.importBlock(blk1)).isOk + xp.removeNewBlockTxs(blk1) + + # --- blk2: 2 more transactions --- + for i in 3..<5: + let ptx = mx.makeTx( + BaseTx( + gasLimit : 75000, + recipient: Opt.some(recipient), + amount : 100.u256), + acc, i.AccountNonce) + check xp.addTx(ptx).isOk + + xp.timestamp = xp.timestamp + 1 + let bundle2 = xp.assembleBlock().get + let blk2 = bundle2.blk + check blk2.transactions.len == 2 + check (waitFor chain.importBlock(blk2)).isOk + xp.removeNewBlockTxs(blk2) + + let + blk1Hash = blk1.header.computeBlockHash + blk2Hash = blk2.header.computeBlockHash + + # --- Capture pre-state from the chain's per-block txFrames --- + let + txFrame1 = chain.txFrame(blk1Hash) + txFrame2 = chain.txFrame(blk2Hash) + let + preBlk1STabLen = txFrame1.aTx.sTab.len + preBlk1AccLeavesLen = txFrame1.aTx.accLeaves.len + preBlk1VTop = txFrame1.aTx.vTop + preBlk1KvtLen = txFrame1.kTx.sTab.len + preBlk1Recipient = LedgerRef.init(txFrame1).getBalance(recipient) + preBlk1Sender = LedgerRef.init(txFrame1).getBalance(acc.address) + preBlk2Recipient = LedgerRef.init(txFrame2).getBalance(recipient) + preChainBlocks = chain.hashToBlock.len + preChainHeads = chain.heads.len + check preBlk1Recipient == 300.u256 + check preBlk2Recipient == 500.u256 + check preBlk1STabLen > 0 + check preBlk1KvtLen > 0 + + # --- Serialize + persist --- + let serializeFrame = chain.baseTxFrame + check chain.serialize(serializeFrame).isOk + serializeFrame.checkpoint(chain.base.header.number, skipSnapshot = true) + com.db.persist(serializeFrame) + + # --- New ForkedChainRef on the same com; deserialize from disk --- + var fc = ForkedChainRef.init(com) + let dRc = fc.deserialize() + check dRc.isOk + + # --- High-level shape matches --- + check fc.hashToBlock.len == preChainBlocks + check fc.heads.len == preChainHeads + + # --- Loaded blobs are deleted from the base frame so they don't + # accumulate across restart/prune cycles. KVT marks deletions with an + # empty-value tombstone in the delta layer: a read through baseTxFrame + # returns Ok with an empty seq, which `loadTxFrameAsChild` must then + # catch via the `blob.len < 8` size check (NOT slip through into + # deblobifyTxFrame). `.valueOr` does not fire here because `get` + # returns Ok(empty seq). --- + let blk1Load = + loadTxFrameAsChild(fc.baseTxFrame, fc.baseTxFrame, blk1Hash) + let blk2Load = + loadTxFrameAsChild(fc.baseTxFrame, fc.baseTxFrame, blk2Hash) + check blk1Load.isErr and "blob too short" in blk1Load.error.ctx + check blk2Load.isErr and "blob too short" in blk2Load.error.ctx + + # --- Per-block field equality proves no-replay path --- + # If replay() were still being used, freshly-built deltas would yield + # different sTab/vTop than the originals. Byte-for-byte equality here + # is only achievable by restoring the persisted blob. + let restoredBlk1 = fc.txFrame(blk1Hash) + check restoredBlk1.aTx.sTab.len == preBlk1STabLen + check restoredBlk1.aTx.accLeaves.len == preBlk1AccLeavesLen + check restoredBlk1.aTx.vTop == preBlk1VTop + check restoredBlk1.kTx.sTab.len == preBlk1KvtLen + + # --- Functional reads on the restored chain --- + check LedgerRef.init(restoredBlk1).getBalance(recipient) == preBlk1Recipient + check LedgerRef.init(restoredBlk1).getBalance(acc.address) == preBlk1Sender + check restoredBlk1.getBlockHeader(blk1Hash).isOk + + let restoredBlk2 = fc.txFrame(blk2Hash) + check LedgerRef.init(restoredBlk2).getBalance(recipient) == preBlk2Recipient + check restoredBlk2.getBlockHeader(blk2Hash).isOk + + # --- Continuation: import blk3 with two more txs onto the + # deserialized chain. Proves the restored frames remain writable. --- + let xp2 = TxPoolRef.new(fc) + xp2.feeRecipient = feeRecipient + xp2.prevRandao = default(Bytes32) + xp2.timestamp = xp.timestamp + 1 + for i in 5..<7: + let ptx = mx.makeTx( + BaseTx( + gasLimit : 75000, + recipient: Opt.some(recipient), + amount : 100.u256), + acc, i.AccountNonce) + check xp2.addTx(ptx).isOk + + let bundle3 = xp2.assembleBlock().get + let blk3 = bundle3.blk + check blk3.transactions.len == 2 + check (waitFor fc.importBlock(blk3)).isOk + + let blk3Hash = blk3.header.computeBlockHash + let restoredBlk3 = fc.txFrame(blk3Hash) + check LedgerRef.init(restoredBlk3).getBalance(recipient) == + preBlk2Recipient + 200.u256 + + test "loadTxFrameAsChild: missing key returns Err": + let coreDb = newCoreDbRef(AristoDbMemory) + let h = Hash32.fromHex("0x" & "21".repeat(32)) + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + + test "loadTxFrameAsChild: blob shorter than 8 bytes returns DataInvalid": + let coreDb = newCoreDbRef(AristoDbMemory) + let h = Hash32.fromHex("0x" & "22".repeat(32)) + check coreDb.baseTxFrame.put( + txFrameKey(h).toOpenArray, @[1'u8, 2, 3]).isOk + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + check "blob too short" in rc.error.ctx + + test "loadTxFrameAsChild: aristo region truncated returns Err": + let coreDb = newCoreDbRef(AristoDbMemory) + let h = Hash32.fromHex("0x" & "23".repeat(32)) + var blob = newSeq[byte]() + blob.add uint32(1000).toBytesBE + blob.add uint32(0).toBytesBE + check coreDb.baseTxFrame.put(txFrameKey(h).toOpenArray, blob).isOk + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + check "aristo region truncated" in rc.error.ctx + + test "loadTxFrameAsChild: kvt region truncated returns Err": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + let aBlob = blobifyTxFrame(frame.aTx) + frame.dispose() + + let h = Hash32.fromHex("0x" & "24".repeat(32)) + var blob = newSeqOfCap[byte](8 + aBlob.len) + blob.add aBlob.len.uint32.toBytesBE + for b in aBlob: blob.add b + blob.add uint32(1000).toBytesBE + check coreDb.baseTxFrame.put(txFrameKey(h).toOpenArray, blob).isOk + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + check "kvt region truncated" in rc.error.ctx + + test "loadTxFrameAsChild: corrupted aristo blob returns Err": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + var aBlob = blobifyTxFrame(frame.aTx) + aBlob[0] = 0xFF'u8 + let kBlob = blobifyKvtTxFrame(frame.kTx) + frame.dispose() + + let h = Hash32.fromHex("0x" & "25".repeat(32)) + let blob = buildTxFrameBlob(aBlob, kBlob) + check coreDb.baseTxFrame.put(txFrameKey(h).toOpenArray, blob).isOk + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + check "aristo deblobify" in rc.error.ctx + + test "loadTxFrameAsChild: corrupted kvt blob returns Err": + let coreDb = newCoreDbRef(AristoDbMemory) + let frame = coreDb.txFrameBegin() + let aBlob = blobifyTxFrame(frame.aTx) + var kBlob = blobifyKvtTxFrame(frame.kTx) + kBlob[0] = 0xFF'u8 + frame.dispose() + + let h = Hash32.fromHex("0x" & "26".repeat(32)) + let blob = buildTxFrameBlob(aBlob, kBlob) + check coreDb.baseTxFrame.put(txFrameKey(h).toOpenArray, blob).isOk + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isErr + check "kvt deblobify" in rc.error.ctx + + test "loadTxFrameAsChild: deleted (tombstone) hash returns 'blob too short'": + let coreDb = newCoreDbRef(AristoDbMemory) + let src = coreDb.txFrameBegin() + let h = Hash32.fromHex("0x" & "32".repeat(32)) + check coreDb.baseTxFrame.storeTxFrame(src, h).isOk + src.dispose() + + let base = coreDb.baseTxFrame + let pre = loadTxFrameAsChild(base, base, h) + check pre.isOk + pre.value.dispose() + + check coreDb.baseTxFrame.deleteTxFrame(h).isOk + let probe = coreDb.baseTxFrame.get(txFrameKey(h).toOpenArray) + check probe.isOk and probe.value.len == 0 + + let base2 = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base2, base2, h) + check rc.isErr + check "blob too short" in rc.error.ctx + + test "loadTxFrameAsChild: stored frame round-trips": + let coreDb = newCoreDbRef(AristoDbMemory) + let src = coreDb.txFrameBegin() + src.aTx.blockNumber = Opt.some(99'u64) + src.kTx.sTab[@[0x10'u8]] = @[0x20'u8, 0x30] + + let h = Hash32.fromHex("0x" & "27".repeat(32)) + check coreDb.baseTxFrame.storeTxFrame(src, h).isOk + + let base = coreDb.baseTxFrame + let rc = loadTxFrameAsChild(base, base, h) + check rc.isOk + let loaded = rc.value + check loaded.aTx.blockNumber == Opt.some(99'u64) + check loaded.kTx.sTab.len == 1 + check loaded.kTx.sTab[@[0x10'u8]] == @[0x20'u8, 0x30] + + loaded.dispose() + src.dispose() + +when isMainModule: + discard