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¶
- 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¶
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 |
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:
- 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()
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 |
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:
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¶
| 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.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