Decentralized savings for personal goals & community circles. Your money, your way. Built on Flare.
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.
CircleVault is a Solidity-based savings platform on Flare's Coston2 testnet designed to serve different user needs:
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.
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
Without Flare: Would need Chainlink VRF (expensive, delayed finality) or centralized randomness (corrupted)
With Flare's RandomNumberV2:
_generator.getRandomNumber() returning (uint256, bool isSecure, bool isSigned)Initially, GroupVault.initialize() called ContractRegistry.getRandomNumberV2() directly, which reverted with "Internal error" on Coston2 due to:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
if() check covers all cases instead of multiple validationsTest Suite: 90 tests execute in <2 seconds with local Hardhat network
90 tests validating:
vm.warp() (time warping)Mock contracts handle Flare's RandomNumberV2 without needing real oracle
# 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.
This project demonstrates how to build sophisticated financial mechanics on blockchain while maintaining gas efficiency, security, and testability(comprehensive edge case validation).

