A decentralized bill splitting application built on Base network with on-chain USDC settlements.
The BasedBills smart contract system consists of four main contracts:
Group.sol- Core logic for expense groups and bill splittingGroupFactory.sol- Factory contract for creating new Group instances using EIP-1167 clonesRegistry.sol- Tracks user-to-group mappingsIUSDC.sol- Interface for USDC token interactions
- Manages group members and their expense balances
- NEW: Custom group names instead of auto-generated ones
- Handles bill creation and splitting logic (including custom amounts)
- Implements settlement process with approval mechanism
- NEW: Gamble feature for all-or-nothing settlements
- Automatically distributes USDC when settlement conditions are met
- NEW: Complete settlement history tracking
- Creates new Group instances using minimal proxy pattern (EIP-1167)
- NEW: Accepts group names during creation
- Registers new groups with the Registry
- Ensures creators are members of groups they create
- Maintains mapping of users to their groups
- Only allows GroupFactory to register new groups
- Provides view functions for frontend integration
| Contract | Address | BaseScan |
|---|---|---|
| Group Logic | 0xb2a71877fbd3ea1a21ae894c7299b6f0b625a8aa |
Read Contract |
| Registry | 0x071164b35b896bc429d5f518c498695ffc69fe10 |
Read Contract |
| GroupFactory | 0x97191494e97a71a2366e459f49e2c15b61fb4055 |
Read Contract |
| USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
Read Contract |
| Contract | Address | BaseScan |
|---|---|---|
| Group Logic | 0xb2a71877fbd3ea1a21ae894c7299b6f0b625a8aa |
Read Contract |
| Registry | 0x071164b35b896bc429d5f518c498695ffc69fe10 |
Read Contract |
| GroupFactory | 0x759dead21af026b4718635bee60487f3a71d25f9 |
Read Contract |
| MockUSDC | 0x97191494e97a71a2366e459f49e2c15b61fb4055 |
Read Contract |
🆕 Enhanced MockUSDC Features:
- Added
mintForTest()function - anyone can mint 100 USDC for testing - Perfect for testing bill splitting and settlement flows
- No authentication required - just connect wallet and call the function
✅ All contracts are verified on BaseScan and Base Sepolia.
🌐 Network Default: The frontend now defaults to Base Sepolia (Testnet) for development, with Base Mainnet available as an alternative option.
🟠 Smart Network Guidance: The app provides helpful orange messages to guide users:
- On Base Mainnet: Suggests switching to Base Sepolia for easier testing without funds
- On Base Sepolia: Encourages using the
mintForTest()function to get 100 USDC instantly for testing
The messages are displayed in attractive orange boxes with proper line breaks for better readability.
- Group Names: Groups now have custom names instead of generated ones
- Address Book Suggestions: Frontend suggests addresses from groups
- Enhanced Bill Splitting: Custom amounts per participant
- Gamble Feature: All-or-nothing settlement alternative
- Settlement Rejection: Cancel settlements before approval/deposit (bills remain unsettled)
- Settlement Tracking: Complete history of settlements
- Auto-deployment:
deployments.jsonautomatically updated on deploy - 🆕 Test USDC Minting:
mintForTest()function in MockUSDC for easy testing - 🆕 Smart Error Handling: No more infinite API retries - shows clear error messages
- 🆕 Smart Network Guidance: Orange messages guide users between networks and testing features
The project includes a comprehensive verification script (verify-all-contracts.ts) that:
- ✅ Dynamically generates constructor arguments using viem's
encodeAbiParameters - ✅ Loads deployment addresses from
deployments.jsonautomatically - ✅ Resolves all dependencies (OpenZeppelin, local contracts) automatically
- ✅ Handles all contract types (Group, Registry, GroupFactory) with proper configurations
- ✅ Supports both BaseScan and Blockscout verification
- ✅ Provides EIP-1167 proxy verification instructions
- ✅ Rate limiting and error handling built-in
Usage:
npm run verify # Universal verification scriptThe script automatically:
- Checks current verification status
- Generates constructor arguments for each contract
- Submits unverified contracts for verification
- Monitors verification progress
- Provides proxy verification instructions
# Install dependencies
npm install
# Set up environment variables
cp .env.example .env
# Add your PRIVATE_KEY and ETHERSCAN_API_KEY
# Deploy contracts to Base Sepolia
npm run deploy
# Verify contracts on BaseScan (automatic)
npm run verify# Install dependencies
npm install
# Set up environment variables
cp .env.example .env
# Add your PRIVATE_KEY and ETHERSCAN_API_KEY
# Deploy contracts to Base Mainnet (uses real USDC)
npm run deploy-mainnet
# Verify contracts on BaseScan (automatic)
npm run verify-mainnet0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) instead of MockUSDC.
The frontend application supports both networks simultaneously:
- Base Mainnet: Production environment with real USDC
- Base Sepolia: Testnet environment with MockUSDC for testing
Users can switch between networks using the network selector in the app interface.
The contracts have been thoroughly tested with multi-account scenarios including:
- Group creation with multiple members
- Bill splitting (shared and personal expenses)
- Settlement process with approvals
- USDC distribution mechanics
- Node.js 18+
- Hardhat
- Base Sepolia testnet access
npm install
npx hardhat compileThe project is configured for Base Sepolia testnet. Update hardhat.config.ts for other networks.
// Through GroupFactory
address[] memory members = [alice, bob, charlie];
address newGroup = groupFactory.createGroup(members);// Add a shared expense
group.addBill("Dinner", 60e6, [alice, bob]); // 60 USDC split between alice and bob// 1. Trigger settlement
group.triggerSettlement();
// 2. Creditors approve
group.approveSettlement(); // Called by each creditor
// 3. Debtors fund their portion
usdc.approve(groupAddress, amountOwed);
group.fundSettlement();
// 4. Automatic distribution when conditions metThe gamble feature provides a fun, all-or-nothing alternative to traditional settlements:
- Propose: Any member can propose a gamble when there are unsettled debts
- Vote: ALL members must unanimously vote "yes" for the gamble to proceed
- Execute: A random member is selected as the "loser" who pays for all unsettled bills
- Settle: The loser owes the full amount to the original bill payers
- Fair Distribution: The loser reimburses original payers for the full gross amount
- Clean Slate: All previous balances are reset before applying gamble results
- Audit Trail: Bills are marked as settled with a settlement ID for tracking
- Automatic Settlement: After gamble execution, a new settlement is triggered
block.prevrandao which is NOT SECURE for production. For real-world use with actual value, implement Chainlink VRF for verifiable randomness.
Before Gamble:
- Alice paid $60 lunch (Bob owes $30, Charlie owes $30)
- Bob paid $40 groceries (Alice owes $20, Charlie owes $20)
- Net: Alice owes $10, Bob is owed $10, Charlie owes $20
After Gamble (Charlie loses):
- Charlie owes Alice $60 (for lunch bill)
- Charlie owes Bob $40 (for groceries bill)
- Total: Charlie owes $100, Alice/Bob get reimbursed fully
- Uses OpenZeppelin contracts for proven security patterns
- Implements proper access controls and validation
- All contracts verified on BaseScan for transparency
- Gamble randomness is NOT production-ready - use Chainlink VRF for real deployments
// Create a new group with specified members
function createGroup(address[] calldata _members) external returns (address groupAddress)
// Create a group with deterministic address (advanced)
function createGroupDeterministic(address[] calldata _members, bytes32 _salt) external returns (address groupAddress)// Get group address by ID
function getGroup(uint256 _groupId) external view returns (address)
// Get total number of groups created
function getTotalGroups() external view returns (uint256)
// Predict deterministic group address
function predictGroupAddress(bytes32 _salt) external view returns (address)
// Check if address is a valid group from this factory
function isValidGroup(address _groupAddress) external view returns (bool)// Get all groups a user belongs to
function getGroupsForUser(address _user) external view returns (address[] memory)
// Get number of groups a user belongs to
function getGroupCountForUser(address _user) external view returns (uint256)// Add a bill with equal split among participants
function addBill(
string calldata _description,
uint256 _amount,
address[] calldata _participants
) external
// Add a bill with custom amounts per participant
function addCustomBill(
string calldata _description,
address[] calldata _participants,
uint256[] calldata _amounts
) external
// Trigger settlement process (any member can call)
function triggerSettlement() external
// Approve settlement (creditors only)
function approveSettlement() external
// Fund settlement with USDC (debtors only)
function fundSettlement() external
// Reject/cancel settlement before approval/deposit (creditors/debtors)
function rejectSettlement() external// Get all member addresses
function getMembers() external view returns (address[] memory)
// Get number of members
function getMemberCount() external view returns (uint256)
// Get balance for specific member (positive = owed, negative = owes)
function getBalance(address _member) external view returns (int256)
// Get all member balances at once
function getAllBalances() external view returns (
address[] memory memberAddresses,
int256[] memory memberBalances
)
// Get settlement breakdown (creditors vs debtors)
function getSettlementAmounts() external view returns (
address[] memory creditors,
uint256[] memory creditorAmounts,
address[] memory debtors,
uint256[] memory debtorAmounts
)
// Check if settlement can be completed
function canCompleteSettlement() external view returns (bool)// Get all bills in the group
function getAllBills() external view returns (Bill[] memory)
// Get specific bill by ID
function getBill(uint256 _billId) external view returns (Bill memory)
// Get total number of bills
function getBillCount() external view returns (uint256)
// Get bills with pagination
function getBillsPaginated(uint256 _offset, uint256 _limit) external view returns (Bill[] memory)
// Get bills paid by specific member
function getBillsByPayer(address _payer) external view returns (uint256[] memory)address[] public members; // Array of group members
mapping(address => bool) public isMember; // Check if address is member
mapping(address => int256) public balances; // Member balances
address public usdcAddress; // USDC contract address
bool public settlementActive; // Is settlement in progress
uint256 public totalOwed; // Total amount to be settled
uint256 public fundedAmount; // Amount already funded
uint256 public billCounter; // Number of bills created
mapping(address => bool) public hasFunded; // Has member funded settlement
mapping(address => bool) public hasApproved; // Has member approved settlement// Propose a gamble to settle all debts (requires unanimous approval)
function proposeGamble() external
// Vote on an active gamble proposal
function voteOnGamble(bool _accept) external
// Cancel a gamble (only proposer can call)
function cancelGamble() external
// Get current gamble status
function getGambleStatus() external view returns (
bool active,
address proposer,
uint256 voteCount,
uint256 totalMembers,
bool hasVoted
)// Get bills that haven't been settled yet
function getUnsettledBills() external view returns (Bill[] memory)
// Get bills settled in a specific settlement
function getBillsBySettlement(uint256 _settlementId) external view returns (Bill[] memory)struct Bill {
uint256 id; // Unique bill ID
string description; // Bill description
uint256 totalAmount; // Total bill amount (USDC with 6 decimals)
address payer; // Who paid the bill
address[] participants; // Who participated in the expense
uint256[] amounts; // Exact amount each participant owes
uint256 timestamp; // When bill was created (block.timestamp)
uint256 settlementId; // ID of settlement that cleared this bill (0 = unsettled)
}import { parseUnits, formatUnits } from 'viem';
// Create a new group
const members = [alice.address, bob.address, charlie.address];
const groupAddress = await groupFactory.write.createGroup([members]);
// Add equal split bill
await group.write.addBill([
"Team Lunch",
parseUnits("60", 6), // 60 USDC
[alice.address, bob.address]
]);
// Add custom split bill
await group.write.addCustomBill([
"Groceries",
[alice.address, bob.address, charlie.address],
[parseUnits("40", 6), parseUnits("25", 6), parseUnits("35", 6)] // $40, $25, $35
]);
// Get all balances
const [addresses, balances] = await group.read.getAllBalances();
balances.forEach((balance, i) => {
const name = getNameFromAddress(addresses[i]);
const amount = formatUnits(balance < 0 ? -balance : balance, 6);
const status = balance < 0 ? "owes" : "owed";
console.log(`${name}: $${amount} ${status}`);
});
// Get bill history
const bills = await group.read.getAllBills();
bills.forEach(bill => {
console.log(`Bill ${bill.id}: ${bill.description}`);
console.log(`Total: $${formatUnits(bill.totalAmount, 6)}`);
console.log(`Paid by: ${getNameFromAddress(bill.payer)}`);
console.log(`Date: ${new Date(Number(bill.timestamp) * 1000).toLocaleDateString()}`);
});
// Get settlement breakdown
const [creditors, creditorAmounts, debtors, debtorAmounts] = await group.read.getSettlementAmounts();
// Trigger settlement
await group.write.triggerSettlement();
// Approve settlement (if you're a creditor)
await group.write.approveSettlement();
// Fund settlement (if you're a debtor)
// First approve USDC spending
const amountOwed = debtorAmounts[debtorIndex];
await usdc.write.approve([groupAddress, amountOwed]);
await group.write.fundSettlement();
// === NEW: Gamble Feature ===
// Propose a gamble (alternative to normal settlement)
await group.write.proposeGamble();
// Vote on a gamble
await group.write.voteOnGamble([true]); // Accept
// await group.write.voteOnGamble([false]); // Reject
// Check gamble status
const [active, proposer, voteCount, totalMembers, hasVoted] = await group.read.getGambleStatus();
console.log(`Gamble active: ${active}, Votes: ${voteCount}/${totalMembers}`);
// Cancel gamble (only proposer)
await group.write.cancelGamble();
// === NEW: Settlement Tracking ===
// Get unsettled bills
const unsettledBills = await group.read.getUnsettledBills();
console.log(`${unsettledBills.length} bills pending settlement`);
// Get bills from a specific settlement
const settlementBills = await group.read.getBillsBySettlement([1]); // Settlement ID 1
console.log(`Settlement 1 included ${settlementBills.length} bills`);// Custom hook for group data
function useGroupData(groupAddress: string) {
const [members, setMembers] = useState<string[]>([]);
const [balances, setBalances] = useState<{address: string, balance: bigint}[]>([]);
const [bills, setBills] = useState<Bill[]>([]);
useEffect(() => {
async function fetchData() {
const [addresses, balanceAmounts] = await group.read.getAllBalances();
const allBills = await group.read.getAllBills();
setMembers(addresses);
setBalances(addresses.map((addr, i) => ({
address: addr,
balance: balanceAmounts[i]
})));
setBills(allBills);
}
fetchData();
}, [groupAddress]);
return { members, balances, bills };
}
// Custom hook for settlement status
function useSettlementStatus(groupAddress: string) {
const [canSettle, setCanSettle] = useState(false);
const [settlementActive, setSettlementActive] = useState(false);
useEffect(() => {
async function checkStatus() {
const canComplete = await group.read.canCompleteSettlement();
const isActive = await group.read.settlementActive();
setCanSettle(canComplete);
setSettlementActive(isActive);
}
checkStatus();
}, [groupAddress]);
return { canSettle, settlementActive };
}
// Custom hook for gamble feature (NEW!)
function useGambleStatus(groupAddress: string) {
const [gambleStatus, setGambleStatus] = useState({
active: false,
proposer: '',
voteCount: 0,
totalMembers: 0,
hasVoted: false
});
useEffect(() => {
async function fetchGambleStatus() {
const [active, proposer, voteCount, totalMembers, hasVoted] = await group.read.getGambleStatus();
setGambleStatus({
active,
proposer,
voteCount: Number(voteCount),
totalMembers: Number(totalMembers),
hasVoted
});
}
fetchGambleStatus();
}, [groupAddress]);
return gambleStatus;
}
// Custom hook for bill tracking (NEW!)
function useBillTracking(groupAddress: string) {
const [unsettledBills, setUnsettledBills] = useState<Bill[]>([]);
const [settlementHistory, setSettlementHistory] = useState<{[key: number]: Bill[]}>({});
useEffect(() => {
async function fetchBillData() {
const unsettled = await group.read.getUnsettledBills();
setUnsettledBills(unsettled);
// Fetch settlement history (example for settlements 1-5)
const history: {[key: number]: Bill[]} = {};
for (let i = 1; i <= 5; i++) {
try {
const settlementBills = await group.read.getBillsBySettlement([i]);
if (settlementBills.length > 0) {
history[i] = settlementBills;
}
} catch (error) {
// Settlement doesn't exist yet
break;
}
}
setSettlementHistory(history);
}
fetchBillData();
}, [groupAddress]);
return { unsettledBills, settlementHistory };
}MIT License - see LICENSE file for details.