Skip to content

Delegation

Overview

Delegation is the mechanism by which accounts assign their voting power to a representative. Representatives do not hold funds — they accumulate the XUSD balances of their delegators as voting weight, which they use to resolve conflicts through the voting system.

Only XUSD confers weight

Only XUSD balances contribute to voting weight. XE balances are excluded entirely. A representative's weight is the sum of XUSD balances of all accounts that delegate to it — regardless of how much XE those accounts hold.

Setting a Representative

Every block includes an optional Representative field — a hex-encoded ed25519 public key (64 hex characters / 32 bytes). When an account publishes a block with this field set, it delegates its XUSD balance to that representative.

{
  "type": "send",
  "account": "a1b2c3...",
  "previous": "d4e5f6...",
  "balance": 50000,
  "asset": "XE",
  "representative": "f7e8d9...",
  "destination": "...",
  "amount": 10000,
  "signature": "...",
  "hash": "..."
}

Empty Representative Field

An empty Representative field ("") means keep the current delegation. It does not clear the delegation. This allows accounts to publish blocks (sends, receives, etc.) without having to re-specify their representative on every block.

// Empty Representative means "keep current delegation". Only change
// delegation when the block explicitly sets a new representative.
effectiveRep := newRep
if effectiveRep == "" {
    effectiveRep = oldRep
}

Changing vs. clearing delegation

There is currently no mechanism to clear delegation once set. An empty Representative field preserves the existing delegation. To effectively remove voting weight from a representative, delegate to a different key.

Non-XUSD Blocks

Blocks on non-XUSD assets (e.g., XE) can set or change the Representative field. The delegation mapping updates, but the weight does not change because only XUSD balances confer weight. The representative field on an XE block affects who the account delegates to, not how much weight they contribute.

Weight Calculation

A representative's voting weight equals the sum of XUSD balances of all accounts currently delegating to it.

Weight(R) = Σ XUSD_Balance(A) for all accounts A where Delegation(A) = R

Atomic Updates

Weight is updated atomically on every block via updateDelegation(). The process:

  1. Read the account's current XUSD balance and current representative
  2. Subtract the previous XUSD balance from the old representative's weight
  3. Update the delegation mapping (if a new representative is specified)
  4. Add the new XUSD balance to the effective representative's weight
  5. Persist the delegation to the DelegationStore if the store supports it
Step Operation Representative Weight Change
1 Old rep loses old balance oldRep -prevXUSDBal
2 Delegation map updated
3 New rep gains new balance effectiveRep +newXUSDBal

For XUSD blocks, prevBalance is the XUSD balance before the block. For non-XUSD blocks, the XUSD balance hasn't changed, so prevXUSDBal = newXUSDBal — the net weight change is zero (unless the representative changed).

big.Int Arithmetic

All weight values are stored and computed using Go's math/big.Int to prevent overflow. The XUSD supply can be large, and summing many account balances could exceed uint64 range. Weight underflow (which would indicate data corruption) is caught and logged, with the weight clamped to zero.

if l.weights[oldRep].Sign() < 0 {
    log.Printf("ERROR: delegation weight underflow for representative %s",
        shortAddr(oldRep))
    l.weights[oldRep].SetInt64(0)
}

Key Functions

updateDelegation(account, prevBalance, newBlock)

Updates the in-memory delegation and weight maps after a block is processed. Called with the account lock held.

Parameter Type Description
account string The account that published the block
prevBalance uint64 The account's asset balance before this block
newBlock *Block The newly confirmed block

GetVoteWeight(representative) *big.Int

Returns a copy of the total vote weight delegated to a representative. Returns zero if the representative has no delegators. Thread-safe (acquires read lock on delegation mutex).

GetRepresentative(account) string

Returns the current representative for an account, or "" if no delegation is set. Thread-safe.

GetTotalDelegatedWeight() *big.Int

Returns the sum of all representative vote weights across the entire network. Used as the denominator when checking quorum threshold. Thread-safe.

snapshotWeights() map[string]uint64

Returns a point-in-time copy of all representative weights as uint64 values. Used to freeze weights at conflict detection time. Values that exceed uint64 are clamped to math.MaxUint64.

Weight Snapshots

When a conflict is detected, the current delegation weights are snapshotted and stored on the Conflict struct:

type Conflict struct {
    AccountAddress string
    PreviousHash   string
    BlockHashes    []string
    DetectedAt     time.Time
    WeightSnapshot map[string]uint64  // rep → weight at detection time
    TotalWeight    uint64             // total delegated weight at detection time
}

Immutable for the conflict

Once snapshotted, the weights for a conflict are frozen. All vote weight lookups for that conflict use the snapshot, not the current live weights. This prevents an attacker from manipulating delegation between conflict detection and resolution — e.g., by rapidly shifting weight to a representative that voted for the attacker's preferred block.

The snapshot captures:

  • Per-representative weight — used when storing individual votes
  • Total delegated weight — used as the quorum denominator

Persistence

Delegation mappings are persisted via the DelegationStore interface:

type DelegationStore interface {
    PutDelegation(account, representative string) error
    DeleteDelegation(account string) error
}

On startup, if the store implements DelegationIterator, the ledger rebuilds its in-memory delegation and weight maps by iterating all stored delegations:

type DelegationIterator interface {
    IterateDelegations(fn func(account, representative string) error) error
}

This ensures delegation state survives node restarts without requiring a full chain replay.

Concurrency

The delegation system uses a sync.RWMutex (delegationMu) to protect the in-memory maps:

  • Write lock held during updateDelegation() — blocks are processed sequentially per account
  • Read lock held during GetVoteWeight(), GetRepresentative(), GetTotalDelegatedWeight(), and snapshotWeights()

This allows concurrent weight queries (e.g., during vote validation) without blocking block processing on other accounts.

  • Conflict Detection — when and how weight snapshots are taken
  • Voting — how representatives use their weight to vote
  • Quorum — how weight determines conflict resolution
  • Block Types — the Representative field on blocks