Skip to content

Conflict Detection

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:

Block 1 ← Block 2 ← Block 3 ← Block 4

An equivocation creates a fork:

                    ┌── 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.

Open block conflicts

Two competing open blocks (first block on an account) both have Previous = "0". This is detected as a conflict the same way — two blocks with the same Previous hash for the same account.

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.

func checkAndRecordConflict(cs ConflictStore, chain *AccountChain, b *Block) (isConflict bool, isNew bool, err error)
Return Type Meaning
isConflict bool A conflict exists (including newly created ones)
isNew bool This call caused the conflict set to reach size 2 for the first time
err error Storage 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)

Caller must hold the account lock

checkAndRecordConflict() must be called with the per-account lock held to prevent race conditions between scanning the chain and creating the conflict record.

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:

const maxConflictHashes = 10
if 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

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"`
}
Field Type Description
AccountAddress string Hex-encoded public key of the equivocating account
PreviousHash string The shared Previous hash (conflict point)
BlockHashes []string 2-10 competing block hashes
DetectedAt time.Time When the conflict was first detected
WeightSnapshot map[string]uint64 Representative weights frozen at detection time
TotalWeight uint64 Total 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:

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:

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.

Asynchronous callback

The conflict callback runs asynchronously to avoid blocking block processing. The callback receives the Conflict struct with the weight snapshot already populated.

ConflictStore Interface

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

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

Method Description
SaveConflict Create or update a conflict record
GetConflict Look up a conflict by account + previous hash
DeleteConflict Remove a resolved conflict
GetConflictsForAccount List all active conflicts for an account
GetAllConflicts List all active conflicts (used by the stale conflict sweeper)

Staged Block Methods

Method Description
SaveStagedBlock Store a conflicting block in staging
GetStagedBlock Retrieve a staged block by hash
DeleteStagedBlock Remove 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