← Back to home

CircleVault

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

Problem Statement

Demo Video: https://drive.google.com/drive/folders/10z2VkDF4etOaRvCJ7UyeSBYnSm_xSCVR?usp=sharingCircleVault 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.

Solution

CircleVault: Building a Flexible Savings Platform for Diverse Financial NeedsProject OverviewCircleVault 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 trackingGroup Savers: GroupVault enables communities to pool resources with transparent distribution mechanismsRotational 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 timeThe platform's innovation is providingfinancial flexibility: same underlying protocol serves solo savers, traditional group savings circles, and hybrid models—all without requiring intermediaries or manual coordination.Technology StackSmart 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 logicGroupVault.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 periodNon-Rotational Mode: Equal distribution to all members regardless of contribution timingPeriod-based payment tracking across multiple roundsDual withdrawal models: rotational payout vs split distributionFactory.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 developmentTesting & DeploymentHardhat 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 safetyNode.js native test runner: Running 90+ tests validating period progression, random selection, and edge casesPartner Technologies & BenefitsFlare Network (Coston2): Provides native RandomNumberV2 oracle for verifiable randomness without expensive cross-chain callsFlare's Secure Random Number Generator: Enables trustless rotational payouts where recipients are unpredictably but fairly selected each period—eliminates centralized manipulationHardhat 3 Beta: Supports multiple testing frameworks (TypeScript/Viem, ethers.js, Solidity forge-std) in single project and also Comprehensive logs, dramatically speeds up development workflowHow Technologies Work TogetherSINGLE 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 distributionsFlare Integration: The Randomness RevolutionWithout 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 manipulatedBenefit 2: Native to Flare - no cross-chain delays or Chainlink oracle costs `The Hacky Parts Worth Mentioning1.Forking Coston2 to Test Deploy Script LocallyInitially,GroupVault.initialize()calledContractRegistry.getRandomNumberV2()directly, which reverted with "Internal error" on Coston2 due to:Registry unavailability during initializationTimelock/authorization checks failing on contract registrySolution: 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 QueryOriginally, deployment script parsedGroupVaultCreatedevents 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 TrackingInstead of maintaining a separate array/mapping of paid participants (which would exceed contract size limits/ cost more gas), implemented Fisher-Yates shuffle directly onallParticipantarray:// 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 IsolationGroupVault 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 CalculationFees 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 StateChangeDuringStaticCallWhen testing, Flare'sgetRandomNumber()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 ConstraintContract validates thataddCreatorFeeRotateAndDefaultFee()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 PreventionOnly process completed periods usingperiodToProcess = currentPeriod - 1:uint256 periodToProcess = currentPeriod - 1; require(currentPeriod > 0, "No periods completed yet"); uint256 paidCount = periodToProcess; // Use directly, not periodToProcess - 1Why: Prevents off-by-one errors and underflow bugs. Ensures we only process periods that have actually finished.9.Iterative Round System Without State ResetInstartNewRound(), instead of clearing state and looping to reset parameters, use iterative rounds:currentRound++; // Create new round context roundGoals[currentRound] = _goal; // Fresh state for new roundWhy: 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 GasCircleVault 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 offchainPre-reserving empty values in constructor means singleif()check covers all cases instead of multiple validationsReduces registration gas cost while maintaining uniqueness validationSimpler, cleaner code with fewer conditionalsTesting ApproachTest Suite: 90 tests execute in <2 seconds with local Hardhat network90 testsvalidating:Period progression withvm.warp()(time warping)Random recipient selection correctness (Fisher-Yates shuffle)Fee calculations across different scenariosMulti-round state isolationEdge cases (no periods completed, all paid, pending users)Mock contractshandle Flare's RandomNumberV2 without needing real oracleDeployment 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_coston2The deployment script handles: contract deploys → token distribution → user registration → vault creation → multi-user acceptance flow.Lessons LearnedState Dependencies Matter: Test ordering and state setup aren't just quality-of-life issues - they reflect contract design constraintsRNG Mocking Is Tricky: Understand whether functions are called as regular calls vs static calls before mockingFinancial Logic Complexity: Period calculations, fee handling, and payment tracking require exhaustive edge case testingProxy Pattern Trade-offs: Minimal proxies save gas but require careful implementation verification per cloneVerbose Logging Saves Hours: Detailed deployment logs catch issues immediately instead of failing silentlyCode StatisticsSmart 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).

Hackathon

ETHGlobal Buenos Aires

2025

Contributors