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
Weightfield 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
- Acquire the per-conflict mutex (prevents double-voting via TOCTOU)
- Check if this representative has already voted on the conflict — skip if so
- Select the block with the lexicographically lowest hash (deterministic tie-breaking)
- Look up weight from the conflict's weight snapshot (falls back to live weight if snapshot unavailable)
- Build the
Votestruct with timestamp, block hash, and conflict identifiers - Sign the vote with ed25519 using the node's private key
- Store the vote via
VoteStore.PutVote() - Notify the
QuorumManagerviaOnVote() - Broadcast the vote via the
VoteEmittercallback - 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 minutesNo 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:
- The
QuorumManageris notified viaOnVote()to retally the conflict - 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 = 10Vote 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
Weightfield 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) *VoteManagerParameter
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.Mapof*sync.Mutex) — serializesHasVoted+PutVoteto 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.
Related Pages
- Consensus Overview — how voting fits into the consensus lifecycle
- Delegation — how voting weight is derived
- Conflict Detection — what triggers vote casting
- Quorum — how votes are tallied and conflicts resolved
- Gossip — how votes are broadcast across the network