Skip to main content

Confidential Checkpoints System

Overview

The CheckpointsConfidential library is a critical innovation in Lunarys that enables historical tracking of encrypted values. It's specifically designed for the DAO governance system but also used in PrivacyPool for reserve history.

Why Checkpoints Are Needed

The Problem

In traditional (non-confidential) governance:

// Easy: Check voting power at past block
uint256 votingPower = token.balanceOfAt(user, blockNumber);

In confidential governance:

// Problem: How to track encrypted balances over time?
euint64 encryptedBalance = token.confidentialBalanceOf(user);
// But we need: balance at block X for proposal voting

**Challenge:**We need to know a user's encrypted token balance at a specific block number (when a proposal was created) to prevent:

  • Buying tokens after proposal creation to vote
  • Voting with the same tokens across multiple accounts
  • Manipulating vote outcomes

The Solution

CheckpointsConfidential creates a timeline of encrypted values:

struct TraceEuint64 {
Checkpoint[] _checkpoints;
}

struct Checkpoint {
uint48 _key; // Block number
euint64 _value; // Encrypted balance at that block
}

**Key insight:**The encrypted values are stored, allowing: Historical lookups of encrypted balances Time-travel queries (balance at block X) Maintains confidentiality (values still encrypted)


Architecture

Library Structure

library CheckpointsConfidential {
struct TraceEuint64 {
Checkpoint[] _checkpoints;
}

struct Checkpoint {
uint48 _key;
euint64 _value;
}

// Core functions
function push(TraceEuint64 storage self, uint48 key, euint64 value) internal
function lowerLookup(TraceEuint64 storage self, uint48 key) internal view returns (euint64)
function upperLookup(TraceEuint64 storage self, uint48 key) internal view returns (euint64)
function upperLookupRecent(TraceEuint64 storage self, uint48 key) internal view returns (euint64)
function latest(TraceEuint64 storage self) internal view returns (euint64)
function latestCheckpoint(TraceEuint64 storage self) internal view returns (bool exists, uint48 _key, euint64 _value)
function length(TraceEuint64 storage self) internal view returns (uint256)
function at(TraceEuint64 storage self, uint32 pos) internal view returns (Checkpoint memory)
}

Data Structure

The checkpoints are stored as a sorted array of block numbers and encrypted values:

Checkpoints Timeline:
Block Encrypted Value
100 -> euint64(1000)
150 -> euint64(1500)
200 -> euint64(2000)
250 -> euint64(1800)

Properties:

  • Sorted by block number (ascending)
  • Each checkpoint is a snapshot
  • Binary search for lookups (O(log n))
  • Append-only (historical values immutable)

Core Operations

1. Push (Create Checkpoint)

function push(
TraceEuint64 storage self,
uint48 key, // Block number
euint64 value // Encrypted value
) internal returns (euint64, euint64)

**Purpose:**Record a new encrypted value at current block

Example Usage:

// In ConfidentialGovernanceToken after transfer
function _afterTokenTransfer(address from, address to, euint64 amount) internal {
if (from != address(0)) {
euint64 newBalance = FHE.sub(_balances[from], amount);
_balanceCheckpoints[from].push(block.number, newBalance);
}
if (to != address(0)) {
euint64 newBalance = FHE.add(_balances[to], amount);
_balanceCheckpoints[to].push(block.number, newBalance);
}
}

Behavior:

Checkpoint[] storage ckpts = self._checkpoints;
uint256 pos = ckpts.length;

if (pos > 0) {
Checkpoint storage last = ckpts[pos - 1];

// If checkpoint already exists for this block, UPDATE it
if (last._key == key) {
last._value = value;
return (last._value, value);
}
}

// Otherwise, APPEND new checkpoint
ckpts.push(Checkpoint({ _key: key, _value: value }));
return (FHE.asEuint64(0), value);

Key points:

  • If block number already exists → update
  • Otherwise → append new checkpoint
  • Returns (oldValue, newValue) as encrypted values

2. LowerLookup (Balance At Or Before)

function lowerLookup(
TraceEuint64 storage self,
uint48 key // Target block number
) internal view returns (euint64)

**Purpose:**Find the encrypted value at or before a specific block

Algorithm:

uint256 len = self._checkpoints.length;

// Binary search
uint256 pos = _lowerBinaryLookup(self._checkpoints, key, 0, len);

