CircleVault

Decentralized savings for personal goals & community circles. Your money, your way. Built on Flare.

CircleVault

Created At

ETHGlobal Buenos Aires

Project Description

Demo Video: https://drive.google.com/drive/folders/10z2VkDF4etOaRvCJ7UyeSBYnSm_xSCVR?usp=sharing

CircleVault is a decentralized savings platform built on Flare network that reimagines how individuals and communities manage their finances through blockchain technology. The platform addresses the traditional challenges of savings such as limited accessibility, lack of transparency etc. by offering two distinct saving mechanisms:

Solo Vault enables users to create personalized savings goals for specific purposes such as education, emergencies, rent, or business capital. Users set their target amounts, contribute at their own pace, and maintain complete control over their funds with the flexibility to withdraw anytime or lock funds until goals are met.

Collective Vault digitizes the age-old tradition of rotating savings groups (known as Ajo or Esusu in various cultures). Friends, family, or community members can create savings circles where participants contribute fixed amounts on a regular schedule. The platform automates fund rotation, ensures transparent record-keeping, and eliminates the trust issues that plague traditional thrift systems. Each member receives their payout turn automatically, with all contributions and distributions recorded immutably on-chain. Built with gas efficiency in mind, CircleVault leverages minimal proxy contract patterns to minimize transaction costs, making it accessible for everyday users regardless of transaction size. All savings activities are secured through smart contracts deployed on Flare, ensuring transparency, immutability, and trustless execution. Users connect via Web3 wallets, maintain full custody of their assets, and benefit from real-time tracking of contributions, group activities, and goal progress.

How it's Made

CircleVault: Building a Flexible Savings Platform for Diverse Financial Needs

Project Overview

CircleVault is a Solidity-based savings platform on Flare's Coston2 testnet designed to serve different user needs:

  • Individual Savers: SingleVault enables solo users to accumulate funds toward personal goals with straightforward tracking
  • Group Savers: GroupVault enables communities to pool resources with transparent distribution mechanisms
  • Rotational Groups: Members contribute collectively and receive turns accessing the full pool (traditional rotating savings association, now trustless)
  • Distributed Groups: Members contribute and withdraw equal shares at any time

The platform's innovation is providing financial flexibility: same underlying protocol serves solo savers, traditional group savings circles, and hybrid models—all without requiring intermediaries or manual coordination.

Technology Stack

Smart Contracts (Solidity ^0.8.28)

  • CircleVault.sol: Platform hub managing user registration, vault creation (single & group), and verification (406 lines)
  • SingleVault.sol: Individual savings contracts with simple accumulation logic
  • GroupVault.sol: Complex group savings contract with dual-mode withdrawal (rotational/non-rotational)
    • Rotational Mode: Fisher-Yates shuffle with RandomNumberV2 for fair random recipient selection per period
    • Non-Rotational Mode: Equal distribution to all members regardless of contribution timing
    • Period-based payment tracking across multiple rounds
    • Dual withdrawal models: rotational payout vs split distribution
  • Factory.sol (MinimalProxy): ERC-1167 minimal proxy pattern for gas-efficient vault cloning (~25K gas per vault vs 500K+ for full deployment)
  • MockToken.sol: ERC20 test token for development

Testing & Deployment

  • Hardhat 3 Beta: Blazing fast compilation, deployment, and local network simulation with multiple testing methods (TypeScript/Viem, ethers, Solidity)
  • Forge-std: Advanced testing with state manipulation (vm.warp, vm.prank, vm.mockCall)
  • Viem: TypeScript client for contract interaction with type safety
  • Node.js native test runner: Running 90+ tests validating period progression, random selection, and edge cases

Partner Technologies & Benefits

  • Flare Network (Coston2): Provides native RandomNumberV2 oracle for verifiable randomness without expensive cross-chain calls
  • Flare's Secure Random Number Generator: Enables trustless rotational payouts where recipients are unpredictably but fairly selected each period—eliminates centralized manipulation
  • Hardhat 3 Beta: Supports multiple testing frameworks (TypeScript/Viem, ethers.js, Solidity forge-std) in single project and also Comprehensive logs, dramatically speeds up development workflow

How Technologies Work Together

SINGLE VAULT (Individual Savers):
1. User calls CircleVault.createVault() with _participant=0
2. Factory.createClone() deploys SingleVault proxy
3. SingleVault.initialize() sets: goalAmount, startTime, endTime, savingFrequency
4. User calls saveForGoal() each period → amountSaved increments
5. At endTime, user withdraws entire accumulated amount
→ Use case: Personal emergency fund, vacation savings, down payment goal

