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

[!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 same Previous hash for the same account.

Detection Process

Detection is deliberately split from recording, in two functions, so that a semantically-invalid block can never seed or extend a conflict record.

1. detectConflictSibling() — read-only

func detectConflictSibling(chain *AccountChain, b *Block) string

Scans the account chain for a block that already occupies the same (account, previous) slot as the incoming block, and returns its hash — or "" if there is none. For open blocks Previous is "0", so two competing opens match here. It records nothing.

2. Validate, then recordConflict()

Between the two steps the ledger applies a series of checks to the incoming block:

CheckOn failure
The on-chain sibling is already finalizedReject the block outright — a finalized prefix is immutable, so the incoming block can never win and staging it would create an unresolvable record
Asset is present and on the allowlistReject
ValidateStagedBlock against the parent balanceReject

Only then is the equivocation recorded:

func recordConflict(cs ConflictStore, account, previous, existingHash, newHash string) (isNew bool, err error)

recordConflict creates the record seeded with both hashes the first time the slot reaches two blocks (returning isNew = true), and otherwise appends the new hash if it is not already present. The validated block is then saved to staging.

[!WARNING] Why validation comes first Recording an unvalidated sibling would leave a phantom hash in the record whose body was never staged, freezing the account behind the unresolved-conflict guard until a quorum sweep evicted it — a cheap, remotely-triggerable wedge on any shared or publicly-keyed account.

[!INFO] Caller must hold the account lock Both functions must be called with the per-account lock held, to prevent race conditions between scanning the chain and creating the conflict record.

The Unresolved-Conflict Guard

While an account has any unresolved conflict record, it rejects new (non-conflicting) blocks:

account has unresolved conflict — block rejected pending resolution

Accepting new blocks during an open conflict would create orphaned children when the loser is swapped out during resolution. This is also why a conflict record that can never resolve is so costly, and why the sweep has explicit paths to drop stuck records.

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 false, nil // conflict tracked, but reject further equivocations
}

After the cap is reached, additional equivocating blocks are acknowledged as conflicting but neither added to the conflict record nor saved to staging.

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"`
}
FieldTypeDescription
AccountAddressstringHex-encoded public key of the equivocating account
PreviousHashstringThe shared Previous hash (the position)
BlockHashes[]string2–10 competing block hashes
DetectedAttime.TimeWhen the conflict was first detected
WeightSnapshotmap[string]uint64Representative weights, in micro-XE, frozen at detection time
TotalWeightuint64Total delegated weight frozen at detection time

Weight Snapshot

When a conflict first reaches size 2, the ledger takes a point-in-time snapshot of all delegation weights and stores it on the record together with their sum. Both are used for all subsequent vote weight lookups and as the quorum denominator. Freezing weights prevents manipulation — an attacker cannot shift delegation between detection and resolution to influence the outcome.

The record's BlockHashes list is not immutable after creation: the sweep trims candidate hashes whose bodies were never received and that no representative is backing.

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 election elects the staged block, the quorum resolution performs a swap — demoting the loser from the main chain to staging and promoting the winner.

A staged block is a real, validated body: representatives will happily converge-vote and final-vote for a sibling they hold only in staging. Refusing to would wedge an equivocated account, because no node would ever vote for the block it holds only in staging.

Conflict Callback

An asynchronous callback tells the VoteManager to drive voting at the position:

recordConflict() → isNew=true → ConflictCallback(conflict) → VoteManager.OnConflict()

It fires when a conflict first reaches size 2 (the isNew flag), with the weight snapshot already populated. It also fires when a sibling body newly arrives for an already-known conflict — a later equivocation, or a phantom body filled in by a targeted block pull. A newly-arrived body can change the deterministic preference and is often the missing piece for convergence, so re-driving immediately beats idling until the next 15-second sweep. Gossip re-deliveries of a body that is already staged never reach this path, so they cannot storm the callback.

[!WARNING] Asynchronous callback The conflict callback runs asynchronously so the heavy downstream work (vote store reads, quorum checks) never runs under the per-account lock.

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 detectConflictSibling() / recordConflict()) prevents race conditions during detection. The conflictRWMu protects cross-account scans.

  • Consensus Overview — how conflict detection fits into the finalization lifecycle
  • Delegation — weight snapshots taken at detection time
  • Voting — the callback that triggers vote casting
  • Quorum — finalization, block swap, and the stale-conflict sweep