mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Four defects, each confirmed by executing it before it was fixed and by mutation after. A block whose parent was an OLD accepted block satisfied height == parent+1, so Verify passed it and Accept applied it: the chain at height 3 rewound to 2, the last-accepted pointer and the height index moved with it, and the index went on naming the abandoned block at 3. A peer bootstrapping from it was served an incoherent chain. The parent must now be the tip or a block verified above it; Accept decides again under its own hold of the lock, because the tip moves between the two. Nothing bound a transaction or a block to the chain it was meant for. An address is the hash of a public key, so the same payer exists on every F-Chain, and a captured transaction authenticated verbatim on all of them and burned a balance there. Every chain sharing a genesis timestamp also shared a genesis id, so one chain's block resolved its parent on the others. The chain id now enters the signing preimage and the block id without travelling on the wire: each side supplies its own, so a mismatch is a failed signature and an unresolvable parent rather than a check someone can forget. A read that FAILED was reported as a read that found NOTHING. An unreadable last-accepted pointer left a live chain looking like a fresh one, with Initialize returning nil and the node ready to build height 1 over height 2. The same conflation on the epoch pointer seats a committee nobody elected, and on a nonce it reopens the replay window a nonce exists to close. One reader now separates absent from unreadable, and nonceOf reports rather than returns zero. HealthCheck likewise stops reporting a ledger it could not read as zero burned. MaxBlockTxs was applied by Verify, and Verify runs after the parse, so an 8 MB message carrying 1,524 transactions parsed to completion before any check ran. Bytes and count are both bounded at the first byte, and neither implies the other; selection stops at whichever comes first, so a proposer cannot build what its own parser refuses. Deleted: the tx-blob accounting check, which the canonical-encoding rule subsumes exactly (a blob with unread bytes re-serializes shorter), and the empty-path branch in NewHTTPHandler, whose one producer returns a literal. fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in the report, not padded. The harness now holds constant what production holds constant: one chain id and one genesis across every node of a chain, rather than a fresh id per node.
1158 lines
48 KiB
Go
1158 lines
48 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package fhevm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/chains/mpcvm/fhe"
|
|
"github.com/luxfi/database/memdb"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/runtime"
|
|
vmcore "github.com/luxfi/vm"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// Each test here pins a defence that an adversarial review found missing. They
|
|
// are written against the attack, not against the implementation: if a future
|
|
// change reopens the hole, the test that named it fails.
|
|
|
|
// C1: Transaction.effect() qualified an epoch-advance vote by the PROPOSAL, but
|
|
// the decision a member votes on is the epoch, of which exactly one is ever
|
|
// open. Two votes from one member for two different committees therefore had
|
|
// two different effects, survived admission and Verify, and collided in Accept
|
|
// — and a block that passes Verify on every validator and then fails Accept on
|
|
// every validator halts the chain at that height by design.
|
|
func TestC1_OneMemberCannotDoubleVoteOnAnEpoch(t *testing.T) {
|
|
vm, _, members := newDecryptVM(t, 3, 2)
|
|
proposalA, _ := newCommittee(t, 3)
|
|
proposalB, _ := newCommittee(t, 3)
|
|
|
|
voteA := advanceTx(t, members[0], 1, proposalA, 2, []byte("k"), 1)
|
|
voteB := advanceTx(t, members[0], 1, proposalB, 2, []byte("k"), 2)
|
|
|
|
require.Equal(t, voteA.effect(), voteB.effect(),
|
|
"one member, one open decision, one effect — whatever it votes for")
|
|
|
|
_, err := vm.SubmitTx(voteA)
|
|
require.NoError(t, err)
|
|
_, err = vm.SubmitTx(voteB)
|
|
require.ErrorIs(t, err, ErrDuplicateEffect, "admission must refuse the second vote")
|
|
|
|
// A peer can still propose the pair. Consensus must refuse the block rather
|
|
// than certify it and then fail to apply it.
|
|
require.ErrorIs(t, forceBlock(vm, voteA, voteB).Verify(context.Background()),
|
|
ErrDuplicateEffect, "Verify must refuse a block carrying both")
|
|
}
|
|
|
|
// H1: a payer could queue a nonce gap. Admission took it (any nonce above the
|
|
// committed one), Verify refused the block for it, and the engine discards a
|
|
// Verify-failed block WITHOUT calling Reject — so the transaction vanished and
|
|
// its effect claim did not. Register's effect is payer-independent, so any
|
|
// funded account could permanently block any handle on that node, for free, and
|
|
// take the rest of the queue with it every round.
|
|
func TestH1_PoisonedQueueIsImpossible(t *testing.T) {
|
|
attacker := newTestKey(t)
|
|
victim := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(attacker, victim), committee, 1)
|
|
|
|
target := digestOf("a-handle-someone-else-wants")
|
|
|
|
// The gap never enters the queue, so it can never poison a block.
|
|
_, err := vm.SubmitTx(registerTx(t, attacker, testScheme, target, 3))
|
|
require.ErrorIs(t, err, ErrBadNonce)
|
|
require.Empty(t, vm.mempool)
|
|
require.Empty(t, vm.claims, "a refused transaction claims nothing")
|
|
|
|
// The victim's honest registration goes through untouched.
|
|
honest := registerTx(t, victim, testScheme, target, 1)
|
|
_, err = vm.SubmitTx(honest)
|
|
require.NoError(t, err)
|
|
acceptQueued(t, vm)
|
|
rec, ok := vm.Ciphertext(honest.Subject)
|
|
require.True(t, ok)
|
|
require.Equal(t, victim.addr, fee.Account(rec.Owner))
|
|
}
|
|
|
|
// H1: even a well-nonced transaction that is selected into a block and then
|
|
// discarded must leave its claim intact and its place in the queue, because the
|
|
// engine is free to drop a proposal it never accepts or rejects.
|
|
func TestH1_ClaimTracksTheQueueExactly(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
tx := registerTx(t, k, testScheme, digestOf("claimed"), 1)
|
|
_, err := vm.SubmitTx(tx)
|
|
require.NoError(t, err)
|
|
require.Len(t, vm.claims, 1)
|
|
require.Equal(t, uint64(1), vm.queued[k.addr])
|
|
|
|
// Build and then walk away from the proposal entirely — no Accept, no Reject.
|
|
_, err = vm.BuildBlock(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, vm.mempool, 1, "the transaction is still queued")
|
|
require.Len(t, vm.claims, 1, "and still holds exactly its own claim")
|
|
|
|
// It still works, and acceptance is what clears both.
|
|
acceptQueued(t, vm)
|
|
require.Empty(t, vm.mempool)
|
|
require.Empty(t, vm.claims)
|
|
require.Empty(t, vm.queued)
|
|
|
|
// With the claim released, the handle is reachable again by whoever needs it
|
|
// — a claim can no longer outlive the transaction that made it.
|
|
_, ok := vm.Ciphertext(tx.Subject)
|
|
require.True(t, ok)
|
|
}
|
|
|
|
// H1: one unfit transaction must not take a block's worth of honest ones with
|
|
// it. BuildBlock runs the same admission rule Verify runs and simply leaves out
|
|
// what does not fit.
|
|
func TestH1_OneUnfitTransactionDoesNotDestroyTheBlock(t *testing.T) {
|
|
rich := newTestKey(t)
|
|
poor := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, map[string]uint64{
|
|
rich.hexAddr(): testFund,
|
|
poor.hexAddr(): 1_000, // cannot afford a single operation
|
|
}, committee, 1)
|
|
|
|
good := registerTx(t, rich, testScheme, digestOf("good"), 1)
|
|
_, err := vm.SubmitTx(good)
|
|
require.NoError(t, err)
|
|
|
|
// The poor payer's transaction is refused at admission, but a peer could
|
|
// still gossip it into our mempool shape; put it there directly to prove
|
|
// selection copes.
|
|
broke := registerTx(t, poor, testScheme, digestOf("broke"), 1)
|
|
vm.mempoolLock.Lock()
|
|
vm.mempool = append(vm.mempool, broke)
|
|
vm.mempoolLock.Unlock()
|
|
|
|
blk, err := vm.BuildBlock(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, blk.(*Block).transactions, 1, "the unaffordable one is left out")
|
|
require.NoError(t, blk.(*Block).Verify(context.Background()),
|
|
"a proposer cannot build a block its own Verify would reject")
|
|
require.NoError(t, blk.(*Block).Accept(context.Background()))
|
|
|
|
_, ok := vm.Ciphertext(good.Subject)
|
|
require.True(t, ok, "the honest transaction is not collateral damage")
|
|
}
|
|
|
|
// H2: nothing bounded a block's timestamp or height against its parent, and
|
|
// chain time drives every expiry F enforces. A proposer could rewind time to
|
|
// revive an expired permit, jump to year 36812 to expire everything at once, or
|
|
// skip heights entirely.
|
|
func TestH2_ChainTimeAndHeightAreBounded(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
base := time.Now()
|
|
vm.clock.Set(base)
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("tip"), 1))
|
|
parent := vm.lastBlock
|
|
|
|
next := registerTx(t, k, testScheme, digestOf("next"), 2)
|
|
_, err := vm.SubmitTx(next)
|
|
require.NoError(t, err)
|
|
|
|
at := func(ts time.Time, height uint64) *Block {
|
|
blk := &Block{
|
|
parentID: vm.lastAccepted, height: height, timestamp: ts,
|
|
transactions: []*Transaction{next}, vm: vm,
|
|
}
|
|
blk.id = blk.computeID()
|
|
return blk
|
|
}
|
|
|
|
// Time may not run backwards past the parent.
|
|
require.ErrorIs(t,
|
|
at(parent.timestamp.Add(-time.Second), parent.height+1).Verify(context.Background()),
|
|
ErrInvalidBlock)
|
|
|
|
// Nor leap beyond the skew allowance.
|
|
require.ErrorIs(t,
|
|
at(vm.clock.Time().Add(MaxFutureSkew+time.Second), parent.height+1).Verify(context.Background()),
|
|
ErrInvalidBlock)
|
|
require.ErrorIs(t,
|
|
at(time.Unix(1<<40, 0), parent.height+1).Verify(context.Background()),
|
|
ErrInvalidBlock)
|
|
|
|
// Heights are consecutive.
|
|
require.ErrorIs(t, at(vm.clock.Time(), parent.height+2).Verify(context.Background()), ErrInvalidBlock)
|
|
require.ErrorIs(t, at(vm.clock.Time(), parent.height).Verify(context.Background()), ErrInvalidBlock)
|
|
|
|
// A block that sits correctly on its parent is fine, including one exactly
|
|
// at the parent's timestamp and one at the edge of the allowance.
|
|
require.NoError(t, at(parent.timestamp, parent.height+1).Verify(context.Background()))
|
|
require.NoError(t, at(vm.clock.Time().Add(MaxFutureSkew), parent.height+1).Verify(context.Background()))
|
|
}
|
|
|
|
// H2: a proposer must never produce a block its own Verify would reject. Two
|
|
// blocks inside one clock tick is the ordinary case — chain time steps forward
|
|
// anyway — and a clock so far behind its own tip that it cannot step forward
|
|
// legally declines to propose.
|
|
func TestH2_ProposerNeverBuildsABlockItWouldReject(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
base := time.Now()
|
|
vm.clock.Set(base)
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("first"), 1))
|
|
parent := vm.lastBlock
|
|
|
|
// The clock has not moved: the ordinary two-blocks-in-one-tick case.
|
|
_, err := vm.SubmitTx(registerTx(t, k, testScheme, digestOf("second"), 2))
|
|
require.NoError(t, err)
|
|
blk, err := vm.BuildBlock(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, blk.Timestamp().After(parent.Timestamp()),
|
|
"chain time advances even when the proposer's clock does not")
|
|
require.NoError(t, blk.(*Block).Verify(context.Background()))
|
|
require.NoError(t, blk.(*Block).Accept(context.Background()))
|
|
|
|
// A clock an hour behind its own tip cannot step forward legally, so it
|
|
// proposes nothing rather than a block it would itself reject.
|
|
vm.clock.Set(base.Add(-time.Hour))
|
|
_, err = vm.SubmitTx(registerTx(t, k, testScheme, digestOf("third"), 3))
|
|
require.NoError(t, err)
|
|
_, err = vm.BuildBlock(context.Background())
|
|
require.ErrorIs(t, err, errClockBehind)
|
|
}
|
|
|
|
// H3: the package claimed it holds no ciphertext body "structurally, not as a
|
|
// matter of discipline". It was discipline. Gas had no length term, nothing
|
|
// bounded Payload or Scheme, encoding/json ignored members it did not know, and
|
|
// Accept persists the transaction verbatim — so a megabyte of ciphertext rode
|
|
// onto the chain inside a register payload, for the same 81,000,000 nLUX a
|
|
// hundred bytes cost, and came back out of the block store.
|
|
func TestH3_BytesArePricedAndBounded(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
digest := digestOf("smuggled")
|
|
handle := deriveHandle(digest, testScheme)
|
|
|
|
// A payload that is a superset of the schema is refused outright: there is
|
|
// no member to hide anything in.
|
|
fat, err := json.Marshal(struct {
|
|
Digest [32]byte `json:"digest"`
|
|
Type uint8 `json:"type"`
|
|
Level int `json:"level"`
|
|
Size uint32 `json:"size"`
|
|
Body []byte `json:"body"`
|
|
}{Digest: digest, Type: 4, Level: 3, Size: 4096, Body: bytes.Repeat([]byte("X"), 1024)})
|
|
require.NoError(t, err)
|
|
|
|
smuggle := k.sign(t, &Transaction{
|
|
Type: TxRegisterCiphertext, Scheme: testScheme, Payer: k.addr,
|
|
Subject: handle, GasLimit: testGas, Nonce: 1, Payload: fat,
|
|
})
|
|
require.ErrorIs(t, smuggle.SyntacticVerify(), ErrInvalidPayload,
|
|
"a payload the schema does not describe is not a payload")
|
|
_, err = vm.SubmitTx(smuggle)
|
|
require.ErrorIs(t, err, ErrInvalidPayload)
|
|
|
|
// Trailing bytes after a well-formed payload are refused too.
|
|
trailing := k.sign(t, &Transaction{
|
|
Type: TxRegisterCiphertext, Scheme: testScheme, Payer: k.addr,
|
|
Subject: handle, GasLimit: testGas, Nonce: 1,
|
|
Payload: append(mustJSON(t, RegisterPayload{Digest: digest, Type: 4, Level: 3, Size: 4096}),
|
|
[]byte(`{"body":"more"}`)...),
|
|
})
|
|
require.ErrorIs(t, trailing.SyntacticVerify(), ErrInvalidPayload)
|
|
|
|
// Bulk is bounded outright, whatever shape it claims. The payload here is
|
|
// VALID and decodes to exactly the schema — whitespace is not content — so
|
|
// the only thing that can refuse it is the length bound itself. Filling it
|
|
// with bytes that are not JSON would prove nothing: the decoder would refuse
|
|
// those with or without a bound, and the test would pass over a chain that
|
|
// had none.
|
|
padded := append(mustJSON(t, RegisterPayload{Digest: digest, Type: 4, Level: 3, Size: 4096}),
|
|
bytes.Repeat([]byte(" "), MaxPayload)...)
|
|
require.Greater(t, len(padded), MaxPayload)
|
|
huge := k.sign(t, &Transaction{
|
|
Type: TxRegisterCiphertext, Scheme: testScheme, Payer: k.addr,
|
|
Subject: handle, GasLimit: testGas, Nonce: 1, Payload: padded,
|
|
})
|
|
require.ErrorIs(t, huge.SyntacticVerify(), ErrInvalidPayload)
|
|
|
|
// The control: the same value, unpadded, is accepted — so what was refused
|
|
// was the size and nothing else.
|
|
require.NoError(t, k.sign(t, &Transaction{
|
|
Type: TxRegisterCiphertext, Scheme: testScheme, Payer: k.addr,
|
|
Subject: handle, GasLimit: testGas, Nonce: 1,
|
|
Payload: mustJSON(t, RegisterPayload{Digest: digest, Type: 4, Level: 3, Size: 4096}),
|
|
}).SyntacticVerify())
|
|
|
|
// And Scheme is not a second unpriced channel on the operations that ignore
|
|
// it: it is bounded on every operation.
|
|
_, permitID := seedPermit(t, vm, k, k, fhe.PermitOpDecrypt, 0)
|
|
wide := k.sign(t, &Transaction{
|
|
Type: TxRevokePermit, Payer: k.addr, Subject: permitID,
|
|
Scheme: string(bytes.Repeat([]byte("A"), MaxScheme+1)),
|
|
GasLimit: testGas, Nonce: 3, Payload: mustJSON(t, RevokePayload{}),
|
|
})
|
|
require.ErrorIs(t, wide.SyntacticVerify(), ErrInvalidPayload)
|
|
}
|
|
|
|
// H3: what a transaction stores it pays for. Two identical operations differing
|
|
// only in payload length must not cost the same.
|
|
func TestH3_LongerPayloadCostsMore(t *testing.T) {
|
|
base, err := GasFor(&Transaction{Type: TxRevokePermit, Payload: mustJSON(nil, RevokePayload{})})
|
|
require.NoError(t, err)
|
|
|
|
long, err := GasFor(&Transaction{
|
|
Type: TxRevokePermit,
|
|
Payload: mustJSON(nil, RevokePayload{Reason: string(bytes.Repeat([]byte("r"), 1000))}),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.Greater(t, long, base, "a longer payload must cost more")
|
|
require.GreaterOrEqual(t, uint64(long-base), uint64(1000*GasPerByte),
|
|
"every stored byte is priced")
|
|
|
|
// The scheme string is stored too, so it is priced too.
|
|
wide, err := GasFor(&Transaction{
|
|
Type: TxRevokePermit, Scheme: "0123456789abcdef",
|
|
Payload: mustJSON(nil, RevokePayload{}),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, base+fee.Gas(16*GasPerByte), wide)
|
|
}
|
|
|
|
// H3: a committee is bounded, so the unauthenticated work a transaction can
|
|
// demand before its signature is checked is bounded with it.
|
|
func TestH3_CommitteeIsBounded(t *testing.T) {
|
|
big, _ := newCommittee(t, MaxCommittee+1)
|
|
require.ErrorIs(t, ValidateCommittee(big, 2, []byte("k")), ErrInvalidCommittee)
|
|
|
|
ok, _ := newCommittee(t, MaxCommittee)
|
|
require.NoError(t, ValidateCommittee(ok, 2, []byte("k")))
|
|
}
|
|
|
|
// H5: ValidateCommittee deduplicated NodeID, but a member's VOTING IDENTITY is
|
|
// addressOf(PublicKey). n seats sharing one key passed every check and seated a
|
|
// threshold only one account could ever vote toward — so no decryption could
|
|
// complete and the committee could never rotate itself out, while the chain
|
|
// reported itself fully seated and healthy.
|
|
func TestH5_CommitteeIsDedupedByVotingIdentity(t *testing.T) {
|
|
one := newTestKey(t)
|
|
shared := make([]fhe.CommitteeMember, 3)
|
|
for i := range shared {
|
|
shared[i] = fhe.CommitteeMember{
|
|
NodeID: ids.GenerateTestNodeID(), // distinct
|
|
PublicKey: one.pub, // identical
|
|
Weight: 1,
|
|
}
|
|
}
|
|
sort.Slice(shared, func(i, j int) bool { return shared[i].NodeID.Compare(shared[j].NodeID) < 0 })
|
|
for i := range shared {
|
|
shared[i].Index = i
|
|
}
|
|
|
|
require.ErrorIs(t, ValidateCommittee(shared, 3, []byte("pk")), ErrInvalidCommittee,
|
|
"three seats and one voter is not a three-of-three committee")
|
|
|
|
// Genesis refuses it too, so a chain cannot be born unable to answer.
|
|
logger := log.NewNoOpLogger()
|
|
gb, err := json.Marshal(Genesis{
|
|
Version: 1, Timestamp: time.Now().Unix(),
|
|
Committee: shared, Threshold: 3, PublicKey: []byte("pk"),
|
|
})
|
|
require.NoError(t, err)
|
|
vm := &VM{}
|
|
err = vm.Initialize(context.Background(), vmcore.Init{
|
|
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger},
|
|
DB: memdb.New(),
|
|
ToEngine: make(chan vmcore.Message, 8),
|
|
Log: logger,
|
|
Genesis: gb,
|
|
})
|
|
require.ErrorIs(t, err, ErrInvalidCommittee)
|
|
|
|
// And an epoch proposal carrying one is refused before anyone votes on it.
|
|
advance := &Transaction{
|
|
Type: TxAdvanceEpoch, Nonce: 1,
|
|
Subject: committeeDigest(1, 3, []byte("pk"), shared),
|
|
Payload: mustJSON(t, AdvancePayload{Epoch: 1, Committee: shared, Threshold: 3, PublicKey: []byte("pk")}),
|
|
}
|
|
require.ErrorIs(t, advance.SyntacticVerify(), ErrInvalidCommittee)
|
|
}
|
|
|
|
// H4: one vote per member, no revote, no timeout, no reset — and the tally only
|
|
// cleared when the advance that could not happen happened. A committee that
|
|
// split its vote could never rotate again, which at unanimity one member could
|
|
// do alone, and which an honest DKG race could do with no adversary at all.
|
|
// A member's LATEST vote is now its vote.
|
|
func TestH4_ASplitVoteCanBeResolved(t *testing.T) {
|
|
vm, _, members := newDecryptVM(t, 3, 3) // unanimity: the worst case
|
|
agreed, _ := newCommittee(t, 3)
|
|
stale, _ := newCommittee(t, 3)
|
|
pk := []byte("k")
|
|
|
|
// One member votes for a proposal the others will not back.
|
|
acceptOne(t, vm, advanceTx(t, members[0], 1, stale, 3, pk, 1))
|
|
acceptOne(t, vm, advanceTx(t, members[1], 1, agreed, 3, pk, 1))
|
|
acceptOne(t, vm, advanceTx(t, members[2], 1, agreed, 3, pk, 1))
|
|
require.Equal(t, uint64(0), vm.CurrentEpoch(), "no proposal has unanimity yet")
|
|
|
|
ep, _ := vm.Epoch(0)
|
|
require.Len(t, ep.Attestations, 3, "one entry per member, not one per vote")
|
|
|
|
// The dissenter changes its mind. Its new vote REPLACES the old one.
|
|
acceptOne(t, vm, advanceTx(t, members[0], 1, agreed, 3, pk, 2))
|
|
|
|
require.Equal(t, uint64(1), vm.CurrentEpoch(), "the committee converged and rotated")
|
|
installed, ok := vm.Epoch(1)
|
|
require.True(t, ok)
|
|
require.Len(t, installed.Committee, 3)
|
|
require.Empty(t, installed.Attestations, "a fresh epoch starts with no votes cast")
|
|
|
|
old, _ := vm.Epoch(0)
|
|
require.Len(t, old.Attestations, 3, "a member's second vote replaced its first")
|
|
require.Equal(t, 3, tally(old.Attestations, committeeDigest(1, 3, pk, agreed)))
|
|
}
|
|
|
|
// H4: the same applies to a decryption. A member that attested a result nobody
|
|
// else saw can withdraw it in favour of the one the committee agreed on.
|
|
func TestH4_AMemberCanCorrectItsAttestation(t *testing.T) {
|
|
owner, grantee := newTestKey(t), newTestKey(t)
|
|
vm, _, members := newDecryptVM(t, 3, 3, owner, grantee)
|
|
|
|
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
|
|
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
|
|
requestID := deriveRequestID(handle, grantee.addr, 1)
|
|
|
|
wrong, right := digestOf("wrong"), digestOf("right")
|
|
acceptOne(t, vm, fulfillTx(t, members[0], requestID, wrong, 1))
|
|
acceptOne(t, vm, fulfillTx(t, members[1], requestID, right, 1))
|
|
acceptOne(t, vm, fulfillTx(t, members[2], requestID, right, 1))
|
|
|
|
rec, _ := vm.Decrypt(requestID)
|
|
require.Equal(t, fhe.RequestPending, rec.Status, "two of three is not unanimity")
|
|
|
|
acceptOne(t, vm, fulfillTx(t, members[0], requestID, right, 2))
|
|
rec, _ = vm.Decrypt(requestID)
|
|
require.Equal(t, fhe.RequestCompleted, rec.Status)
|
|
require.Equal(t, right, rec.ResultHandle)
|
|
require.Len(t, rec.Attestations, 3, "one entry per member")
|
|
require.Zero(t, tally(rec.Attestations, wrong), "the withdrawn vote counts for nothing")
|
|
}
|
|
|
|
// H6: pendingBlocks was written under one mutex (BuildBlock) and read under
|
|
// another (the Accept-abort path, via loadStateLocked). Two mutexes over one
|
|
// map is a data race, and a concurrent map access in Go is a fatal throw the
|
|
// process cannot recover from. It has one owner now: stateLock, with the rest
|
|
// of the chain state. Run under -race.
|
|
func TestH6_BlockBookkeepingHasOneOwner(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("seed"), 1))
|
|
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
nonce := uint64(2)
|
|
|
|
// Proposer: writes pendingBlocks.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
if _, err := vm.SubmitTx(registerTx(t, k, testScheme, digestOf(strconv.FormatUint(nonce, 10)), nonce)); err == nil {
|
|
nonce++
|
|
}
|
|
_, _ = vm.BuildBlock(context.Background())
|
|
}
|
|
}()
|
|
|
|
// Cache reload: the Accept-abort path, which reads pendingBlocks.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
_ = vm.loadState()
|
|
}
|
|
}()
|
|
|
|
// Readers on the consensus surface.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
tip, _ := vm.LastAccepted(context.Background())
|
|
_, _ = vm.GetBlock(context.Background(), tip)
|
|
_, _ = vm.HealthCheck(context.Background())
|
|
_, _ = vm.GetBlockIDAtHeight(context.Background(), 1)
|
|
}
|
|
}()
|
|
|
|
time.Sleep(300 * time.Millisecond)
|
|
close(stop)
|
|
wg.Wait()
|
|
}
|
|
|
|
// M1: DecryptRecord.PermitID was written and read by nothing but the RPC view,
|
|
// so an owner who withdrew a capability watched the committee answer the
|
|
// request anyway and deliver the plaintext to the callback. Revocation now
|
|
// reaches an in-flight request.
|
|
func TestM1_RevocationStopsAnInFlightDecryption(t *testing.T) {
|
|
owner, grantee := newTestKey(t), newTestKey(t)
|
|
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
|
|
|
|
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
|
|
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
|
|
requestID := deriveRequestID(handle, grantee.addr, 1)
|
|
|
|
// One member has already answered when the owner withdraws consent.
|
|
result := digestOf("plaintext-handle")
|
|
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 1))
|
|
acceptOne(t, vm, revokeTx(t, owner, permitID, 3))
|
|
|
|
// The committee can no longer complete it.
|
|
_, err := vm.SubmitTx(fulfillTx(t, members[1], requestID, result, 1))
|
|
require.ErrorIs(t, err, ErrPermitRevoked)
|
|
|
|
// And a peer forcing it into a block gets a revert, not an answer.
|
|
forced := fulfillTx(t, members[1], requestID, result, 1)
|
|
blk := forceBlock(vm, forced)
|
|
require.NoError(t, blk.Verify(context.Background()))
|
|
require.NoError(t, blk.Accept(context.Background()))
|
|
|
|
rec, _ := vm.Decrypt(requestID)
|
|
require.Equal(t, fhe.RequestPending, rec.Status, "a revoked permit answers nothing")
|
|
require.Len(t, rec.Attestations, 1, "the second attestation never landed")
|
|
}
|
|
|
|
// M1: expiry bounds the ASK, not the ANSWER. A permit that ran out after a
|
|
// request was properly made does not strand it — the grantee asked in time, and
|
|
// the committee answering later is not the grantee acting.
|
|
func TestM1_ExpiryDoesNotStrandAnAnsweredRequest(t *testing.T) {
|
|
owner, grantee := newTestKey(t), newTestKey(t)
|
|
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
|
|
|
|
base := time.Now()
|
|
vm.clock.Set(base)
|
|
permitExpiry := base.Unix() + 60
|
|
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, permitExpiry)
|
|
|
|
// The request is made in time, with a window that outlasts the permit.
|
|
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, base.Unix()+10_000, 1))
|
|
requestID := deriveRequestID(handle, grantee.addr, 1)
|
|
|
|
// The permit lapses; the committee answers afterwards.
|
|
vm.clock.Set(time.Unix(permitExpiry+1, 0))
|
|
result := digestOf("answer")
|
|
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 1))
|
|
acceptOne(t, vm, fulfillTx(t, members[1], requestID, result, 1))
|
|
|
|
rec, _ := vm.Decrypt(requestID)
|
|
require.Equal(t, fhe.RequestCompleted, rec.Status,
|
|
"the grantee asked while it could; a lapsed permit is not a withdrawn one")
|
|
require.Equal(t, result, rec.ResultHandle)
|
|
}
|
|
|
|
// L1: wire.go claimed "canonical: parse rejects trailing bytes". That was true
|
|
// of a transaction and not of a block — parseBlock walked the transaction blob
|
|
// by the declared lengths and never checked it had consumed it, so a block had
|
|
// many valid encodings and one id.
|
|
func TestL1_BlockEncodingIsCanonical(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("canon"), 1))
|
|
blk := vm.lastBlock
|
|
|
|
// Its own encoding round-trips.
|
|
again, err := vm.ParseBlock(context.Background(), blk.Bytes())
|
|
require.NoError(t, err)
|
|
require.Equal(t, blk.ID(), again.ID())
|
|
|
|
// Bytes inside the transaction blob that no length covers are refused.
|
|
padded := blockBytesWithBlobPadding(blk, bytes.Repeat([]byte{0xAA}, 4096))
|
|
require.NotEqual(t, blk.Bytes(), padded)
|
|
_, err = vm.ParseBlock(context.Background(), padded)
|
|
require.ErrorIs(t, err, ErrInvalidPayload)
|
|
|
|
// So are bytes after the message.
|
|
_, err = vm.ParseBlock(context.Background(), append(blk.Bytes(), 0xff))
|
|
require.ErrorIs(t, err, ErrInvalidPayload)
|
|
}
|
|
|
|
// L2: the type walk had three blind spots — it did not descend into interface
|
|
// fields, it exempted every 32-byte array from the name check, and its token
|
|
// list is a denylist, which cannot be complete. The denylist stays as a
|
|
// tripwire for NEW types; what actually holds the line is the schema pin below.
|
|
func TestL2_WalkerSeesWhatItClaimedTo(t *testing.T) {
|
|
type viaInterface struct {
|
|
Handle [32]byte
|
|
Extra any
|
|
}
|
|
require.NotEmpty(t, walkType(reflect.TypeOf(viaInterface{}), "iface", map[reflect.Type]bool{}),
|
|
"a persisted record cannot hold an interface: it would hold anything")
|
|
|
|
type viaFixedArray struct {
|
|
SecretShare [32]byte
|
|
}
|
|
require.NotEmpty(t, walkType(reflect.TypeOf(viaFixedArray{}), "arr", map[reflect.Type]bool{}),
|
|
"a share is exactly 32 bytes; being id-sized is not being an id")
|
|
|
|
// The real records still pass.
|
|
for _, r := range persistedTypes() {
|
|
require.Emptyf(t, walkType(r, r.Name(), map[reflect.Type]bool{}),
|
|
"%s must pass its own walk", r.Name())
|
|
}
|
|
}
|
|
|
|
// H2, residual: chain time is monotone and bounded ahead, and that is ALL a
|
|
// chain can promise about it. Within [parent timestamp, local clock + skew] the
|
|
// proposer still chooses, so a permit that lapsed during a gap in block
|
|
// production can still authorize one request in the block that closes the gap.
|
|
//
|
|
// This test measures that freedom rather than leaving it unexamined: the window
|
|
// is exactly the time since the last block. On a chain producing blocks it is
|
|
// seconds; on an idle chain it is the length of the idle period. Removing it
|
|
// entirely means expiries counted in block heights, which no proposer can
|
|
// rewind — a change to the grant API, recorded in LLM.md, not a patch.
|
|
func TestH2_ProposerFreedomIsExactlyTheGapSinceTheLastBlock(t *testing.T) {
|
|
owner, grantee := newTestKey(t), newTestKey(t)
|
|
vm, _, _ := newDecryptVM(t, 3, 2, owner, grantee)
|
|
|
|
base := time.Now()
|
|
vm.clock.Set(base)
|
|
expiry := base.Unix() + 60
|
|
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, expiry)
|
|
parent := vm.lastBlock
|
|
|
|
// Long past the permit's expiry, admission refuses the request.
|
|
vm.clock.Set(time.Unix(expiry+100_000, 0))
|
|
req := requestTx(t, grantee, testScheme, handle, permitID, 0, 1)
|
|
_, err := vm.SubmitTx(req)
|
|
require.ErrorIs(t, err, ErrPermitExpired)
|
|
|
|
at := func(ts time.Time) *Block {
|
|
blk := &Block{
|
|
parentID: vm.lastAccepted, height: parent.height + 1, timestamp: ts,
|
|
transactions: []*Transaction{req}, vm: vm,
|
|
}
|
|
blk.id = blk.computeID()
|
|
return blk
|
|
}
|
|
|
|
// The proposer cannot go below the parent, which is the bound that exists.
|
|
require.ErrorIs(t, at(parent.timestamp.Add(-time.Second)).Verify(context.Background()),
|
|
ErrInvalidBlock)
|
|
|
|
// Inside the window it can, and the request is authorized — by a permit that
|
|
// has, in wall-clock terms, expired. The block is valid; the transaction
|
|
// applies. This is the residual, and it is exactly this large.
|
|
inside := at(time.Unix(expiry-1, 0))
|
|
require.NoError(t, inside.Verify(context.Background()))
|
|
require.NoError(t, inside.Accept(context.Background()))
|
|
_, ok := vm.Decrypt(deriveRequestID(handle, grantee.addr, 1))
|
|
require.True(t, ok)
|
|
|
|
// And it closes behind itself: chain time has now passed the expiry, so no
|
|
// later block can reach back. A second request is refused by consensus, not
|
|
// merely by admission.
|
|
vm.clock.Set(time.Unix(expiry+100_000, 0))
|
|
later := requestTx(t, grantee, testScheme, handle, permitID, 0, 2)
|
|
next := &Block{
|
|
parentID: vm.lastAccepted, height: vm.height + 1,
|
|
timestamp: vm.lastBlock.timestamp.Add(2 * time.Second), // now past the expiry
|
|
transactions: []*Transaction{later}, vm: vm,
|
|
}
|
|
next.id = next.computeID()
|
|
require.NoError(t, next.Verify(context.Background()))
|
|
require.NoError(t, next.Accept(context.Background()))
|
|
_, ok = vm.Decrypt(deriveRequestID(handle, grantee.addr, 2))
|
|
require.False(t, ok, "the expired permit authorizes nothing once chain time passes it")
|
|
}
|
|
|
|
// blockBytesWithBlobPadding rebuilds a block's wire form with extra bytes
|
|
// appended to the transaction blob that no TxLens entry covers. It is
|
|
// Block.Bytes verbatim apart from that one append, which is what makes it a
|
|
// fair test of the parser rather than of the builder.
|
|
func blockBytesWithBlobPadding(b *Block, pad []byte) []byte {
|
|
txLens := make([]uint32, len(b.transactions))
|
|
var txBlob []byte
|
|
for i, tx := range b.transactions {
|
|
txb := tx.Bytes()
|
|
txLens[i] = uint32(len(txb))
|
|
txBlob = append(txBlob, txb...)
|
|
}
|
|
txBlob = append(txBlob, pad...)
|
|
|
|
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(txBlob) + 4*len(txLens) + 128)
|
|
txLensOff := writeU32List(bld, txLens)
|
|
ob := bld.StartObject(blkSize)
|
|
ob.SetBytesFixed(blkParent, b.parentID[:])
|
|
ob.SetUint64(blkHeight, b.height)
|
|
ob.SetInt64(blkTime, b.timestamp.Unix())
|
|
ob.SetList(blkTxLens, txLensOff, len(txLens))
|
|
ob.SetBytes(blkTxBlob, txBlob)
|
|
ob.FinishAsRoot()
|
|
return bld.Finish()
|
|
}
|
|
|
|
// M3: a follower could not verify a block whose parent it had only parsed. The
|
|
// in-flight block set was written in exactly one place — BuildBlock — so a block
|
|
// arriving from a peer was never findable by id, and its child failed with
|
|
// "verify parent: not found" no matter what it contained.
|
|
//
|
|
// The parent lookup is what this fixes. Note what it does NOT fix: Verify checks
|
|
// nonces against COMMITTED state, so two blocks in flight from the SAME payer
|
|
// still cannot both verify — the second reads a nonce the first has not yet
|
|
// committed. Verifying against the parent's resulting state needs a per-block
|
|
// overlay and is a separate change; keyvm has the same limit.
|
|
func TestM3_AFollowerCanVerifyAgainstAParentItOnlyParsed(t *testing.T) {
|
|
first, second := newTestKey(t), newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
proposer := newTestVM(t, fundAll(first, second), committee, 1)
|
|
follower := newTestVM(t, fundAll(first, second), committee, 1)
|
|
|
|
build := func(k testKey, handle string) *Block {
|
|
_, err := proposer.SubmitTx(registerTx(t, k, testScheme, digestOf(handle), 1))
|
|
require.NoError(t, err)
|
|
blk, err := proposer.BuildBlock(context.Background())
|
|
require.NoError(t, err)
|
|
return blk.(*Block)
|
|
}
|
|
|
|
b1 := build(first, "one")
|
|
require.NoError(t, b1.Accept(context.Background()))
|
|
b2 := build(second, "two") // built on b1, still in flight
|
|
|
|
// The follower accepts neither. b2 must still verify: its parent is a block
|
|
// the follower has only parsed.
|
|
p1, err := follower.ParseBlock(context.Background(), b1.Bytes())
|
|
require.NoError(t, err)
|
|
require.NoError(t, p1.Verify(context.Background()))
|
|
|
|
p2, err := follower.ParseBlock(context.Background(), b2.Bytes())
|
|
require.NoError(t, err)
|
|
require.NoError(t, p2.Verify(context.Background()),
|
|
"a verified parent must be findable, whoever built it")
|
|
}
|
|
|
|
// M3: the tracker must not become the leak that an unreleased claim was. The
|
|
// engine may drop a block it never accepts and never rejects, so nothing else
|
|
// removes it — anything at or below the last accepted height is decided or
|
|
// orphaned and must not be retained.
|
|
func TestM3_TheInFlightSetIsBounded(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
for nonce := uint64(1); nonce <= 4; nonce++ {
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf(string(rune('a'+nonce))), nonce))
|
|
}
|
|
|
|
vm.stateLock.RLock()
|
|
defer vm.stateLock.RUnlock()
|
|
for id, blk := range vm.pendingBlocks {
|
|
require.Greater(t, blk.height, vm.height,
|
|
"block %s at height %d is at or below the accepted height %d", id, blk.height, vm.height)
|
|
}
|
|
}
|
|
|
|
// C2: Verify required only that a block's height was its parent's plus one, and
|
|
// never that the parent was the TIP. A block at height 2 whose parent is the
|
|
// long-since-accepted block at height 1 satisfies that perfectly, so a chain at
|
|
// height 3 verified it, accepted it, and rewound: the last-accepted pointer, the
|
|
// in-memory height and the height index all moved back to the orphan, while the
|
|
// index still named the abandoned block at height 3. Every peer bootstrapping
|
|
// from that index was served an incoherent chain.
|
|
func TestC2_AnOrphanCannotRewindTheChain(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("one"), 1))
|
|
forkPoint, forkTime := vm.lastAccepted, vm.lastBlock.timestamp
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("two"), 2))
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("three"), 3))
|
|
tip, height := vm.lastAccepted, vm.height
|
|
require.Equal(t, uint64(3), height)
|
|
|
|
// Everything else about this block is impeccable: the height follows its
|
|
// parent, the time follows its parent and is inside the skew allowance, and
|
|
// its transaction carries the payer's next nonce against committed state.
|
|
orphan := &Block{
|
|
parentID: forkPoint,
|
|
height: 2,
|
|
timestamp: forkTime.Add(time.Second),
|
|
transactions: []*Transaction{registerTx(t, k, testScheme, digestOf("rewind"), 4)},
|
|
vm: vm,
|
|
}
|
|
orphan.id = orphan.computeID()
|
|
|
|
require.ErrorIs(t, orphan.Verify(context.Background()), ErrNotOnTip)
|
|
require.ErrorIs(t, orphan.Accept(context.Background()), ErrNotOnTip,
|
|
"Accept decides this too: Verify judged an earlier tip")
|
|
|
|
require.Equal(t, height, vm.height, "the chain did not rewind")
|
|
require.Equal(t, tip, vm.lastAccepted)
|
|
at2, err := vm.GetBlockIDAtHeight(context.Background(), 2)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, orphan.id, at2, "the height index still names the accepted chain")
|
|
_, ok := vm.Ciphertext(orphan.transactions[0].Subject)
|
|
require.False(t, ok, "and applied nothing")
|
|
|
|
// The control: the same transaction in a block that DOES extend the tip is
|
|
// accepted. What was refused is the parent, not the contents.
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("rewind"), 4))
|
|
require.Equal(t, uint64(4), vm.height)
|
|
}
|
|
|
|
// C2: Accept re-decides it because the tip moves between the two calls. The
|
|
// engine may verify a block, accept a sibling, and only then accept the first —
|
|
// at which point it no longer extends anything.
|
|
func TestC2_ATipThatMovesAfterVerifyIsCaughtByAccept(t *testing.T) {
|
|
first, second := newTestKey(t), newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(first, second), committee, 1)
|
|
|
|
a := forceBlock(vm, registerTx(t, first, testScheme, digestOf("a"), 1))
|
|
b := forceBlock(vm, registerTx(t, second, testScheme, digestOf("b"), 1))
|
|
require.NotEqual(t, a.id, b.id, "two distinct siblings on one parent")
|
|
|
|
require.NoError(t, a.Verify(context.Background()))
|
|
require.NoError(t, b.Verify(context.Background()), "both verify against the same tip")
|
|
|
|
require.NoError(t, a.Accept(context.Background()))
|
|
require.ErrorIs(t, b.Accept(context.Background()), ErrNotOnTip,
|
|
"the sibling no longer extends the tip, whatever Verify said earlier")
|
|
require.Equal(t, uint64(1), vm.height)
|
|
}
|
|
|
|
// H7: MaxBlockTxs was applied by Verify, and Verify runs after the parse — so
|
|
// nothing bounded what a peer could make this node parse, hash and allocate
|
|
// before a single check had run. An 8 MB message carrying 1,524 transactions
|
|
// parsed to completion. Both bounds belong at the first byte, and neither
|
|
// implies the other: the byte bound limits what is read, the count bound limits
|
|
// what is verified, and a small message can still declare a great many tiny
|
|
// transactions.
|
|
func TestH7_ABlockIsBoundedInBytesAndInCount(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
// Bytes: a block over the size bound is refused before it is decoded.
|
|
fat := registerTx(t, k, testScheme, digestOf("fat"), 1)
|
|
fat.Payload = bytes.Repeat([]byte("A"), MaxPayload)
|
|
k.sign(t, fat)
|
|
bulk := make([]*Transaction, 0, 32)
|
|
for len(bulk) < 32 {
|
|
bulk = append(bulk, fat)
|
|
}
|
|
huge := &Block{parentID: vm.lastAccepted, height: 1, timestamp: vm.clock.Time(), transactions: bulk, vm: vm}
|
|
raw := huge.Bytes()
|
|
require.Greater(t, len(raw), MaxBlockSize)
|
|
_, err := vm.ParseBlock(context.Background(), raw)
|
|
require.ErrorIs(t, err, ErrInvalidPayload, "an oversize block is refused at the first byte")
|
|
|
|
// Count: a block UNDER the size bound may still declare more transactions
|
|
// than may ever be verified, so the count is bounded at the parse too.
|
|
tiny := &Transaction{Type: TxRevokePermit, Nonce: 1, Payload: mustJSON(t, RevokePayload{})}
|
|
many := make([]*Transaction, 0, MaxBlockTxs+1)
|
|
for len(many) < MaxBlockTxs+1 {
|
|
many = append(many, tiny)
|
|
}
|
|
crowd := &Block{parentID: vm.lastAccepted, height: 1, timestamp: vm.clock.Time(), transactions: many, vm: vm}
|
|
crowded := crowd.Bytes()
|
|
require.LessOrEqual(t, len(crowded), MaxBlockSize, "this one is small; only the count is out of bounds")
|
|
_, err = vm.ParseBlock(context.Background(), crowded)
|
|
require.ErrorIs(t, err, ErrInvalidPayload)
|
|
|
|
// A block this node builds is held to the byte bound too, so a proposer
|
|
// cannot produce one its peers refuse to parse.
|
|
require.ErrorIs(t, huge.Verify(context.Background()), ErrInvalidBlock)
|
|
|
|
// The control: one transaction under both bounds round-trips.
|
|
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("ordinary"), 1))
|
|
_, err = vm.ParseBlock(context.Background(), vm.lastBlock.Bytes())
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// H7: selection stops at the byte bound as well as the count, so the proposer
|
|
// and the parser agree. Stopping only on the count let 1024 ordinary
|
|
// transactions — each carrying an ML-DSA-65 public key and signature — build a
|
|
// 5 MB block this node's own parser refuses.
|
|
func TestH7_SelectionStopsAtTheByteBound(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, map[string]uint64{k.hexAddr(): 1 << 50}, committee, 1)
|
|
|
|
// Ordinary register transactions — nothing oversized about any of them. What
|
|
// makes them heavy is the fixed part: an ML-DSA-65 public key and signature,
|
|
// about 5 KB a transaction, which no payload bound touches.
|
|
const queued = 450
|
|
for nonce := uint64(1); nonce <= queued; nonce++ {
|
|
_, err := vm.SubmitTx(registerTx(t, k, testScheme, digestOf(strconv.FormatUint(nonce, 10)), nonce))
|
|
require.NoError(t, err)
|
|
}
|
|
require.Len(t, vm.mempool, queued)
|
|
|
|
blkIntf, err := vm.BuildBlock(context.Background())
|
|
require.NoError(t, err)
|
|
blk := blkIntf.(*Block)
|
|
|
|
require.LessOrEqual(t, len(blk.Bytes()), MaxBlockSize,
|
|
"the proposer stopped at the size its own parser enforces")
|
|
require.Less(t, len(blk.transactions), queued, "so it could not take them all")
|
|
require.Less(t, len(blk.transactions), MaxBlockTxs,
|
|
"and the BYTE bound is what stopped it, well before the count bound")
|
|
|
|
require.NoError(t, blk.Verify(context.Background()))
|
|
_, err = vm.ParseBlock(context.Background(), blk.Bytes())
|
|
require.NoError(t, err, "what it built, it can parse")
|
|
|
|
// The rest is still queued and goes out in the next block.
|
|
require.NoError(t, blk.Accept(context.Background()))
|
|
require.Len(t, vm.mempool, queued-len(blk.transactions))
|
|
next := acceptQueued(t, vm)
|
|
require.NotEmpty(t, next.transactions)
|
|
}
|
|
|
|
// H8: nothing bound a transaction or a block to the chain it was meant for. An
|
|
// address is the hash of a public key, so the same payer exists on every
|
|
// F-Chain; a transaction lifted from one authenticated verbatim on the others
|
|
// and burned a balance there for an operation nobody asked for. Blocks were
|
|
// worse: every chain whose genesis carried the same timestamp had the SAME
|
|
// genesis id, so one chain's block resolved its parent on all of them.
|
|
func TestH8_ASignatureAndABlockNameOneChain(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
other := ids.ID{'a', 'n', 'o', 't', 'h', 'e', 'r'}
|
|
|
|
home := newTestVM(t, fundAll(k), committee, 1)
|
|
away := newVMOnChain(t, other, fundAll(k), committee, 1)
|
|
|
|
// Same genesis bytes, same timestamp, different chains: different ids.
|
|
require.NotEqual(t, home.lastAccepted, away.lastAccepted,
|
|
"two chains must not share a genesis block")
|
|
|
|
// A transaction signed for home does not authenticate away.
|
|
tx := registerTx(t, k, testScheme, digestOf("mine"), 1)
|
|
require.NoError(t, tx.authenticate(home.chainID))
|
|
require.ErrorIs(t, tx.authenticate(away.chainID), ErrBadSignature)
|
|
_, err := away.SubmitTx(tx)
|
|
require.ErrorIs(t, err, ErrBadSignature, "the replay is refused at admission")
|
|
|
|
// Nor inside a block: a peer forcing it in gets a refusal, not an effect.
|
|
forced := &Block{
|
|
parentID: away.lastAccepted, height: 1, timestamp: away.clock.Time(),
|
|
transactions: []*Transaction{tx}, vm: away,
|
|
}
|
|
forced.id = forced.computeID()
|
|
require.ErrorIs(t, forced.Verify(context.Background()), ErrBadSignature)
|
|
|
|
// And home's whole block does not land away: its parent is a genesis away
|
|
// has never heard of.
|
|
acceptOne(t, home, tx)
|
|
parsed, err := away.ParseBlock(context.Background(), home.lastBlock.Bytes())
|
|
require.NoError(t, err, "the bytes are well formed; it is the chain that differs")
|
|
require.Error(t, parsed.Verify(context.Background()))
|
|
require.Zero(t, away.height, "nothing from another chain moved this one")
|
|
|
|
// The control: signed for away, it works away.
|
|
native := k.signFor(t, other, registerTx(t, k, testScheme, digestOf("theirs"), 1))
|
|
_, err = away.SubmitTx(native)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// newVMOnChain seats a chain with an explicitly named id. Only a test that is
|
|
// about more than one chain needs it; every other one uses testChainID, because
|
|
// production runs one chain with one id.
|
|
func newVMOnChain(t *testing.T, chain ids.ID, alloc map[string]uint64, committee []fhe.CommitteeMember, threshold int) *VM {
|
|
t.Helper()
|
|
logger := log.NewNoOpLogger()
|
|
gb, err := json.Marshal(Genesis{
|
|
Version: 1, Timestamp: testGenesisTime, Alloc: alloc,
|
|
Committee: committee, Threshold: threshold, PublicKey: []byte("network-fhe-public-key"),
|
|
})
|
|
require.NoError(t, err)
|
|
vm := &VM{}
|
|
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
|
|
Runtime: &runtime.Runtime{ChainID: chain, NetworkID: 96369, Log: logger},
|
|
DB: memdb.New(),
|
|
ToEngine: make(chan vmcore.Message, 8),
|
|
Log: logger,
|
|
Genesis: gb,
|
|
}))
|
|
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
|
|
return vm
|
|
}
|
|
|
|
// L3: what an UNAUTHENTICATED transaction can make this node do before its
|
|
// signature is checked. The answer must be bounded by its own declared size,
|
|
// and it is: bytes and count are bounded at the parse, the payload and scheme
|
|
// are bounded before either is decoded, and the one expensive decode — parsing
|
|
// a committee's ML-DSA-65 public keys — is bounded by MaxCommittee.
|
|
func TestL3_UnauthenticatedWorkIsBoundedBySize(t *testing.T) {
|
|
k := newTestKey(t)
|
|
|
|
// The costly branch: an epoch proposal makes SyntacticVerify parse a public
|
|
// key per member, and that runs before authenticate. MaxCommittee bounds it.
|
|
over, _ := newCommittee(t, MaxCommittee+1)
|
|
huge := &Transaction{
|
|
Type: TxAdvanceEpoch, Nonce: 1,
|
|
Subject: committeeDigest(1, 2, []byte("pk"), over),
|
|
Payload: mustJSON(t, AdvancePayload{Epoch: 1, Committee: over, Threshold: 2, PublicKey: []byte("pk")}),
|
|
}
|
|
require.ErrorIs(t, huge.SyntacticVerify(), ErrInvalidCommittee,
|
|
"the committee is refused by COUNT, before any of its keys is parsed")
|
|
|
|
// Every other byte channel is bounded before it is decoded at all.
|
|
for name, tx := range map[string]*Transaction{
|
|
"payload": {Type: TxRevokePermit, Nonce: 1, Payload: bytes.Repeat([]byte("A"), MaxPayload+1)},
|
|
"scheme": {Type: TxRevokePermit, Nonce: 1, Scheme: string(bytes.Repeat([]byte("A"), MaxScheme+1)), Payload: mustJSON(t, RevokePayload{})},
|
|
"auth": {Type: TxRevokePermit, Nonce: 1, Payload: mustJSON(t, RevokePayload{}), Auth: bytes.Repeat([]byte("A"), 9)},
|
|
"sig": {Type: TxRevokePermit, Nonce: 1, Payload: mustJSON(t, RevokePayload{}), Sig: bytes.Repeat([]byte("A"), 9)},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
require.ErrorIs(t, tx.SyntacticVerify(), ErrInvalidPayload)
|
|
})
|
|
}
|
|
|
|
// And the order holds where it matters: a transaction whose signature is
|
|
// wrong is refused, so nothing beyond the bounded checks above is ever done
|
|
// on an unauthenticated one.
|
|
forged := registerTx(t, k, testScheme, digestOf("forged"), 1)
|
|
forged.Sig[0] ^= 0xff
|
|
require.ErrorIs(t, forged.authenticate(testChainID), ErrBadSignature)
|
|
}
|
|
|
|
// The receive path must decide exactly what the build path decides. A sibling
|
|
// VM's parser discarded the transaction set, so a signature check gated on the
|
|
// set being non-empty ran only on blocks that node had built itself: the same
|
|
// block was refused in memory and accepted off the wire. Here the same block is
|
|
// fed in BOTH ways and the two verdicts are required to agree — for a sound
|
|
// block and for each way one can be unsound.
|
|
func TestTheWireVerdictIsTheMemoryVerdict(t *testing.T) {
|
|
k := newTestKey(t)
|
|
committee, _ := newCommittee(t, 1)
|
|
vm := newTestVM(t, fundAll(k), committee, 1)
|
|
|
|
bothWays := func(t *testing.T, blk *Block) (inMemory, offWire error) {
|
|
t.Helper()
|
|
inMemory = blk.Verify(context.Background())
|
|
parsed, err := vm.ParseBlock(context.Background(), blk.Bytes())
|
|
if err != nil {
|
|
return inMemory, err
|
|
}
|
|
return inMemory, parsed.Verify(context.Background())
|
|
}
|
|
|
|
build := func(txs ...*Transaction) *Block {
|
|
b := &Block{
|
|
parentID: vm.lastAccepted, height: vm.height + 1,
|
|
timestamp: vm.clock.Time(), transactions: txs, vm: vm,
|
|
}
|
|
b.id = b.computeID()
|
|
return b
|
|
}
|
|
|
|
// Sound: both accept.
|
|
sound := build(registerTx(t, k, testScheme, digestOf("sound"), 1))
|
|
mem, wire := bothWays(t, sound)
|
|
require.NoError(t, mem)
|
|
require.NoError(t, wire)
|
|
|
|
// Forged signature: both refuse, and for the same reason.
|
|
forged := registerTx(t, k, testScheme, digestOf("forged"), 1)
|
|
forged.Sig = append([]byte(nil), forged.Sig...)
|
|
forged.Sig[0] ^= 0xff
|
|
forged.id = ids.Empty
|
|
mem, wire = bothWays(t, build(forged))
|
|
require.ErrorIs(t, mem, ErrBadSignature)
|
|
require.ErrorIs(t, wire, ErrBadSignature)
|
|
|
|
// Unsigned: a transaction carrying no authorization at all must not become
|
|
// authorized by travelling.
|
|
unsigned := registerTx(t, k, testScheme, digestOf("unsigned"), 1)
|
|
unsigned.Auth, unsigned.Sig, unsigned.id = nil, nil, ids.Empty
|
|
mem, wire = bothWays(t, build(unsigned))
|
|
require.ErrorIs(t, mem, ErrUnsignedTx)
|
|
require.ErrorIs(t, wire, ErrUnsignedTx)
|
|
|
|
// A nonce out of order: refused both ways.
|
|
mem, wire = bothWays(t, build(registerTx(t, k, testScheme, digestOf("gap"), 9)))
|
|
require.ErrorIs(t, mem, ErrBadNonce)
|
|
require.ErrorIs(t, wire, ErrBadNonce)
|
|
|
|
// And an empty block does not become non-empty by being parsed.
|
|
mem, _ = bothWays(t, build())
|
|
require.ErrorIs(t, mem, ErrInvalidBlock)
|
|
}
|
|
|
|
// A fixed-width field read past the end of its message copies NOTHING, leaving
|
|
// the destination zeroed — the shape that silently turned a short signature
|
|
// into a well-formed unverifiable envelope in a sibling package. Here it cannot
|
|
// pass: a zeroed field re-serializes to bytes that are not the bytes that
|
|
// arrived, and the canonical rule refuses the difference.
|
|
func TestATruncatedFixedFieldIsRefusedNotZeroFilled(t *testing.T) {
|
|
sound := sampleTx().Bytes()
|
|
n, err := zapLen(sound)
|
|
require.NoError(t, err)
|
|
|
|
// Shrink the leading message's declared size so Payer and Subject fall
|
|
// outside it. zapLen still passes — it reads only the size — and zap.Parse
|
|
// still passes, because the header is intact.
|
|
for _, cut := range []int{txPayer + 4, txSubject + 4, txSubject + 20} {
|
|
truncated := append([]byte(nil), sound...)
|
|
size := zap.HeaderSize + cut
|
|
require.Less(t, size, n, "the cut must actually fall inside the message")
|
|
binary.LittleEndian.PutUint32(truncated[12:16], uint32(size))
|
|
|
|
if _, err := zapLen(truncated); err != nil {
|
|
t.Fatalf("cut %d: the length field is sound, zapLen must pass: %v", cut, err)
|
|
}
|
|
_, err := ParseTransaction(truncated)
|
|
require.Errorf(t, err, "cut %d: a field read past the end must refuse, not zero-fill", cut)
|
|
}
|
|
|
|
// The control: uncut, it parses and its fixed fields survive intact.
|
|
parsed, err := ParseTransaction(sound)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sampleTx().Payer, parsed.Payer)
|
|
require.Equal(t, sampleTx().Subject, parsed.Subject)
|
|
}
|