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.
[!NOTE] 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 samePrevioushash 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:
- 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
[!INFO] 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.
[!WARNING] Asynchronous callback The conflict callback runs asynchronously to avoid blocking block processing. The callback receives the
Conflictstruct 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.
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