A production-grade multi-signature wallet built on Ethereum with a FastAPI backend. Requires multiple owner approvals before any transaction executes. Includes cryptographic signature verification so no owner's private key ever touches the server.
- Overview
- Architecture
- Smart Contract
- REST API
- Security Model
- Project Structure
- Getting Started
- API Endpoints
- End-to-End Flow
- Key Concepts
- Roadmap
- Tech Stack
A multi-signature wallet requires M-of-N owner approvals before any ETH transfer executes. If the threshold is 2-of-3, any single owner acting alone cannot move funds — at least two must agree.
This project implements the full stack:
- A Solidity smart contract that enforces approval logic on-chain
- A FastAPI backend that exposes the contract as a REST API
- Cryptographic signature verification that proves caller identity without the server ever holding private keys
This mirrors how real-world multi-sig tools like Gnosis Safe operate — used by DeFi protocols to secure billions of dollars in on-chain assets.
Frontend (React / Mobile)
↕ HTTP + JSON
FastAPI Backend
├── Signature Verification (eth_account)
├── ContractService (web3.py singleton)
└── Pydantic request/response models
↕ web3.py
Deployed Smart Contract
↕
Ethereum Network (Ganache locally / Sepolia testnet)
The backend is a translation and verification layer — not a signer. It:
- Converts between Solidity types and JSON
- Verifies that API callers cryptographically own the addresses they claim
- Relays verified transactions to the blockchain
Private keys never leave the owner's device.
| Variable | Type | Description |
|---|---|---|
owners |
address[] |
Array of all owner addresses |
isOwner |
mapping(address => bool) |
O(1) ownership lookup |
requiredApprovals |
uint256 |
Minimum approvals to execute |
transactions |
Transaction[] |
All submitted transactions |
approved |
mapping(uint256 => mapping(address => bool)) |
Per-transaction approval tracking |
struct Transaction {
address to;
uint256 value;
bool executed;
uint256 approvalCount;
}| Function | Access | Description |
|---|---|---|
submitTransaction(to, value) |
Owners only | Propose a new ETH transfer |
approveTransaction(txId) |
Owners only | Vote to approve a transaction |
executeTransaction(txId) |
Internal | Fires automatically at threshold |
revokeApproval(txId) |
Owners only | Withdraw approval before execution |
getTransaction(txId) |
Public view | Read transaction details |
getTransactionCount() |
Public view | Total number of transactions |
getOwners() |
Public view | List all owner addresses |
| Modifier | Protects Against |
|---|---|
onlyOwner |
Non-owners calling restricted functions |
txExists |
Referencing non-existent transaction IDs |
notExecuted |
Re-approving or re-executing completed transactions |
notApproved |
An owner approving the same transaction twice |
| Event | Parameters | Emitted When |
|---|---|---|
TransactionSubmitted |
txId, submitter, to, value |
New transaction proposed |
TransactionApproved |
txId, owner |
Owner casts approval |
ApprovalRevoked |
txId, owner |
Owner withdraws approval |
TransactionExecuted |
txId |
Threshold reached, ETH sent |
Deposit |
sender, value |
ETH received by contract |
Request models validate all incoming data via Pydantic. Every write operation requires a cryptographic signature proving the caller owns their claimed address.
Response models convert raw Solidity types (address, uint256, bool) into clean JSON for frontend consumption.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/wallet |
Contract address, owners, threshold, balance |
GET |
/api/v1/balance |
Current ETH balance in the contract |
GET |
/api/v1/transactions |
All submitted transactions |
GET |
/api/v1/transactions/{tx_id} |
Single transaction details |
POST |
/api/v1/wallet/deposit |
Send ETH to the contract |
POST |
/api/v1/transactions/submit |
Propose a new transaction |
POST |
/api/v1/transactions/approve |
Approve a transaction |
POST |
/api/v1/transactions/revoke |
Revoke an approval |
Interactive documentation available at /docs when the server is running.
Layer 1 — API Level (signature verification)
Every write request includes a signed message
Backend recovers the signer address cryptographically
If recovered address ≠ claimed address → request rejected
Layer 2 — Contract Level (onlyOwner modifier)
Even if Layer 1 is bypassed, the contract checks ownership
Non-owner transactions revert on-chain
No state changes occur
Neither layer alone is sufficient. Together they form a complete security model.
1. Owner creates a message:
"Approve transaction 0 at timestamp 1735000000"
2. Owner signs it with their private key (MetaMask / local wallet)
→ produces signature: 0x77d0f1...
3. Owner sends to API:
{ "tx_id": 0, "from_address": "0x66aB...",
"signature": "0x77d0f1...", "message": "Approve transaction 0 at timestamp..." }
4. Backend recovers signer from signature:
recovered = w3.eth.account.recover_message(message, signature)
5. Backend checks: recovered == from_address
If not → "Signature mismatch" — request rejected
6. Backend checks timestamp age < 5 minutes
If expired → "Signature expired" — request rejected
7. Transaction submitted to blockchain from verified address
Messages include a Unix timestamp. The API rejects signatures older than 5 minutes — an attacker cannot reuse a captured signature after it expires.
# Message format
f"Approve transaction {tx_id} at timestamp {int(time.time())}"- Stores private keys
- Signs transactions on behalf of owners
- Trusts
from_addresswithout cryptographic proof
multisig-wallet/ # Brownie smart contract project
├── contracts/
│ └── MultiSigWallet.sol # Smart contract
├── tests/
│ └── test_multisig.py # 21 tests
├── scripts/
│ └── deploy.py # Brownie deployment script
└── build/
└── contracts/
└── MultiSigWallet.json # Compiled ABI + bytecode
multisig-api/ # FastAPI backend
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry point
│ ├── contract_service.py # web3.py blockchain interface
│ ├── models.py # Pydantic request/response models
│ └── routes.py # API endpoint definitions
├── deploy_web3.py # web3.py deployment script
├── test_signing.py # Local signature testing utility
├── requirements.txt
└── .env # Environment variables (never commit)
- Python 3.11+
- Node.js 16+
- Ganache Desktop App
- pip
git clone https://github.com/YOUR_USERNAME/multisig-wallet.gitcd multisig-wallet
python -m venv env
source env/bin/activate
pip install eth-brownie
brownie compile- Open the Ganache desktop app
- Create a new workspace named
multisig-dev - Set mnemonic to:
brownie - Set port to:
7545 - Save and start the workspace
cd multisig-api
source api-env/bin/activate # or create: python -m venv api-env
pip install -r requirements.txt
python deploy_web3.pyCopy the deployed contract address from the output.
Create multisig-api/.env:
GANACHE_URL=http://127.0.0.1:7545
CONTRACT_ADDRESS=<address from deploy output>
ABI_PATH=../multisig-wallet/build/contracts/MultiSigWallet.json
cd multisig-api
source api-env/bin/activate
uvicorn app.main:app --reloadAPI running at: http://127.0.0.1:8000
Interactive docs at: http://127.0.0.1:8000/docs
curl http://127.0.0.1:8000/api/v1/wallet{
"contract_address": "0x3194...",
"owners": ["0x66aB...", "0x33A4...", "0x0063..."],
"required_approvals": 2,
"balance_in_eth": 2.0
}curl -X POST http://127.0.0.1:8000/api/v1/transactions/submit \
-H "Content-Type: application/json" \
-d '{
"to": "0x21b4...",
"value_in_eth": 0.5,
"from_address": "0x66aB...",
"signature": "0x...",
"message": "Submit transaction to 0x21b4 at timestamp 1735000000"
}'curl -X POST http://127.0.0.1:8000/api/v1/transactions/approve \
-H "Content-Type: application/json" \
-d '{
"tx_id": 0,
"from_address": "0x66aB...",
"signature": "0x...",
"message": "Approve transaction 0 at timestamp 1735000000"
}'1. Deploy contract with 3 owners, threshold of 2
2. Deposit ETH into the contract
3. Owner 1 signs a message and submits a transaction via API
4. Owner 1 signs a message and approves via API
5. Owner 2 signs a message and approves via API
→ Threshold reached → executeTransaction fires automatically
→ ETH transferred to recipient on-chain
6. GET /transactions/0 shows executed: true
address[] public owners; // for iteration
mapping(address => bool) public isOwner; // for O(1) lookupThe array lets you list all owners. The mapping lets you check ownership instantly without looping. Both are needed — they solve different problems.
Every state-changing function follows this order:
// 1. CHECKS — validate everything first
require(isOwner[msg.sender], "Not an owner");
require(!approved[txId][msg.sender], "Already approved");
// 2. EFFECTS — update state
approved[txId][msg.sender] = true;
transactions[txId].approvalCount += 1;
// 3. INTERACTIONS — emit events, external calls
emit TransactionApproved(txId, msg.sender);Setting executed = true before sending ETH in executeTransaction prevents reentrancy attacks.
One shared web3.py connection is created at API startup and reused for every request — avoiding the overhead of opening a new blockchain connection per request. Same principle as database connection pooling.
cd multisig-wallet
source env/bin/activate
brownie test -vCovers deployment validation, ETH deposits, transaction submission, approval flow, threshold execution, ETH transfer confirmation, approval revocation, and all rejection cases including duplicate owners, invalid thresholds, double approvals, and non-owner access.
- JWT authentication layer for session management
- WebSocket support for real-time transaction status updates
- Deploy to Sepolia testnet with Infura/Alchemy
- Frontend React interface with MetaMask integration
- Transaction history with event log indexing
- Email/push notifications when threshold is reached
- Support for ERC20 token transfers, not just ETH
Smart Contract
- Solidity
^0.8.0 - Brownie
1.21.0 - pytest
Backend
- FastAPI
- web3.py
- Pydantic
- uvicorn
- eth-account
- python-dotenv
Local Blockchain
- Ganache Desktop
Never commit your .env file. Never share private keys. Always verify signatures server-side before submitting transactions. The .gitignore for this project excludes .env, api-env/, build/, __pycache__/, and *.pyc.
MIT