Voting
How representatives vote to finalize blocks
Overview
The VoteManager runs the local representative's two-phase election at each chain position — a position being an account plus the Previous hash its blocks extend. It casts converge votes, escalates to irrevocable final votes, and validates and stores votes arriving from other representatives. After each stored vote the QuorumManager retallies to check whether a candidate has finalized.
The two vote kinds share one struct and are distinguished by the signed Final flag:
| Kind | Final | Mutability |
|---|---|---|
| Converge | false | Mutable — a representative may replace its converge vote as its preference shifts |
| Final | true | Irrevocable — once recorded, no later vote from that representative at that position is accepted |
There is one evolving vote slot per (account, previous, representative): a converge vote may be replaced, then promoted in place to a final vote. Only final votes finalize a block.
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 the position belongs to
ConflictPrev string // previous hash identifying the position
Timestamp int64 // unix nanoseconds
Final bool // true = final (irrevocable) vote; false = converge vote. Signed.
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 whose chain the position sits on |
ConflictPrev | string | The Previous hash identifying the position ("0" for an open position) |
Timestamp | int64 | Unix nanoseconds when the vote was cast |
Final | bool | Signed. true marks an irrevocable final vote |
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, but
Finalis TheWeightfield is not part of the signed payload — it is populated by the receiving node from the weight snapshot, so a representative cannot claim more weight than it has.Finalis signed, so a converge vote cannot be forged into a final vote (or vice versa) by flipping the bit.
Casting Votes
Voting is driven from three entry points, all of which funnel into castVoteLocked under the per-position lock:
| Trigger | When |
|---|---|
OnBlockAdded(b) | A block was accepted at an uncontested position (fires per block; skips genesis) |
OnConflict(c) | A fork first reaches size 2 at a position, or a sibling body newly arrives for a known fork |
SweepFrontiers() | Periodic backstop — re-drives every unfinalized position on every account chain |
Phase 1 — converge
- Short-circuit if the position is already resolved (its child is at or below the account's final-height watermark), or if this node is not a weighted representative.
- Re-affirm and stop if this representative already final-voted here — the stored final vote is re-broadcast, re-signed with a fresh timestamp so it is not rejected as stale, throttled to once per 15-second window.
- Honour an existing commit-lock: if the write-once store already holds a locked hash for the position, final-vote that hash regardless of current preference.
- Gather candidates — the caller's seed hash, any hash carrying votes at the position, and any hash in the conflict record.
- Filter to eligible candidates — those whose block body this node holds (on chain or in staging) or that appear in the conflict record. A vote-only hash nobody holds is excluded, so a validly-signed vote for a fabricated all-zero hash cannot silence voting at the position.
- Prefer the lowest hash among eligible candidates.
- Apply the dependency gate — withhold emission if the preferred block's dependencies are not finalized locally. Incoming votes are still tallied, so a lagging node never deadlocks the election.
- Sign, store, notify the QuorumManager, and broadcast via
VoteEmitter.
A preference change always emits immediately. A redundant re-emit of the same preference is throttled to once per 15-second window — a throttle, never a suppressor, so a vote dropped at a peer is re-delivered on the next window.
Phase 2 — final
A representative escalates when either lock condition holds:
- the preferred candidate carries a ≥67% converge supermajority of the position's total weight — and the candidate-stability window (3 seconds since the last genuinely-new held candidate appeared at the position) has elapsed; or
- the election is older than the fallback delay (10 seconds), the converge plurality leader is this node's own preference, and it carries a strict majority (>50%) of total weight.
When the quorum path defers only because the stability window has not elapsed, a one-shot re-drive is armed for the remaining time — so a quiet position finalizes one window later rather than waiting for the next 15-second sweep.
It then calls PutFinalVoteIfAbsent to write the write-once commit-lock, and final-votes whatever hash the store returns (which may be a pre-existing lock, not the requested one). The final vote is only emitted once the store's durability Sync succeeds; on failure emission is deferred and retried, and because the lock persists the retry is idempotent.
[!IMPORTANT] Why two safety bars, not one A node's local plurality among the votes it happens to have seen is not evidence the network agrees. Requiring the stability window on the quorum path gives a lower-hash sibling in flight time to arrive before an irrevocable lock forms; requiring
leader == prefand a strict majority on the fallback path means a node only ever locks the hash it would itself elect. Because each representative is commit-locked to at most one hash per position, two siblings can never both gather a strict majority — so at most one can ever be locked network-wide.
Deterministic preference
pref := lowestHash(eligible)All honest representatives converge on the same block — the lowest hash among candidates they hold. 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).
A conflict-record sibling whose body is missing locally deliberately stays eligible: it is a live "phantom" the block-pull machinery is fetching, and abstaining until it lands is what lets the deterministic winner be rehydrated and elected. A sibling held only in staging counts as held and votable — a fork's second block lives only in staging until it resolves, but it is a real, validated body.
Receiving Votes: ReceiveVote()
ReceiveVote validates and stores an incoming vote from the network.
Validation Steps
Votes pass through ValidateVote(), which checks three 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 | Timestamp — non-negative and within ±5 minutes of local time | Stale or future-dated vote |
const voteWindowNanos = int64(5 * 60 * 1e9) // ±5 minutes[!NOTE] A conflict record is not required Under two-phase finalization every position is voted on, contested or not, and an uncontested position carries no conflict record — so vote validation deliberately does not require one.
ValidateVotealso does not check thatBlockHashis a real candidate at the position; that filtering happens on the emission and tally sides, which require a held body whoseAccountandPreviousmatch the position.
Final Votes Are Irrevocable
A converge vote is mutable: a representative may replace its own converge vote as its preference shifts, and a peer's newer converge vote replaces the one previously stored for that representative. A final vote is not — once stored, any later or conflicting vote from that representative at that position is ignored:
existing, err := vm.store.GetVote(v.ConflictAccount, v.ConflictPrev, v.RepPubKey)
if existing != nil && existing.Final {
return nil // final votes are irrevocable — ignore further votes from this rep
}The durable commit-lock is what makes this hold across restarts, not just in memory.
Votes for an Already-Resolved Position
If a vote arrives for a position this node has already finalized, the vote is dropped and — when this node is a weighted representative — it re-broadcasts a freshly-signed final vote for the finalized child, derived from chain state, subject to the same 15-second per-position throttle as the ordinary re-affirm. Storing votes for a settled position is pure pollution, and the post-finalization cleanup deletes every representative's stored votes network-wide — so without that re-affirm a laggard that missed final-vote quorum could re-vote forever with only its own weight while its conflict record never clears.
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 re-emit throttle for the position is re-armed, so the next sweep may emit immediately — back-off never delays a vote that follows new network information.
- The
QuorumManageris notified viaOnVote()to re-tally the position's final votes. - Local two-phase voting is re-driven: a peer's vote may shift the converge leader or satisfy this node's lock condition.
- If a candidate has reached the threshold, finalization proceeds immediately.
Vote Buffering
Votes can arrive before the local node knows anything about the position they refer to. Because vote validation no longer requires a conflict record, such a vote is normally just accepted and stored like any other — there is nothing to wait for.
A small per-position buffer remains from the pre-#526 design. ReceiveVote consults it only when ValidateVote rejects a vote and a second check (isConflictNotFound) reports that the signature, weight and timestamp were all fine and the only thing missing was the conflict record:
- Maximum 10 buffered votes per position (DoS protection)
- Buffered votes are flushed and re-validated when
OnConflict()next fires for that position
const maxPendingVotesPerConflict = 10[!NOTE] Largely vestigial Since
ValidateVotestopped requiring a conflict record, the two checks test the same three conditions, so in practice a vote that fails validation also failsisConflictNotFoundand the buffer is rarely — if ever — reached from the live path. Do not rely on it: a vote for an unknown position is normally accepted and stored directly.
Vote Encoding
voteSigningBytes defines the canonical byte layout a vote's ed25519 signature covers. EncodeVote / DecodeVote wrap that layout in a compact binary framing (signing bytes plus a length-prefixed signature).
[!NOTE] Gossip carries JSON On the wire, vote gossip publishes votes as JSON-encoded
VoteMsgvalues, not the binary framing below. The binary form matters because it is what gets signed and verified.
Signing Bytes Layout
The signed payload (138 bytes) contains all vote fields except Signature and Weight:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Version byte (0x02) |
| 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) |
| 137 | 1 | Final flag (0 = converge, 1 = final) |
| Total | 138 |
DecodeVote rejects any other version byte.
Wire Format
The full encoded vote appends the signature to the signing bytes:
| Offset | Size | Field |
|---|---|---|
| 0 | 138 | Signing bytes (see above) |
| 138 | 2 | Signature length (big-endian uint16) |
| 140 | N | Signature bytes |
| Total | 140 + N |
Typically 140 + 64 = 204 bytes for ed25519.
func EncodeVote(v *Vote) ([]byte, error)
func DecodeVote(data []byte) (*Vote, error)[!NOTE] Weight is never taken from the sender
Weightis outside the signed payload and outside the binary framing. Whatever a sender puts there, the receiving node overwrites it — from the conflict's weight snapshot when one exists, otherwise from current delegated weight. 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, block/staging reads and conflict queries |
The QuorumManager is registered separately via SetQuorumManager() to break the circular dependency (QuorumManager also references VoteStore).
Close() cancels any pending re-drive timers and waits for in-flight callbacks, so a stopped node leaves no voting goroutines behind.
VoteStore Interface
type VoteStore interface {
PutVote(vote *Vote) error
GetVotesByConflict(account, previous string) ([]*Vote, error)
HasVoted(account, previous, repPubKey string) (bool, error)
GetVote(account, previous, repPubKey string) (*Vote, error)
}| Method | Description |
|---|---|
PutVote | Store a validated vote (overwrites this representative's converge vote at the position) |
GetVotesByConflict | Retrieve all votes at a position (used by tallying) |
HasVoted | Whether a representative has any vote at a position |
GetVote | The representative's current vote at a position, or nil — read to enforce that a final vote is never overwritten |
The write-once commit-lock lives in a separate interface, FinalVoteStore, whose records are never deleted:
type FinalVoteStore interface {
GetFinalVote(account, previous string) (hash string, ok bool, err error)
PutFinalVoteIfAbsent(account, previous, hash string) (stored string, written bool, err error)
}Serving Votes to Peers
After a position finalizes, the cleanup deletes every representative's stored votes — network-wide. A node that is still stuck on a losing fork therefore has nothing to pull from gossip. VotesForPosition(account, previous) answers a targeted vote pull with this node's stored votes for the position, plus — when this node is a weighted representative that has already finalized the child there — a freshly-derived signed final vote for that child. A final vote for a block behind the rollback wall is a commitment this representative can always soundly make.
Unlike the gossip re-affirm, this is a point-to-point answer to one requesting peer, so it is not subject to the broadcast throttle.
Concurrency
The VoteManager uses several independent locks:
- Per-position mutex (
sync.Mapof*sync.Mutex, keyedaccount|previous) — serializes the read-modify-write of a representative's vote slot, preventing double-counting via TOCTOU races - Pending votes mutex — protects the buffered vote map
- Re-emit throttle mutex — protects the per-position last-emit timestamps
- Candidate mutex — protects the candidate-stability window state and its pending re-drive timers
This allows votes for different positions to be processed concurrently while keeping each position's vote state consistent. Lock order is always per-position lock → QuorumManager lock; the sweep deliberately calls back out from under the quorum lock to preserve it.
Related Pages
- Consensus Overview — how voting fits into the finalization lifecycle
- Delegation — how voting weight is derived
- Conflict Detection — what triggers vote casting on a fork
- Quorum — how final votes are tallied and winners finalized
- Gossip — how votes are broadcast across the network
- Direct Messaging — the targeted
vote_requestpull