Voting

How representatives vote on conflicting blocks

Overview

The VoteManager handles casting and receiving votes for conflicts. When a conflict is detected, the local representative casts a signed vote for one of the competing blocks. Votes from other representatives arrive over the network, are validated, and stored. After each stored vote, the QuorumManager retallies to check if resolution has been reached.

Vote Struct

type Vote struct {
    RepPubKey       string // hex-encoded ed25519 public key of the representative
    BlockHash       string // hex-encoded hash of the block voted for
    ConflictAccount string // account address of the conflict
    ConflictPrev    string // previous hash of the conflict
    Timestamp       int64  // unix nanoseconds
    Signature       []byte // ed25519 signature over voteSigningBytes
    Weight          uint64 // vote weight snapshot at cast time (not signed)
}

Field

Type

Description

RepPubKey

string

Hex ed25519 public key of the voting representative

BlockHash

string

Hash of the block this vote supports

ConflictAccount

string

Account that produced the equivocation

ConflictPrev

string

The shared Previous hash identifying the conflict

Timestamp

int64

Unix nanoseconds when the vote was cast

Signature

[]byte

ed25519 signature over the signing bytes (see encoding)

Weight

uint64

Snapshotted weight at vote time (not signed, set by receiver)

[!NOTE] Weight is not signed The Weight field is not part of the signed payload. It is populated by the receiving node using the weight snapshot from the conflict detection time. This prevents a representative from claiming more weight than it actually has.

Casting Votes: OnConflict()

OnConflict is the ConflictCallback implementation. It fires exactly once when a conflict first reaches size 2.

Process

  1. Acquire the per-conflict mutex (prevents double-voting via TOCTOU)
  2. Check if this representative has already voted on the conflict — skip if so
  3. Select the block with the lexicographically lowest hash (deterministic tie-breaking)
  4. Look up weight from the conflict's weight snapshot (falls back to live weight if snapshot unavailable)
  5. Build the Vote struct with timestamp, block hash, and conflict identifiers
  6. Sign the vote with ed25519 using the node's private key
  7. Store the vote via VoteStore.PutVote()
  8. Notify the QuorumManager via OnVote()
  9. Broadcast the vote via the VoteEmitter callback
  10. Flush any pending votes that arrived before this conflict was detected

Deterministic Tie-Breaking

blockHash := lowestHash(c.BlockHashes)

All honest representatives vote for the same block — the one with the lowest hash. This prevents an attacker from controlling the outcome by manipulating network propagation order (e.g., sending Block A to some nodes first and Block B to others).

Receiving Votes: ReceiveVote()

ReceiveVote validates and stores an incoming vote from the network.

Validation Steps

Votes pass through ValidateVote() which checks four conditions:

Check

Rejection Reason

1

Signature — ed25519 verify against voteSigningBytes

Invalid or forged vote

2

Weight > 0 — representative has delegated weight

Non-representative or zero-weight node

3

Conflict exists — the referenced conflict is in the conflict store

Unknown or already-resolved conflict

4

Timestamp — within ±5 minutes of local time

Stale or future-dated vote

const voteWindowNanos = int64(5 * 60 * 1e9) // ±5 minutes

No Vote Flipping

A representative can only vote once per conflict. After a vote is stored, subsequent votes from the same representative on the same conflict are silently ignored:

voted, err := vm.store.HasVoted(v.ConflictAccount, v.ConflictPrev, v.RepPubKey)
if voted {
    return nil // idempotent: already voted, silently ignore
}

This prevents a representative from changing its vote after seeing how others voted.

Weight Assignment

When storing a received vote, the weight is set from the conflict's weight snapshot, not from the vote itself:

if conflict != nil && conflict.WeightSnapshot != nil {
    v.Weight = conflict.WeightSnapshot[v.RepPubKey]
} else {
    w := vm.ledger.GetVoteWeight(v.RepPubKey)
    // ...
}

This prevents timing attacks where an attacker inflates weight between conflict detection and voting.

Post-Storage

After successful storage:

  1. The QuorumManager is notified via OnVote() to retally the conflict
  2. If quorum is reached, conflict resolution proceeds immediately

Vote Buffering

Votes can arrive before the local node has detected the referenced conflict (e.g., due to network propagation delays). These are buffered rather than rejected.

Buffer Rules

  • Votes must still pass signature, weight, and timestamp checks
  • Only the conflict existence check is deferred
  • Maximum 10 buffered votes per conflict (DoS protection)
  • Buffered votes are flushed when the conflict is eventually detected via OnConflict()
const maxPendingVotesPerConflict = 10
Vote arrives → ValidateVote fails (conflict not found)

    ├── Passes signature, weight, timestamp? → Buffer it
    └── Fails other checks? → Reject it

Later: OnConflict() fires → flushPendingVotes() replays buffered votes

[!INFO] Why buffer? In a distributed network, different nodes detect conflicts at different times. A representative far from the equivocating node may receive votes from nearby representatives before it receives the conflicting block itself. Buffering prevents these valid votes from being lost.

Vote Encoding

Votes are serialized to a compact binary format for network transmission.

Signing Bytes Layout

The signed payload (137 bytes) contains all vote fields except Signature and Weight:

Offset

Size

Field

0

1

Version byte (0x01)

1

32

RepPubKey (hex-decoded)

33

32

BlockHash (hex-decoded)

65

32

ConflictAccount (hex-decoded)

97

32

ConflictPrev (hex-decoded; "0" encodes as 32 zero bytes)

129

8

Timestamp (big-endian int64)

Total

137

Wire Format

The full encoded vote appends the signature to the signing bytes:

Offset

Size

Field

0

137

Signing bytes (see above)

137

2

Signature length (big-endian uint16)

139

N

Signature bytes

Total

139 + N

Typically 139 + 64 = 203 bytes for ed25519

func EncodeVote(v *Vote) ([]byte, error)
func DecodeVote(data []byte) (*Vote, error)

[!NOTE] Weight not transmitted The Weight field is not part of the wire format. Each receiving node looks up the weight from its own copy of the conflict's weight snapshot. This prevents a malicious node from inflating its claimed weight.

VoteEmitter

The VoteEmitter is an optional callback that broadcasts locally cast votes to the network:

VoteEmitter func(v *Vote)

When set, it is called after the vote is stored locally and the QuorumManager is notified. The emitter typically feeds into the gossip layer for network-wide distribution.

VoteManager Construction

func NewVoteManager(store VoteStore, keyPair *KeyPair, ledger *Ledger) *VoteManager

Parameter

Description

store

VoteStore implementation for persisting votes

keyPair

Node's ed25519 key pair for signing local votes

ledger

Ledger reference for weight lookups and conflict queries

The QuorumManager is registered separately via SetQuorumManager() to break the circular dependency (QuorumManager also references VoteStore).

VoteStore Interface

type VoteStore interface {
    PutVote(vote *Vote) error
    GetVotesByConflict(account, previous string) ([]*Vote, error)
    HasVoted(account, previous, repPubKey string) (bool, error)
}

Method

Description

PutVote

Store a validated vote

GetVotesByConflict

Retrieve all votes for a specific conflict (used by tallying)

HasVoted

Check if a representative already voted on a conflict

Concurrency

The VoteManager uses two levels of locking:

  • Per-conflict mutex (sync.Map of *sync.Mutex) — serializes HasVoted + PutVote to prevent double-counting via TOCTOU race conditions
  • Pending votes mutex — protects the buffered vote map

This allows votes for different conflicts to be processed concurrently while ensuring each conflict's vote state is consistent.