GROUP VAULT NON-ROTATIONAL (Equal-Share Groups):
1. Creator calls CircleVault.createVault() with _participant=3 (example)
2. Factory.createClone() deploys GroupVault proxy, rotational=false by default
3. GroupVault.initialize() sets: goalAmount (9000), amountPerPeriod (3000 per person)
4. Users join → creator accepts → all become validMembers
5. Each period: all members contribute amountPerPeriod
6. At endTime: all members withdraw equal share of pool
7. No random selection, transparent math: everyone gets their proportional share
→ Use case: Group project fund, shared household expenses, team goal pooling

GROUP VAULT ROTATIONAL (Taking Turns):
1. Creator calls CircleVault.createVault() with _participant=3
2. Factory.createClone() deploys GroupVault proxy
3. GroupVault.initialize() sets period structure
4. Creator calls addCreatorFeeRotateAndDefaultFee(creatorFee, rotational=true, penaltyFee)
5. Users join → creator accepts
6. Each period:
   - All 3 members contribute 1000 tokens (3000 total in pool)
   - After period ends, anyone calls processRotationalWithdrawal()
   - Calls Flare's RandomNumberV2 → selects random unpaid member
   - Selected member receives: (3000 - platformFee - creatorFee)
   - Fisher-Yates swap ensures O(1) selection, no reshuffling
7. Pattern repeats: different member selected each period
→ Use case: Traditional rotating savings circle (tanda/susu/hui/ajo), fair turn-taking systems, lottery-style distributions

Flare Integration: The Randomness Revolution

Without Flare: Would need Chainlink VRF (expensive, delayed finality) or centralized randomness (corrupted)

