Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 42 additions & 40 deletions logos_chain/chain/block_validation.nim
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,33 @@
import
results,
../core/local_tree,
../core/mantle/tx_validation,
../ledger/ledger,
libp2p/crypto/ed25519/ed25519

export tx_validation.StatelessLedgerError

from ../core/types import
Block, createBlockRoot, ExpectedBedrockVersion, MaxBlockSize, header, blockId
from ../core/mantle/primitives import MaxBlockTxs
from ../core/mantle/tx_types import
SignedMantleTx,
encodeSignedMantleTx,
isSupportedOpcode,
opPayloadToOpcode,
expectedOpProofKindForOpcode
from ../core/mantle/tx_types import SignedMantleTx, encodeSignedMantleTx

type
BlockValidationErrorKind* {.pure.} = enum
InvalidBlockStructure
TreeAdmissionRejected
HeaderRejected
TransactionsRejected
StatelessTxRejected

BlockValidationError* = object
case kind*: BlockValidationErrorKind
of BlockValidationErrorKind.HeaderRejected, BlockValidationErrorKind.TransactionsRejected:
ledgerError*: LedgerError
of BlockValidationErrorKind.StatelessTxRejected:
statelessError*: StatelessLedgerError
else:
discard

func txBytesLen(txs: openArray[SignedMantleTx]): int =
var total = 0
Expand Down Expand Up @@ -57,50 +72,38 @@ func validateBlockHeader*(blk: Block): bool =

true

func validateBlockBody*(blk: Block): bool =
proc validateBlockBody*(blk: Block): Result[void, BlockValidationError] =
if blk.signature == default(Ed25519Signature):
return false
return err(BlockValidationError(kind: BlockValidationErrorKind.InvalidBlockStructure))

if blk.txs.len > MaxBlockTxs:
return false
return err(BlockValidationError(kind: BlockValidationErrorKind.InvalidBlockStructure))

## Do NOT change this evaluation order: per-transaction validation MUST run
## before txBytesLen to ensure opcode/payload/proof structures are valid
## prior to transaction serialization in encodeSignedMantleTx (preventing
## AssertionDefect on malformed input).
for tx in blk.txs:
if tx.tx.ops.len != tx.opProofs.len:
return false
for i in 0 ..< tx.tx.ops.len:
let op = tx.tx.ops[i]
if not isSupportedOpcode(op.opcode):
return false
if op.opcode != opPayloadToOpcode(op.payload):
return false
if tx.opProofs[i].kind != expectedOpProofKindForOpcode(op.opcode):
return false
validateMantleTxStateless(tx).isOkOr:
return err(BlockValidationError(
kind: BlockValidationErrorKind.StatelessTxRejected,
statelessError: error,
))

if txBytesLen(blk.txs) > MaxBlockSize:
return false
return err(BlockValidationError(kind: BlockValidationErrorKind.InvalidBlockStructure))

true
ok()

func validateBlock*(blk: Block): bool =
proc validateBlock*(blk: Block): Result[void, BlockValidationError] =
## Do NOT change this evaluation order: validateBlockBody MUST run before
## validateBlockHeader to ensure transaction count bounds (MaxBlockTxs) and
## per-transaction opcode/proof structures are verified prior to Merkle root
## construction in validateBlockHeader (preventing AssertionDefect on malformed input).
validateBlockBody(blk) and validateBlockHeader(blk)

type
BlockValidationErrorKind* {.pure.} = enum
InvalidBlockStructure
TreeAdmissionRejected
HeaderRejected
TransactionsRejected

BlockValidationError* = object
case kind*: BlockValidationErrorKind
of BlockValidationErrorKind.HeaderRejected, BlockValidationErrorKind.TransactionsRejected:
ledgerError*: LedgerError
else:
discard
?validateBlockBody(blk)
if not validateBlockHeader(blk):
return err(BlockValidationError(kind: BlockValidationErrorKind.InvalidBlockStructure))
ok()

