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

go
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)}
FieldTypeDescription
RepPubKeystringHex ed25519 public key of the voting representative
BlockHashstringHash of the block this vote supports
ConflictAccountstringAccount that produced the equivocation
ConflictPrevstringThe shared Previous hash identifying the conflict
Timestampint64Unix nanoseconds when the vote was cast
Signature[]byteed25519 signature over the signing bytes (see encoding)
Weightuint64Snapshotted weight at vote time (not signed, set by receiver)

#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

go
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:

CheckRejection Reason
1Signature — ed25519 verify against voteSigningBytesInvalid or forged vote
2Weight > 0 — representative has delegated weightNon-representative or zero-weight node
3Conflict exists — the referenced conflict is in the conflict storeUnknown or already-resolved conflict
4Timestamp — within ±5 minutes of local timeStale or future-dated vote
go
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:

go
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:

go
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()
go
const maxPendingVotesPerConflict = 10
diagram
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

#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:

OffsetSizeField
01Version byte (0x01)
132RepPubKey (hex-decoded)
3332BlockHash (hex-decoded)
6532ConflictAccount (hex-decoded)
9732ConflictPrev (hex-decoded; "0" encodes as 32 zero bytes)
1298Timestamp (big-endian int64)
Total137

#Wire Format

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

OffsetSizeField
0137Signing bytes (see above)
1372Signature length (big-endian uint16)
139NSignature bytes
Total139 + NTypically 139 + 64 = 203 bytes for ed25519
go
func EncodeVote(v *Vote) ([]byte, error)func DecodeVote(data []byte) (*Vote, error)

#VoteEmitter

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

go
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

go
func NewVoteManager(store VoteStore, keyPair *KeyPair, ledger *Ledger) *VoteManager
ParameterDescription
storeVoteStore implementation for persisting votes
keyPairNode's ed25519 key pair for signing local votes
ledgerLedger 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

go
type VoteStore interface {    PutVote(vote *Vote) error    GetVotesByConflict(account, previous string) ([]*Vote, error)    HasVoted(account, previous, repPubKey string) (bool, error)}
MethodDescription
PutVoteStore a validated vote
GetVotesByConflictRetrieve all votes for a specific conflict (used by tallying)
HasVotedCheck 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.