With Flare's RandomNumberV2:

  • Contract calls _generator.getRandomNumber() returning (uint256, bool isSecure, bool isSigned)
  • Benefit 1: Cryptographically-verifiable - recipients cannot be predicted or manipulated
  • Benefit 2: Native to Flare - no cross-chain delays or Chainlink oracle costs `

The Hacky Parts Worth Mentioning

1. Forking Coston2 to Test Deploy Script Locally

Initially, GroupVault.initialize() called ContractRegistry.getRandomNumberV2() directly, which reverted with "Internal error" on Coston2 due to:

  • Registry unavailability during initialization
  • Timelock/authorization checks failing on contract registry

Solution: Forked the Coston2 blockchain locally to safely test the deploy script and catch initialization errors before mainnet deployment. This prevented costly redeploys and allowed iterative fixes in a safe environment.

2. Event Parsing Complexity → Direct State Query

Originally, deployment script parsed GroupVaultCreated events to extract vault addresses:

// OLD: Fragile event parsing
const vaultCreatedEvent = groupReceipt.logs.find(log => {
  const decoded = decodeEventLog({abi, data: log.data, topics: log.topics});
  return decoded.eventName === 'GroupVaultCreated';
});
groupVaultCloneAddr = (decoded.args as any).vaultAddress;

Solution: Query contract state directly for simpler, more reliable code:

// NEW: Direct array access
const groupProxies = await circleVault.read.getAllGroupProxy() as Address[];
const groupVaultAddr = groupProxies[groupProxies.length - 1];

Benefit: Faster execution, no event parsing fragility, more readable code.

3. Fisher-Yates Swap for Gas-Efficient Recipient Tracking

Instead of maintaining a separate array/mapping of paid participants (which would exceed contract size limits/ cost more gas), implemented Fisher-Yates shuffle directly on allParticipant array:

// Move selected recipient to "paid" section by swapping with paidCount position
goal.allParticipant[actualIndex] = goal.allParticipant[paidCount];
goal.allParticipant[paidCount] = recipient;

Why: Avoids creating duplicate data structures, saves gas, keeps contract within size limits, and guarantees O(1) recipient selection without reshuffling.

4. Triple-Nested Mapping for Round Isolation

GroupVault tracks payments per period across multiple rounds:

mapping(uint256 => mapping(uint256 => mapping(address => bool))) public roundHasPaidForPeriod;
mapping(uint256 => mapping(uint256 => PeriodPayment)) public roundPeriodPayments;

Why: Enables different savings rounds without state conflicts. New round = new set of period payments without clearing previous data. Adds complexity but enables future multi-round functionality and clean state separation.

5. Expected vs Actual Amount Fee Calculation

Fees calculated on expected amount (all participants × amountPerPeriod), NOT actual contributions:

uint256 _expectedAmount = goal.amountPerPeriod * goal.allParticipant.length;
uint256 platformFee = (_expectedAmount * Fee) / 100;

Why: Prevents fee variance regardless of participation rate. Even if only 2 of 3 members paid, platform charges fees as if all 3 paid. Unintuitive but prevents gaming the fee system and ensures consistent platform revenue.

6. Mock RandomNumberV2 to Avoid StateChangeDuringStaticCall

When testing, Flare's getRandomNumber() is called as a static call within rotational withdrawal logic. Initial mock implementations modified state, causing revert.

Solution: Converted mock RNG to pure deterministic functions:

function getRandomNumber() public view returns (uint256, bool, bool) {
  uint256 randomValue = uint256(keccak256(
    abi.encodePacked(block.timestamp, block.prevrandao, msg.sender, block.number)
  ));
  return (randomValue, true, true);
}

Trade-off: Lost pseudo-randomness between blocks but gained test stability without modifying contract behavior.

7. PendingUser Ordering Constraint

Contract validates that addCreatorFeeRotateAndDefaultFee() cannot be called if users already joined:

if(pending.length > 0 && groupGoals.allParticipant.length > 0) {
  revert PendingUser();
}

Solution: Execute fee setup BEFORE user join loops in deployment script. This state management dependency isn't obvious and required careful test flow redesign.

Lesson: State initialization order matters deeply in financial contracts.

8. Period Processing Logic with Underflow Prevention

Only process completed periods using periodToProcess = currentPeriod - 1:

uint256 periodToProcess = currentPeriod - 1;
require(currentPeriod > 0, "No periods completed yet");
uint256 paidCount = periodToProcess; // Use directly, not periodToProcess - 1

Why: Prevents off-by-one errors and underflow bugs. Ensures we only process periods that have actually finished.

9. Iterative Round System Without State Reset

In startNewRound(), instead of clearing state and looping to reset parameters, use iterative rounds:

currentRound++; // Create new round context
roundGoals[currentRound] = _goal; // Fresh state for new round

Why: Avoids expensive state clearing loops, enables clean history tracking, and allows future audit queries on previous rounds without state conflicts.

10. Username Hashing Offchain to Save Gas

CircleVault stores usernames as bytes32 hashes. Initially, the contract converted usernames to bytes32 at runtime.

Solution: Move conversion offchain and pre-reserve empty username entries in constructor:

bytes32 private constant EMPTY_USERNAME_HASH = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
bytes32 private constant EMPTY_USERNAME = bytes32(0);

constructor(address _singleVault, address _groupVault, address _factory) { 
  admin = msg.sender;
  // Pre-reserve empty username constants to avoid duplicate checks later
  userNameMap[EMPTY_USERNAME_HASH] = msg.sender;
  userNameMap[EMPTY_USERNAME] = msg.sender;
  // ... rest of initialization
}

function register(bytes32 _username, bool _isAssetLiser) public {
  // Simple check: if username is taken, revert (covers empty cases automatically)
  if(userNameMap[_username] != address(0)){
    revert AlreadyTaken();
  }
  userNameMap[_username] = msg.sender;
}

Why:

  • Eliminates keccak256 hashing cost (~30 gas) per registration by moving computation offchain
  • Pre-reserving empty values in constructor means single if() check covers all cases instead of multiple validations
  • Reduces registration gas cost while maintaining uniqueness validation
  • Simpler, cleaner code with fewer conditionals

Testing Approach

  • Test Suite: 90 tests execute in <2 seconds with local Hardhat network

  • 90 tests validating:

    • Period progression with vm.warp() (time warping)
    • Random recipient selection correctness (Fisher-Yates shuffle)
    • Fee calculations across different scenarios
    • Multi-round state isolation
    • Edge cases (no periods completed, all paid, pending users)
  • Mock contracts handle Flare's RandomNumberV2 without needing real oracle

Deployment Flow

# Local hardhat node
npx hardhat node

# In another terminal
npx hardhat run scripts/deploy-and-setup.ts

# For Coston2
NETWORK=coston2 RPC_URL=https://rpc.ankr.com/flare_coston2 npx hardhat run scripts/deploy-and-setup.ts

# For forked Coston2 (simulate live network locally)
npx hardhat node --fork https://rpc.ankr.com/flare_coston2

The deployment script handles: contract deploys → token distribution → user registration → vault creation → multi-user acceptance flow.

Lessons Learned

  1. State Dependencies Matter: Test ordering and state setup aren't just quality-of-life issues - they reflect contract design constraints
  2. RNG Mocking Is Tricky: Understand whether functions are called as regular calls vs static calls before mocking
  3. Financial Logic Complexity: Period calculations, fee handling, and payment tracking require exhaustive edge case testing
  4. Proxy Pattern Trade-offs: Minimal proxies save gas but require careful implementation verification per clone
  5. Verbose Logging Saves Hours: Detailed deployment logs catch issues immediately instead of failing silently

Code Statistics

  • Smart Contracts: 2,500+ lines (main contracts)
  • Tests: 1,500+ lines (90 tests)
  • Mock Contracts: 200+ lines (4 modular mocks)
  • Deployment Script: 400+ lines (complete setup automation)

This project demonstrates how to build sophisticated financial mechanics on blockchain while maintaining gas efficiency, security, and testability(comprehensive edge case validation).

background image mobile

Join the mailing list

Get the latest news and updates