A gas-optimized smart contract system for managing decentralized voting power through NFT delegation using cryptographic Merkle trees.
DavinciDAO Census Contract enables ERC-721 NFT holders to delegate their voting power to representatives. The contract uses Lean Incremental Merkle Trees (Lean-IMT) to maintain a verifiable census of voting weights on-chain, with automatic root updates and event emission for off-chain indexing.
- On-chain Merkle tree using Lean-IMT with automatic root calculation
- Gas-optimized operations with transient storage caching (EIP-1153)
- Batch operations for delegating multiple tokens in a single transaction
- Proof-based security preventing unauthorized weight manipulation
- Root history via circular buffer for historical verification
- Event-driven architecture for off-chain indexing via The Graph
┌─────────────────────────────────────────────────────────────────┐
│ Onchain Census Contract │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Delegation Mapping Lean-IMT Census Tree │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │TokenID → Delegate│───────────▶│ Address │ Weight │ │
│ └──────────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ Merkle Root ────┐ │
│ │ │
│ Root History (Circular Buffer) │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ Root₁ → Block₁ │◀────────┘ │
│ │ Root₂ → Block₂ │ │
│ │ ... │ │
│ └──────────────────────────────────────┘ │
│ │
│ Events ─────▶ The Graph Subgraph ─────▶ Client Queries │
└─────────────────────────────────────────────────────────────────┘
The easiest way to deploy and configure everything is using the automated deployment pipeline:
# View available commands and deployments
make help
# Deploy a configuration
make deploy <deployment-name>Example:
make deploy haberdashery # Deploy haberdashery configuration
make deploy test-sepolia # Deploy test configuration on SepoliaThe make deploy command automatically:
- Compiles and deploys the smart contract using Forge
- Updates subgraph configuration with the new contract address
- Deploys the subgraph to The Graph (if credentials provided)
- Configures
webapp/.envwith deployment details
- Foundry - Ethereum development toolkit
- Node.js & pnpm - For webapp and subgraph
- The Graph CLI - For subgraph deployment (optional)
- An Ethereum RPC endpoint (Alchemy, Infura, or local node)
- Deployment wallet with ETH for gas fees
-
Clone and install
git clone <repository-url> cd davincidao make install
-
Configure environment (optional)
Create a
.envfile in the project root with your credentials:PRIVATE_KEY=0x... # Deployer private key RPC_URL=https://ethereum-rpc-endpoint # RPC endpoint ETHERSCAN_API_KEY=... # For contract verification GRAPH_DEPLOY_KEY=... # The Graph deploy key GRAPH_SLUG=davinci-sepolia-test # Subgraph slug
If not provided, the deployment script will prompt you interactively.
-
Create a deployment configuration
Deployments are defined in
deployments/<name>/deploy.sol.Example
deploy.sol:// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Script, console2} from "forge-std/Script.sol"; import "../../src/DavinciDao.sol"; contract DeployDavinciDao is Script { function run() external { address[] memory collections = new address[](1); collections[0] = address(0x7c61Ae9629664D1CEEc8Abc0fD17CB0866d86d89); uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); DavinciDao census = new DavinciDao(collections); console2.log(" Contract deployed at: %s", address(census)); vm.stopBroadcast(); } }
-
Deploy
make deploy your-deployment
The deployment will:
- Prompt for any missing configuration
- Deploy the contract
- Update subgraph.yaml with the contract address and start block
- Deploy subgraph (if Graph credentials provided)
- Update webapp/.env with all configuration
-
Verify contract
make verify-contract CONTRACT=0x... CHAIN_ID=11155111
make test # Run Solidity tests
make run # Start webapp dev server
make build # Build smart contracts
make clean # Clean build artifacts# Using environment variables from .env
make deploy test-sepolia
# Or provide interactively when prompted:
# - Private key: 0x551c8ae18ba84d8279d2e8090c379520af28d9a3f62b94ae80e9c78cc8cb5520
# - RPC URL: https://ethereum-rpc-endpoint
# - Graph slug: testAfter deployment, start the webapp:
make runThe subgraph indexes all delegation events and maintains a queryable database of:
- Current delegations per account
- Historical weight changes
- Token delegation history
- Complete Merkle tree reconstruction data
See subgraph/README.md for deployment instructions.
A React-based web interface for:
- Connecting wallets (MetaMask, WalletConnect)
- Viewing your NFT holdings
- Delegating voting power to representatives
- Managing delegations
- Visualizing the Merkle tree
See webapp/README.md for setup and deployment.
A Go-based command-line tool for:
- Batch delegation operations
- Tree reconstruction and verification
- Integration testing
- Automated delegation workflows
See go-tool/README.md for usage.
The DavinciDAO contract implements the ICensusValidator interface, allowing external contracts (such as voting systems, governance contracts, or token-gated applications) to validate census roots on-chain.
interface ICensusValidator {
/// @notice Validates a census root and returns the block number when it was set
/// @param root The census Merkle root to validate
/// @return blockNumber The block number when this root was set (0 if invalid/evicted)
function getRootBlockNumber(uint256 root) external view returns (uint256 blockNumber);
}// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./ICensusValidator.sol";
contract VotingContract {
ICensusValidator public census;
constructor(address _censusContract) {
census = ICensusValidator(_censusContract);
}
function createProposal(uint256 censusRoot) external {
// Validate that the census root is valid and recent
uint256 rootBlock = census.getRootBlockNumber(censusRoot);
require(rootBlock > 0, "Invalid census root");
require(block.number - rootBlock < 100, "Census root too old");
// Create proposal with validated census...
}
}- The contract maintains a circular buffer of the last 100 roots
- Roots older than 100 updates will return
blockNumber = 0(evicted from history) - At 15s block time, this provides ~1-2 days of history
- For longer history, use off-chain indexing via The Graph subgraph
See src/examples/VotingExample.sol for a complete reference implementation showing how to:
- Validate census roots before using them
- Create proposals with census snapshots
- Check root age to ensure recent data
- Integrate with the ICensusValidator interface
delegate(address to, uint256 nftIndex, uint256[] calldata ids, uint88 currentWeightOfTo, uint256[] calldata toProof)
- Delegate voting power from owned NFTs to a representative
- Reverts if tokens are already delegated (use
updateDelegationinstead) - Requires Merkle proof if recipient already has voting weight
undelegate(uint256 nftIndex, uint256[] calldata ids, ProofInput[] calldata proofs)
- Remove delegation from tokens (caller must be current owner)
- Requires proofs for all affected delegates
- Emits
UndelegatedBatchandWeightChangedevents
updateDelegation(address to, uint256 nftIndex, uint256[] calldata ids, uint88 currentWeightOfTo, ProofInput[] calldata fromProofs, uint256[] calldata toProof)
- Move delegation from current delegate(s) to a new delegate
- More gas efficient than undelegate + delegate
- Requires proofs for both old and new delegates
getCensusRoot() → uint256
- Returns current Merkle root of the census tree
getRootBlockNumber(uint256 root) → uint256
- Returns block number when a specific root was set (0 if never set or evicted from buffer)
computeLeafWithWeight(address account, uint88 weight) → uint256
- Pure helper function to compute packed leaf value for an account
- Useful for client-side proof generation
getTokenDelegations(uint256 nftIndex, uint256[] calldata ids) → address[]
- Batch query for delegation status of multiple tokens
- Returns array of delegate addresses (address(0) if not delegated)
getNFTids(uint256 nftIndex, uint256[] calldata candidateIds) → uint256[]
- Returns token IDs that are delegated AND currently owned by caller
- Gas-optimized single-pass filter
The DavinciDAO contract uses LeanIMT (Lean Incremental Merkle Tree) to maintain the census of voting weights. To verify transactions or generate proofs off-chain, you must reconstruct the tree in a way that exactly matches the contract's tree state.
❌ WRONG: Querying current accounts and rebuilding
// This will NOT work!
const accounts = await subgraph.getAccounts() // accounts with weight > 0
const tree = new LeanIMT()
for (const acc of accounts) {
tree.insert(packLeaf(acc.address, acc.weight))
}
// ❌ Root will not match! Missing historical operationsProblem: This approach ignores the tree's history. When an account's weight goes to 0, the contract removes that leaf from the tree using LeanIMT._remove(). This causes the tree to rebalance, changing the structure and indices of remaining leaves. Simply inserting current accounts creates a different tree structure.
Example:
Contract operations:
1. INSERT Alice weight=3 → tree = [Alice] (index 0)
2. INSERT Bob weight=1 → tree = [Alice, Bob] (indices 0, 1)
3. INSERT Charlie weight=2 → tree = [Alice, Bob, Charlie] (indices 0, 1, 2)
4. REMOVE Bob (weight→0) → tree = [Alice, EMPTY, Charlie] (indices 0, 1, 2)
↑ CRITICAL: Bob's slot stays but becomes 0!
5. INSERT Dave weight=1 → tree = [Alice, EMPTY, Charlie, Dave] (indices 0, 1, 2, 3)
If you query current accounts and rebuild:
1. INSERT Alice weight=3 → tree = [Alice] (index 0)
2. INSERT Charlie weight=2 → tree = [Alice, Charlie] (indices 0, 1)
3. INSERT Dave weight=1 → tree = [Alice, Charlie, Dave] (indices 0, 1, 2)
↑ WRONG! Dave is at index 2, not 3
The trees have different structures and sizes:
- Contract tree: size=4, indices=[0, EMPTY, 2, 3]
- Rebuilt tree: size=3, indices=[0, 1, 2]
This causes:
1. Different Merkle roots (tree structure mismatch)
2. Invalid proofs (indices don't match contract's tree)
3. ARRAY_RANGE_ERROR when submitting transactions
The only way to reconstruct the tree correctly is to replay all WeightChanged events in chronological order, performing the exact same operations the contract performed:
// 1. Fetch ALL WeightChanged events in order
const events = await subgraph.getAllWeightChangeEvents() // Ordered by blockNumber, logIndex
// 2. Create empty tree
const tree = new LeanIMT((a, b) => poseidon2([a, b]))
// 3. Replay each event
for (const event of events) {
const account = event.account.id
const prevWeight = parseInt(event.previousWeight)
const newWeight = parseInt(event.newWeight)
// Pack leaf: (address << 88) | weight
const addr = BigInt(account)
const oldLeaf = (addr << 88n) | BigInt(prevWeight)
const newLeaf = (addr << 88n) | BigInt(newWeight)
if (prevWeight === 0 && newWeight > 0) {
// INSERT: New account getting weight
tree.insert(newLeaf)
} else if (newWeight === 0 && prevWeight > 0) {
// REMOVE: Account weight going to 0
// IMPORTANT: tree.update(index, 0n) sets the leaf to 0 but KEEPS the slot
// The tree size doesn't decrease - it maintains an empty slot at that index
const index = tree.indexOf(oldLeaf)
tree.update(index, 0n) // Sets to 0, but slot remains (tree size unchanged)
} else if (prevWeight > 0 && newWeight > 0) {
// UPDATE: Weight change (still > 0)
const index = tree.indexOf(oldLeaf)
tree.update(index, newLeaf)
}
}
// 4. Tree root now matches contract
console.log('Tree root:', tree.root)-
Chronological Order: Events MUST be processed in the exact order they were emitted
- Order by:
blockNumber ASC, logIndex ASC - The
logIndexis critical for intra-block ordering
- Order by:
-
Complete History: You need ALL
WeightChangedevents, not just current state- The subgraph tracks these in the
WeightChangeEvententity - Includes insertions, updates, AND removals
- The subgraph tracks these in the
-
Exact Operations: Match the contract's operations:
0 → >0: INSERT (new leaf)>0 → 0: REMOVE (update to 0, tree rebalances)>0 → >0: UPDATE (modify existing leaf)
-
Leaf Packing: Must match contract's format
// Contract: (address << 88) | weight uint256 leaf = (uint256(uint160(account)) << 88) | uint256(weight);