// If found, return the value
return pos == len ? FHE.asEuint64(0) : _unsafeAccess(self._checkpoints, pos)._value;

Binary Search Logic:

function _lowerBinaryLookup(
Checkpoint[] storage self,
uint48 key,
uint256 low,
uint256 high
) private view returns (uint256) {
while (low < high) {
uint256 mid = (low + high) / 2;
if (_unsafeAccess(self, mid)._key < key) {
low = mid + 1;
} else {
high = mid;
}
}
return high;
}

Example:

// Checkpoints:
// Block 100 -> euint64(1000)
// Block 200 -> euint64(2000)
// Block 300 -> euint64(1500)

lowerLookup(150) → returns euint64(1000) // From block 100
lowerLookup(200) → returns euint64(2000) // Exact match
lowerLookup(250) → returns euint64(2000) // From block 200
lowerLookup(50) → returns euint64(0) // Before first checkpoint

Use case:

// Get voting power at proposal creation
euint64 votingPower = _balanceCheckpoints[voter].lowerLookup(proposalSnapshot);

3. UpperLookup (Balance After)

function upperLookup(
TraceEuint64 storage self,
uint48 key
) internal view returns (euint64)

**Purpose:**Find the encrypted value strictly AFTER a specific block

Example:

// Checkpoints:
// Block 100 -> euint64(1000)
// Block 200 -> euint64(2000)
// Block 300 -> euint64(1500)

upperLookup(150) → returns euint64(2000) // Next is block 200
upperLookup(200) → returns euint64(1500) // Next is block 300
upperLookup(300) → returns euint64(0) // No checkpoint after

**Use case:**Less common, mainly for range queries.

4. UpperLookupRecent (Optimized Recent Lookup)

function upperLookupRecent(
TraceEuint64 storage self,
uint48 key
) internal view returns (euint64)

**Purpose:**Optimized version of upperLookup for recent blocks

Optimization:

  • Checks last 5 checkpoints first (most likely scenario)
  • Falls back to binary search if not found
  • Much cheaper gas for recent lookups

Implementation:

uint256 len = self._checkpoints.length;

uint256 low = 0;
uint256 high = len;

// Check last 5 checkpoints (recent optimization)
if (len > 5) {
uint256 mid = len - (len % 5);
if (key < _unsafeAccess(self._checkpoints, mid)._key) {
high = mid;
} else {
low = mid + 1;
}
}

// Binary search in remaining range
uint256 pos = _upperBinaryLookup(self._checkpoints, key, low, high);
return pos == len ? FHE.asEuint64(0) : _unsafeAccess(self._checkpoints, pos)._value;

Use case:

  • Governance voting (proposals usually recent)
  • Real-time balance queries
  • Most common lookup pattern

5. Latest (Current Value)

function latest(TraceEuint64 storage self) internal view returns (euint64)

**Purpose:**Get the most recent encrypted value

Implementation:

uint256 pos = self._checkpoints.length;
return pos == 0 ? FHE.asEuint64(0) : _unsafeAccess(self._checkpoints, pos - 1)._value;

Use case:

// Get current balance (latest checkpoint)
euint64 currentBalance = _balanceCheckpoints[user].latest();

6. Latest Checkpoint (With Metadata)

function latestCheckpoint(
TraceEuint64 storage self
) internal view returns (
bool exists,
uint48 _key,
euint64 _value
)

**Purpose:**Get latest checkpoint with block number and existence flag

Returns:

struct LatestCheckpoint {
bool exists; // True if at least one checkpoint exists
uint48 _key; // Block number of latest checkpoint
euint64 _value; // Encrypted value at that block
}

Use case:

(bool exists, uint48 block, euint64 balance) = _balanceCheckpoints[user].latestCheckpoint();
if (exists) {
// User has history
console.log("Last update at block:", block);
}

Use Cases in Lunarys

1. DAO Governance Voting

**Problem:**Prevent vote manipulation

**Solution:**Use checkpoints to snapshot balances

contract ConfidentialGovernor {
// When proposal is created
function propose(...) external returns (uint256 proposalId) {
uint256 snapshot = block.number;
proposals[proposalId].snapshot = snapshot;
// Voting power will be checked at this block
}

// When user votes
function castVote(uint256 proposalId, uint8 support) external {
uint256 snapshot = proposals[proposalId].snapshot;

// Get voting power at snapshot block (historical lookup)
euint64 votingPower = governanceToken.getPastVotes(
msg.sender,
snapshot
);

// Vote with encrypted weight
_castVote(proposalId, support, votingPower);
}
}

