
SafeTea Wallet — Building a Minimal Multisig Wallet in Solidity
I built SafeTea, a minimal, gas-efficient multi-signature wallet that lets multiple owners collectively manage ETH and ERC20 assets through on-chain proposals and confirmations.
The goal was to keep it lean — no full OpenZeppelin dependency, no unnecessary abstractions. Just clean Solidity with a clear proposal lifecycle.
The Problem With Single-Key Wallets
A single private key is a single point of failure. For any shared treasury, team fund, or high-value wallet, you want multiple parties to agree before a transaction goes through. That's what multisig solves.
Most existing solutions are either too heavy (Gnosis Safe) or too simple (basic 2-of-3 with no expiry). SafeTea sits in the middle: a full proposal system with majority voting, expiration, and owner governance — all in one contract.
How It Works
Every action in SafeTea goes through a proposal lifecycle:
- An owner submits a transaction (target, value, calldata, expiry)
- Owners confirm or reject it
- Once majority threshold is reached, it executes automatically
- If the expiry passes without consensus, anyone can mark it expired
The majority threshold is calculated dynamically:
function getMajorityThreshold() public view returns (uint256) {
return (owners.length >> 1) + 1;
}
So a 3-owner wallet needs 2 confirmations, a 5-owner wallet needs 3, and so on. It scales automatically as owners are added or removed.
Transaction Flow
Submitting a transaction:
function submitTransaction(address to, uint256 value, bytes memory data, uint256 _expiry)
external onlyOwner returns (uint256 txIndex)
Expiry must be between now and 30 days out — no indefinitely pending proposals. Confirming auto-executes once threshold is hit:
function confirmTransaction(uint256 txIndex) external onlyOwner validTx(txIndex) {
// ...
if (transactions[txIndex].confirmations >= getMajorityThreshold()) {
_executeTransaction(txIndex);
}
}
Rejections work the same way — majority rejection cancels the proposal immediately.
Owner Governance
Adding or removing owners follows the exact same proposal pattern. An owner calls proposeOwner() with the address and proposal type (Add or Remove), and the rest of the owners vote on it.
This means no single owner can unilaterally change the wallet's ownership set. The last owner can never be removed either — the contract enforces a minimum of 2 owners at all times.
After an owner proposal executes, the factory is notified via safeTeaFactory.updateWalletOwners(owners) to keep the registry in sync.
Gas Optimizations
A few deliberate choices kept gas low:
- Structs use packed types (
uint8,uint16,uint32) instead ofuint256everywhere - Custom errors instead of
requirestrings ++iin loops, no redundant storage reads- No OpenZeppelin — the contract is self-contained
Contract Architecture
Three contracts make up the system:
SafeTeaWallet.sol— core multisig logicSafeTeaFactory.sol— deploys new wallets and maintains an owner registryISafeTeaFactory.sol— interface used by the wallet to call back into the factory
The factory pattern means users can deploy their own wallet instance without any privileged admin.
Frontend
The frontend is built with React, TypeScript, Viem, and Wagmi. WalletConnect integration via ConnectKit handles wallet connections. TanStack Query manages async contract reads and mutations.
The UI lets owners create wallets, submit transactions, vote, and track proposal status — all connected to live contract state.
Testing
Tests are written with Foundry and cover:
- ETH and ERC20 transfers
- Proposal confirmation and rejection flows
- Expiration handling
- Owner add/remove governance
forge test --coverage
Deployment
Contracts deploy to Sepolia via Foundry scripts:
forge script script/DeploySafeTeaFactory.s.sol:DeploySafeTeaFactory \
--rpc-url https://sepolia.infura.io/v3/$INFURA_KEY \
--broadcast --verify \
--etherscan-api-key $ETHERSCAN_API_KEY
Check it out:
Testnet: https://safetea.qzz.io
GitHub: SafeTeaWallet on GitHub