proc validateBlockAndTransactions*(
blk: Block,
Expand All @@ -109,8 +112,7 @@ proc validateBlockAndTransactions*(
): Result[BlockId, BlockValidationError] =
## Read-only validation: stateless structural checks, localTree extension,
## and parent existence check in the ledger.
if not validateBlock(blk):
return err(BlockValidationError(kind: BlockValidationErrorKind.InvalidBlockStructure))
?validateBlock(blk)
if not localTree.canExtend(blk.header):
return err(BlockValidationError(kind: BlockValidationErrorKind.TreeAdmissionRejected))
if ledger.state(blk.header.parentBlock).isNone:
Expand All @@ -129,7 +131,7 @@ proc prepareBlockUpdate*(
let prepared = ledger.prepareUpdate(
id, blk.header.parentBlock, blk.header.slot, blk.header.proofOfLeadership, blk.txs
).valueOr:
if error in {LedgerError.InvalidSlot, LedgerError.InvalidProof}:
if error in {LedgerError.InvalidSlot, LedgerError.InvalidProof, LedgerError.ParentNotFound, LedgerError.UnsupportedLotteryF, LedgerError.VerifierNotInitialised}:
return err(BlockValidationError(kind: BlockValidationErrorKind.HeaderRejected, ledgerError: error))
else:
return err(BlockValidationError(kind: BlockValidationErrorKind.TransactionsRejected, ledgerError: error))
Expand Down
8 changes: 7 additions & 1 deletion logos_chain/chain/chain.nim
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import
./block_validation,
./genesis

export genesis, local_tree
export genesis, local_tree, block_validation
export ledger except config

type
Expand All @@ -34,17 +34,21 @@ type
InvalidStructure
TreeRejected
LedgerRejected
StatelessTxRejected

BlockApplyError* = object
case kind*: BlockApplyErrorKind
of BlockApplyErrorKind.LedgerRejected:
ledgerError*: LedgerError
of BlockApplyErrorKind.StatelessTxRejected:
statelessError*: StatelessLedgerError
else:
discard

func `$`*(e: BlockApplyError): string =
case e.kind
of BlockApplyErrorKind.LedgerRejected: "ledger: " & $e.ledgerError
of BlockApplyErrorKind.StatelessTxRejected: "stateless tx: " & $e.statelessError
else: $e.kind

func ledgerConfig*(settings: DeploymentSettings): LedgerConfig =
Expand Down Expand Up @@ -122,6 +126,8 @@ proc tryApplyBlock*(
of BlockValidationErrorKind.HeaderRejected,
BlockValidationErrorKind.TransactionsRejected:
return err(BlockApplyError(kind: LedgerRejected, ledgerError: error.ledgerError))
of BlockValidationErrorKind.StatelessTxRejected:
return err(BlockApplyError(kind: StatelessTxRejected, statelessError: error.statelessError))
if not chain.localTree.addBlockToTree(blk):
return err(BlockApplyError(kind: TreeRejected))
chain.ledger.commitUpdate(prepared.id, prepared.state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

import
results,
../core/mantle/[operations, proofs],
../core/crypto/types,
../zk/poc,
../zk/poseidon2/hasher
./[operations, proofs],
../crypto/types,
../../zk/poc,
../../zk/poseidon2/hasher

export poc, results

Expand Down
147 changes: 147 additions & 0 deletions logos_chain/core/mantle/tx_validation.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# nimbos
# Copyright (c) 2026 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option, this file may not be copied, modified, or distributed except according to those terms.

## Stateless and cryptographic transaction validation for Mantle transactions.
## Spec: [Bedrock v1.1 — Mantle Specification v1.10.0](https://github.com/logos-co/logos-lips/blob/435a6f183a92b871473d80a720b427f70cbf1b68/docs/blockchain/raw/bedrock-v1.1-mantle-specification.md)

{.push raises: [], gcsafe.}

import
std/sets,
results,
libp2p/crypto/ed25519/ed25519,
libp2p/multiaddress,
./poc_verifier,
./primitives,
./operations,
./proofs,
./tx_hashing,
./tx_types

type
StatelessLedgerError* {.pure.} = enum
## Stateless and monotonic terminal transaction errors.
DoubleSpend ## same NoteId appears twice as an input across the transaction
ZeroValueNote ## output Note has value == 0
InvalidProof ## ZK multi-sig, leader-proof, or signature verify failed
UnsupportedOp ## Op kind not yet wired in this ledger version
EmptyLocators ## SDP Declare locators must contain at least one element
TooManyLocators
InvalidLocator
InvalidChannelConfig ## ChannelConfig has zero threshold or empty keys
EmptyInputs ## Deposit/Withdraw/Transfer must consume at least one note
VerifierNotInitialised ## per-circuit VK singleton wasn't installed at startup

export results, StatelessLedgerError

proc validateMantleTxStateless*(
tx: SignedMantleTx,
verifyProof: ProofOfClaimVerifier = verifyProofOfClaim,
): Result[void, StatelessLedgerError] =
## Stateless transaction validation in a single pass: structural checks
## (opcode support, payload consistency, proof kind matching) plus standalone
## cryptographic proof and signature verifications (Ed25519 signatures and PoC Groth16).
if tx.tx.ops.len != tx.opProofs.len:
return err(StatelessLedgerError.InvalidProof)

var txHash: Opt[ZkHash]
template getTxHash(): ZkHash =
txHash.valueOr:
let h = mantleTxHash(tx.tx)
txHash = Opt.some(h)
h

var allInputs: HashSet[NoteId]

for i in 0 ..< tx.tx.ops.len:
let op = tx.tx.ops[i]
let proof = tx.opProofs[i]
if not isSupportedOpcode(op.opcode):
return err(StatelessLedgerError.UnsupportedOp)
if op.opcode != opPayloadToOpcode(op.payload):
return err(StatelessLedgerError.UnsupportedOp)
if proof.kind != expectedOpProofKindForOpcode(op.opcode):
return err(StatelessLedgerError.InvalidProof)

case op.payload.kind
of Transfer:
let t = op.payload.transfer
if t.inputs.noteIds.len == 0:
return err(StatelessLedgerError.EmptyInputs)
for inputId in t.inputs.noteIds:
if allInputs.containsOrIncl(inputId):
return err(StatelessLedgerError.DoubleSpend)
for note in t.outputs.notes:
if note.value == 0:
return err(StatelessLedgerError.ZeroValueNote)

of ChannelDeposit:
let d = op.payload.channelDeposit
if d.inputs.len == 0:
return err(StatelessLedgerError.EmptyInputs)
for inputId in d.inputs:
if allInputs.containsOrIncl(inputId):
return err(StatelessLedgerError.DoubleSpend)

of ChannelWithdraw:
let w = op.payload.channelWithdraw
if w.inputs.len == 0:
return err(StatelessLedgerError.EmptyInputs)
for inputId in w.inputs:
if allInputs.containsOrIncl(inputId):
return err(StatelessLedgerError.DoubleSpend)

of ChannelTransfer:
let ct = op.payload.channelTransfer
if ct.inputs.len == 0:
return err(StatelessLedgerError.EmptyInputs)
for inputId in ct.inputs:
if allInputs.containsOrIncl(inputId):
return err(StatelessLedgerError.DoubleSpend)
for note in ct.outputs:
if note.value == 0:
return err(StatelessLedgerError.ZeroValueNote)

of ChannelConfig:
let cfg = op.payload.channelConfig
if cfg.keys.len == 0 or cfg.configurationThreshold == 0 or
cfg.configurationThreshold.int > cfg.keys.len or
cfg.transferThreshold == 0:
return err(StatelessLedgerError.InvalidChannelConfig)

of ChannelInscribe:
if not verify(proof.ed25519SigProof, getTxHash(), op.payload.channelInscribe.signer):
return err(StatelessLedgerError.InvalidProof)

of SdpDeclare:
let decl = op.payload.sdpDeclare
if decl.locators.len == 0:
return err(StatelessLedgerError.EmptyLocators)
if decl.locators.len > MaxSdpLocators:
return err(StatelessLedgerError.TooManyLocators)
for loc in decl.locators:
if not isValidLocator(loc):
return err(StatelessLedgerError.InvalidLocator)
if not verify(proof.declarationProof.ed25519Sig, getTxHash(), decl.providerId):
return err(StatelessLedgerError.InvalidProof)

of SdpWithdraw, SdpActive:
discard # No stateless-only invariants for SdpWithdraw / SdpActive

of LeaderClaim:
if verifyProof == nil:
return err(StatelessLedgerError.VerifierNotInitialised)
let claim = op.payload.leaderClaim
let public = proofOfClaimPublic(claim, claim.rewardsRoot, getTxHash())
let verified = verifyProof(proof.proofOfClaimProof, public).valueOr:
return err(StatelessLedgerError.VerifierNotInitialised)
if not verified:
return err(StatelessLedgerError.InvalidProof)

ok()

{.pop.}
Loading
Loading