Benefit:

  • Can't buy tokens after proposal to vote
  • Can't transfer tokens to alt account to vote twice
  • Encrypted weights prevent vote selling

2. Governance Token Delegation

Delegated voting power tracking:

mapping(address => TraceEuint64) private _delegatedVotes;

function delegate(address delegatee) external {
address delegator = msg.sender;
euint64 balance = _balances[delegator];

// Remove votes from old delegatee
address oldDelegatee = _delegates[delegator];
if (oldDelegatee != address(0)) {
euint64 oldVotes = _delegatedVotes[oldDelegatee].latest();
euint64 newVotes = FHE.sub(oldVotes, balance);
_delegatedVotes[oldDelegatee].push(block.number, newVotes);
}

// Add votes to new delegatee
euint64 currentVotes = _delegatedVotes[delegatee].latest();
euint64 newVotes = FHE.add(currentVotes, balance);
_delegatedVotes[delegatee].push(block.number, newVotes);

_delegates[delegator] = delegatee;
}

Benefit:

  • Delegate votes while keeping balances confidential
  • Historical delegation tracking
  • Prevent delegation manipulation

3. PrivacyPool Reserve History

Track encrypted reserves over time:

contract PrivacyPool {
using CheckpointsConfidential for CheckpointsConfidential.TraceEuint64;

TraceEuint64 private reserveATrace;
TraceEuint64 private reserveBTrace;

function settleSwap(...) external {
// Update reserves
reserveA = newReserveA;
reserveB = newReserveB;

// Checkpoint the new reserves
reserveATrace.push(block.number, reserveA);
reserveBTrace.push(block.number, reserveB);

lastUpdate = block.number;
}
}

Use cases:

  • Historical reserve analytics (encrypted)
  • Time-weighted average price (TWAP) calculations
  • Liquidity mining rewards
  • Impermanent loss tracking

Gas Optimization Strategies

Problem: Growing Array

Each checkpoint adds one element to the array:

  • Block 100: 1 checkpoint → O(1) lookup
  • Block 1000: 10 checkpoints → O(log 10) lookup
  • Block 10000: 100 checkpoints → O(log 100) lookup

Solution 1: UpperLookupRecent

For recent queries (most common), check last few elements first:

// Instead of full binary search
if (len > 5 && key >= _checkpoints[len - 5]._key) {
// Linear search last 5
return linearSearch(key, len - 5, len);
}
// Fall back to binary search

Gas savings: ~50% for recent lookups

Solution 2: Checkpoint Pruning (Future)

Periodic compression of old checkpoints:

// Example: Keep only checkpoints from last 100 blocks
function pruneOldCheckpoints(uint48 keepAfter) external {
// Remove checkpoints before keepAfter block
// Governance only, not for active voting
}

Solution 3: Sparse Checkpoints

Only create checkpoint if value changed significantly:

function push(TraceEuint64 storage self, uint48 key, euint64 value) internal {
euint64 lastValue = latest(self);

// Only checkpoint if changed by >0.1%
// (would need FHE comparison, expensive)
}

**Trade-off:**Accuracy vs gas cost


Security Considerations

1. Encrypted Value Leakage

**Question:**Does checkpoint history leak information?

**Answer:**No, values remain encrypted:

// All values are euint64 (encrypted)
Checkpoint {
_key: 100, // Public: block number
_value: euint64(...) // Private: encrypted balance
}

What observers see:

  • Block numbers of updates
  • Number of checkpoints
  • Actual values
  • Changes between checkpoints

2. Checkpoint Manipulation

**Attack:**User creates many checkpoints to DoS lookups

Mitigation:

  • Checkpoints auto-created on transfers
  • User can't manually create checkpoints
  • Max one checkpoint per block (updates existing)

Example:

// Block 100
push(100, euint64(1000)) → Creates checkpoint

// Later in block 100
push(100, euint64(1500)) → UPDATES same checkpoint (no new entry)

3. Historical Immutability

**Property:**Past checkpoints cannot be changed

**Why:**Array is append-only:

