Skip to content

Voting

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)

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

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)

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.