Conflict Detection

Detecting double-spends in the lattice

#Overview

Conflict detection is the entry point to the consensus system. A conflict (equivocation) occurs when an account publishes two or more blocks that share the same Previous hash — meaning they both claim to follow the same parent block. This is the block lattice equivalent of a double-spend.

The detection system identifies these forks, records them, stages the conflicting block, and triggers the voting process.

#What Is an Equivocation?

In a well-behaved account chain, each block has a unique Previous hash pointing to the block before it:

diagram
Block 1 ← Block 2 ← Block 3 ← Block 4

An equivocation creates a fork:

diagram
                    ┌── Block 3a (send 500 XE to Alice)Block 1 ← Block 2 ─┤                    └── Block 3b (send 500 XE to Bob)

Both Block 3a and Block 3b reference Block 2 as their Previous. Only one can be valid.

#Detection Process

#checkAndRecordConflict()

The core detection function runs every time a new block is submitted to the ledger. It checks whether any existing block on the same account already uses the same Previous hash.

go
func checkAndRecordConflict(cs ConflictStore, chain *AccountChain, b *Block) (isConflict bool, isNew bool, err error)
ReturnTypeMeaning
isConflictboolA conflict exists (including newly created ones)
isNewboolThis call caused the conflict set to reach size 2 for the first time
errerrorStorage or lookup failure

The function:

  1. Scans the account chain for any existing block with the same Previous hash as the incoming block
  2. If no match: returns (false, false, nil) — no conflict
  3. If match found, loads or creates the conflict record:
    • New conflict: creates a Conflict with both block hashes, returns (true, true, nil)
    • Existing conflict: appends the new hash (if not already present), returns (true, false, nil)

#Conflict Cap

The number of block hashes in a single conflict is capped at 10. This prevents an attacker from generating unlimited equivocating blocks and consuming unbounded memory:

go
const maxConflictHashes = 10if len(record.BlockHashes) >= maxConflictHashes {    return true, false, nil // conflict tracked, but reject further equivocations}

After the cap is reached, additional equivocating blocks are acknowledged as conflicting but not added to the conflict record.

#The Conflict Struct

go
type Conflict struct {    AccountAddress string            `json:"account_address"`    PreviousHash   string            `json:"previous_hash"`    BlockHashes    []string          `json:"block_hashes"`    DetectedAt     time.Time         `json:"detected_at"`    WeightSnapshot map[string]uint64 `json:"weight_snapshot,omitempty"`    TotalWeight    uint64            `json:"total_weight,omitempty"`}
FieldTypeDescription
AccountAddressstringHex-encoded public key of the equivocating account
PreviousHashstringThe shared Previous hash (conflict point)
BlockHashes[]string2-10 competing block hashes
DetectedAttime.TimeWhen the conflict was first detected
WeightSnapshotmap[string]uint64Representative weights frozen at detection time
TotalWeightuint64Total delegated weight frozen at detection time

#Weight Snapshot

When the conflict callback fires, the ledger takes a point-in-time snapshot of all delegation weights. This snapshot is stored on the conflict and used for all subsequent vote weight lookups. Freezing weights prevents manipulation — an attacker cannot shift delegation between detection and resolution to influence the outcome.

#Block Staging

When a conflicting block is detected, it is not added to the main chain. Instead, it is saved to a separate staging area via the ConflictStore:

diagram
Main Chain:    ... ← Block 2 ← Block 3a (original)Staging:       Block 3b (conflicting)

The original block (first-seen) stays on the main chain. The second block goes to staging. If the voting process determines the staged block should win, the quorum resolution performs a swap — demoting the loser from the main chain to staging and promoting the winner.

#Conflict Callback

When a conflict first reaches size 2 (the isNew flag), an asynchronous callback fires. This callback is how the VoteManager learns about new conflicts:

diagram
checkAndRecordConflict() → isNew=true → ConflictCallback(conflict) → VoteManager.OnConflict()

The callback fires exactly once per conflict. Subsequent equivocating blocks (adding hashes 3 through 10) do not re-trigger the callback.

#ConflictStore Interface

The ConflictStore interface provides persistence for conflict records and staged blocks:

go
type ConflictStore interface {    // Conflict record CRUD    SaveConflict(c *Conflict) error    GetConflict(account, previousHash string) (*Conflict, error)    DeleteConflict(account, previousHash string) error    GetConflictsForAccount(account string) ([]*Conflict, error)    GetAllConflicts() ([]*Conflict, error)     // Staged block management    SaveStagedBlock(b *Block) error    GetStagedBlock(hash string) (*Block, error)    DeleteStagedBlock(hash string) error}

#Conflict Methods

MethodDescription
SaveConflictCreate or update a conflict record
GetConflictLook up a conflict by account + previous hash
DeleteConflictRemove a resolved conflict
GetConflictsForAccountList all active conflicts for an account
GetAllConflictsList all active conflicts (used by the stale conflict sweeper)

#Staged Block Methods

MethodDescription
SaveStagedBlockStore a conflicting block in staging
GetStagedBlockRetrieve a staged block by hash
DeleteStagedBlockRemove a staged block after conflict resolution

#Helper Functions

#GetConflictForBlock(cs, blockHash)

Scans all conflicts and returns the first one containing the given block hash. Uses a read lock for concurrent access. Returns (nil, false) if the block is not part of any known conflict.

#RemoveConflict(cs, account, previous)

Deletes the conflict record for a given account and previous hash. Called by confirmConflict during cleanup.

#NewConflict(account, previousHash, hashA, hashB)

Creates a new Conflict struct seeded with the two block hashes that caused the equivocation. Sets DetectedAt to the current time.

#Concurrency

Conflict operations use a package-level sync.RWMutex (conflictRWMu):

  • Read lock for GetConflictForBlock() — allows concurrent lookups
  • Write lock for RemoveConflict() — exclusive access during deletion

The per-account lock (held by the caller of checkAndRecordConflict()) prevents race conditions during detection. The conflictRWMu protects cross-account scans.

  • Consensus Overview — how conflict detection fits into the consensus lifecycle
  • Delegation — weight snapshots taken at detection time
  • Voting — the callback that triggers vote casting
  • Quorum — conflict resolution and block swap