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:
Block 1 ← Block 2 ← Block 3 ← Block 4An 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.
#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:
- Scans the account chain for any existing block with the same
Previoushash as the incoming block - If no match: returns
(false, false, nil)— no conflict - If match found, loads or creates the conflict record:
- New conflict: creates a
Conflictwith both block hashes, returns(true, true, nil) - Existing conflict: appends the new hash (if not already present), returns
(true, false, nil)
- New conflict: creates a
#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 = 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
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.
#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.
#Related Pages
- 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