// Can only:
ckpts.push(...) → Add new checkpoint
ckpts[len-1] = ... → Update current block's checkpoint

// Cannot:
ckpts[i] = ... → Change past checkpoint (i < len-1)
delete ckpts[i] → Remove checkpoint

**Implication:**Historical voting power is tamper-proof


Performance Analysis

Storage Cost

Each checkpoint:

struct Checkpoint {
uint48 _key; // 6 bytes (block number)
euint64 _value; // ~32 bytes (encrypted value)
}
// Total: ~38 bytes per checkpoint

Estimate:

  • 1 year = ~2.6M blocks (Ethereum)
  • 1 checkpoint/day = 365 checkpoints
  • Storage: 365 × 38 = ~14KB per user
  • Cost: 2.8M gas ($20 at 50 gwei, $2000 ETH)

Acceptable for governance!

Lookup Cost

Binary search: O(log n)

CheckpointsIterationsGas Cost
10~3~3K
100~7~7K
1000~10~10K
10000~13~13K

Still cheaper than linear search O(n)!


Comparison with Alternatives

Alternative 1: ERC20Votes (OpenZeppelin)

Standard approach (non-confidential):

// OpenZeppelin ERC20Votes
mapping(address => Checkpoint[]) private _checkpoints;

struct Checkpoint {
uint32 fromBlock;
uint224 votes; // Plain uint, not encrypted
}

Lunarys approach:

// CheckpointsConfidential
mapping(address => TraceEuint64) private _checkpoints;

struct Checkpoint {
uint48 _key;
euint64 _value; // Encrypted!
}

Key difference: euint64 instead of uint224

Alternative 2: No Checkpoints (Unsafe)

Bad approach:

// Just use current balance (WRONG for governance)
function castVote(uint256 proposalId) external {
uint256 weight = balanceOf(msg.sender); // Current balance
_castVote(proposalId, weight);
}

Attack:

  1. Proposal created at block 100
  2. Attacker buys 1M tokens at block 101
  3. Attacker votes with 1M tokens
  4. Attacker sells tokens
  5. **Result:**Manipulated vote with borrowed capital

Checkpoints prevent this!

Alternative 3: Snapshot All Balances

Naive approach:

// Snapshot entire balance mapping (EXPENSIVE!)
function createProposal(...) external {
uint256 snapshot = block.number;
for (address user in allUsers) {
_snapshots[snapshot][user] = balanceOf(user);
}
}

Problems:

  • Costs O(N) gas (N = number of holders)
  • Unbounded gas cost
  • DoS vulnerability

Checkpoints:

  • Costs O(1) gas per transfer
  • Amortized cost
  • No DoS vector

Code Example: Full Integration

contract ConfidentialGovernanceToken is ERC7984 {
using CheckpointsConfidential for CheckpointsConfidential.TraceEuint64;

mapping(address => CheckpointsConfidential.TraceEuint64) private _balanceCheckpoints;
mapping(address => CheckpointsConfidential.TraceEuint64) private _delegateCheckpoints;

function _afterTokenTransfer(
address from,
address to,
euint64 amount
) internal override {
// Update balance checkpoints
if (from != address(0)) {
euint64 newBalance = FHE.sub(_balances[from], amount);
_balanceCheckpoints[from].push(block.number, newBalance);
}
if (to != address(0)) {
euint64 newBalance = FHE.add(_balances[to], amount);
_balanceCheckpoints[to].push(block.number, newBalance);
}

// Update delegate checkpoints
_updateDelegateCheckpoints(from, to, amount);
}

function getPastVotes(address account, uint256 blockNumber)
public
view
returns (euint64)
{
require(blockNumber < block.number, "Future block");
return _delegateCheckpoints[account].lowerLookup(uint48(blockNumber));
}

function getVotes(address account) public view returns (euint64) {
return _delegateCheckpoints[account].latest();
}
}

Summary

CheckpointsConfidential is a breakthrough for confidential governance:

Historical encrypted balances - Track values over time Efficient lookups - O(log n) binary search Governance security - Prevent vote manipulation Privacy preserved - All values remain encrypted Gas optimized - Append-only, sparse updates

**Without it:**Confidential governance would be impossible.

**With it:**Lunarys achieves the first fully private DAO voting system!


Further Reading


This is the magic that makes confidential voting possible!