This project has not undergone an audit and is provided as-is without any warranties.
This project presents a proof of concept (PoC) to manage multiple on-chain wallets, each represented by a smart contract called a Deposit Contract, through a single contract called the MasterContract which serves as the main entry point.
The MasterContract manages multiple deposit addresses deployed as minimal proxy contracts (EIP-1167 clones) and can execute transactions on behalf of these addresses without requiring each deposit contract to hold ETH for gas fees.
This approach provides a form of gas sponsorship: a single operator pays gas once to move funds out of many deposit contracts.
The supported token standards are ERC-20, ERC-721, and ERC-1155, as well as native ETH.
Several Ethereum standards address the problem of gas sponsorship, i.e. allowing a user (or a contract) to have transactions executed without paying gas themselves. This section compares the Deposit Contract approach with ERC-2612, ERC-2771, ERC-4337, and EIP-7702.
| Feature | Deposit Contract (this project) | ERC-2612 (Permit) | ERC-2771 (Meta-Tx) | ERC-4337 (Account Abstraction) | EIP-7702 (Set Code for EOAs) |
|---|---|---|---|---|---|
| Scope | Multi-wallet management with centralized gas payment | Gasless ERC-20 approvals only | Generic meta-transactions via trusted forwarder | Full account abstraction with smart contract wallets | Temporary/persistent code delegation for EOAs |
| User custody | Funds are controlled by the MasterContract operator | User retains full custody | User retains full custody | User delegates custody to a smart wallet. Owner of the smart wallet controls the smart wallet | User retains full custody |
| Gas payer | MasterContract operator (EOA) | Relayer calling permit |
Gas relay (paymaster) via on-chain trusted forwarder | Bundler, optionally reimbursed by a Paymaster | Transaction sender or a sponsor |
| Protocol changes required | None | None | None | None (higher-layer only) | Yes (new transaction type 0x04) |
| Token support | ERC-20, ERC-721, ERC-1155, native ETH | ERC-20 only | contract must inherit ERC2771Context and authorizes the forwarder used by the paymaster/gas relayer |
Any | Any |
| Signature required from user | No | Yes (EIP-712 off-chain signature) | Yes (off-chain signature verified by forwarder) | Yes (UserOperation signature) |
Yes (authorization tuple signature) |
| On-chain infrastructure | MasterContract + DepositContract clones | permit() function on the ERC-20 token |
Trusted Forwarder contract | EntryPoint singleton + Bundler network + optionally paymaster | None (native EVM support) |
| Decentralization | Centralized (operator-controlled) | Decentralized (user-initiated) | Semi-decentralized (depends on relayer) | Decentralized (open bundler network). Some ERC-4337 wallet can share wallet ownership between end-user and platform provider (MPC wallet notably) | Decentralized (user-initiated) |
| Complexity | Low | Low | Medium | High | Medium |
The Deposit Contract model is best suited for centralized or semi-centralized custody scenarios where:
- An operator manages many deposit addresses (e.g., exchange hot wallets, payment processors, treasury management).
- Deposit addresses only need to receive funds from external parties and have their funds withdrawn by a trusted operator.
- Simplicity and low deployment cost are priorities over decentralization and user self-custody.
- No user interaction or signature is needed at withdrawal time.
For use cases requiring user self-custody, decentralization, or general-purpose gas abstraction, solutions like ERC-4337 (with Paymasters) or EIP-7702 are more appropriate.
The system consists of two contracts:
- MasterContract: The central control contract that creates deposit contracts, queues transfers, and executes them. It uses OpenZeppelin's
AccessControlfor role-based permissions. - DepositContract: A minimal proxy contract that holds funds and delegates execution authority exclusively to the MasterContract.
Deposit contracts are deployed as EIP-1167 minimal proxy clones, making creation gas-efficient. They can also be deployed deterministically using CREATE2 for predictable addresses.
| Role | Description |
|---|---|
DEFAULT_ADMIN_ROLE |
Full admin, inherits all other roles |
PAUSER_ROLE |
Can pause/unpause the contract |
CREATE_DEPOSIT_CONTRACT_ROLE |
Can create new deposit contracts |
TRANSFER_REQUEST_CREATOR_ROLE |
Can queue and remove transfers |
TRANSFER_REQUEST_EXECUTOR_ROLE |
Can execute queued transfers |
Deposit contracts are created by calling createDepositContracts (non-deterministic) or createDeterministicDepositContracts (deterministic, using CREATE2 with a salt).
External users send funds (ETH, ERC-20, ERC-721, ERC-1155) directly to a deposit contract address. The deposit contract can receive ETH via its receive() function and tokens via standard transfer mechanisms (ERC721Holder, ERC1155Holder).
The operator calls queueTransfers with an array of transfer requests. Each request specifies:
- The source deposit contract
- The recipient address
- The token address and amounts/token IDs
- The operation type (
NATIVE,ERC20,ERC721,ERC1155)
The operator executes transfers by calling:
executeNextTransferto process one transfer at a time (FIFO order)flushPendingTransfersto execute all pending transfers in a single transaction
The MasterContract calls executeTransaction on each deposit contract, which performs the actual token transfer. The operator pays gas once for batch operations across many deposit contracts.
.
├── doc
│ ├── audit # Audit reports
│ ├── compilation # Flattened contracts for verification
│ ├── coverage # Code coverage reports
│ ├── ERC # Reference ERC/EIP specifications
│ ├── script # Surya documentation scripts
│ └── surya # Surya-generated diagrams
├── lib
│ └── openzeppelin-contracts
├── script # Deployment scripts
├── src
│ ├── DepositContract.sol
│ ├── MasterContract.sol
│ ├── invariant # Interfaces, errors, events, enums
│ ├── mocks # Mock contracts for testing
│ └── modules # AccessControlModule
└── test # Foundry tests
| Symbol | Meaning |
|---|---|
| 🛑 | Function can modify state |
| 💵 | Function is payable |
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| DepositContract | Implementation | ReentrancyGuard, ERC1155Holder, ERC721Holder, DepositContractInvariant, DepositContractInterface | ||
| └ | Public ❗️ | 🛑 | NO❗️ | |
| └ | executeTransaction | External ❗️ | 🛑 | nonReentrant onlyMaster |
| └ | External ❗️ | 💵 | NO❗️ |
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| MasterContract | Implementation | ReentrancyGuard, AccessControlModule, Pausable, MasterContractInvariant | ||
| └ | Public ❗️ | 🛑 | AccessControlModule | |
| └ | pendingCurrentTransfers | Public ❗️ | NO❗️ | |
| └ | pendingTransferCount | Public ❗️ | NO❗️ | |
| └ | allDepositContracts | Public ❗️ | NO❗️ | |
| └ | predictDeterministicAddress | Public ❗️ | NO❗️ | |
| └ | predictDeterministicAddresses | Public ❗️ | NO❗️ | |
| └ | checkSaltValidity | Public ❗️ | NO❗️ | |
| └ | pause | Public ❗️ | 🛑 | onlyRole |
| └ | unpause | Public ❗️ | 🛑 | onlyRole |
| └ | createDeterministicDepositContracts | Public ❗️ | 🛑 | onlyRole whenNotPaused |
| └ | createDepositContracts | Public ❗️ | 🛑 | onlyRole whenNotPaused |
| └ | queueTransfers | Public ❗️ | 🛑 | onlyRole whenNotPaused |
| └ | executeNextTransfer | Public ❗️ | 🛑 | nonReentrant onlyRole whenNotPaused |
| └ | removeTransfer | Public ❗️ | 🛑 | onlyRole |
| └ | removeTransferBatch | Public ❗️ | 🛑 | onlyRole |
| └ | flushPendingTransfers | Public ❗️ | 🛑 | nonReentrant onlyRole whenNotPaused |
| └ | _commonCheckERC20Native | Internal 🔒 | ||
| └ | _commonCheckERC721_1155 | Internal 🔒 | ||
| └ | _executeNextTransfer | Internal 🔒 | 🛑 | |
| └ | _removeTransfer | Internal 🔒 | 🛑 | |
| └ | _pushDepositContracts | Internal 🔒 | 🛑 |
This project is not audited.
An audit was performed by ABDK on a previous version of this project. Several improvements have been made since, notably additional validation checks in the queueTransfers function.
slither . --checklist --filter-paths "openzeppelin-contracts|test|forge-std|mocks" > slither-report.mdSee crytic/slither
myth analyze src/DepositContract.sol --solc-json solc_setting.json > myth_depositContract_report.mdmyth analyze src/MasterContract.sol --solc-json solc_setting.json > myth_masterContract_report.mdaderyn --output aderyn-report.md -x src/mocksSee Cyfrin/aderyn
This section presents a list of potential improvement to this project suggested by Claude Code.
| # | Improvement | Description | Drawback |
|---|---|---|---|
| 1 | Revert in executeNextTransfer when all queued transfers are removed |
When all remaining entries have STATUS.REMOVED, the function silently succeeds without executing anything, desynchronizing off-chain accounting. |
Minor behavior change for callers that treat a successful transaction as a no-op signal. |
| 2 | Validate depositContract addresses in queueTransfers |
Any address can be passed as depositContract without verification it was created by this MasterContract, allowing a compromised creator role to target arbitrary contracts. |
One extra SLOAD per queued transfer; mapping must stay in sync with clone creation. |
| 3 | Skip failing transfers in flushPendingTransfers |
A single reverting transfer causes the entire batch to revert with no skip mechanism, blocking all subsequent transfers in the queue. | Per-transfer failures are silent; partially executed batches require off-chain reconciliation. |
| 4 | Prevent double removal in removeTransfer |
Calling removeTransfer twice on the same ID emits a duplicate TransferRemoved event, confusing off-chain indexers tracking transfer lifecycle. |
One additional SLOAD per removal call. |
Description: When all remaining entries have STATUS.REMOVED, the function silently advances nextTransferIndex to the end without executing anything and without reverting. Off-chain executors observing a successful transaction may incorrectly assume a transfer occurred, desynchronizing accounting systems.
Drawback: Minor behavior change for callers that currently treat a successful transaction as a no-op signal.
function executeNextTransfer() public nonReentrant onlyRole(TRANSFER_REQUEST_EXECUTOR_ROLE) whenNotPaused {
uint256 nextTransferIndexLocal = nextTransferIndex;
if (nextTransferIndexLocal == pendingTransfers.length) {
revert MasterContract_NoPendingTransfers();
}
bool executed = false;
uint256 pendingTransferLengthLocal = pendingTransfers.length;
for (; nextTransferIndexLocal < pendingTransferLengthLocal; ++nextTransferIndexLocal) {
Transfer memory transfer = pendingTransfers[nextTransferIndexLocal];
if (transfer.status != STATUS.REMOVED) {
++nextTransferIndexLocal;
_executeNextTransfer(transfer);
executed = true;
break;
}
}
// All remaining entries were REMOVED — nothing executed
if (!executed) revert MasterContract_NoPendingTransfers();
nextTransferIndex = nextTransferIndexLocal;
}Description: The queueTransfers function accepts any address in TransferInput.depositContract without verifying it was created by this MasterContract. A compromised TRANSFER_REQUEST_CREATOR_ROLE holder could queue transfers targeting arbitrary contracts, causing unpredictable behavior during execution.
Drawback: One extra SLOAD per queued transfer; the mapping must be populated on every clone creation.
// New state variable
mapping(address => bool) public isDepositContract;
// Populate in _pushDepositContracts
function _pushDepositContracts(DepositContract clone, DepositContract[] memory newContracts, uint256 i)
internal returns (DepositContract[] memory)
{
isDepositContract[address(clone)] = true; // <-- add this
newContracts[i] = clone;
depositContracts.push(clone);
emit DepositContractCreated(clone);
return newContracts;
}
// Guard in queueTransfers
if (!isDepositContract[address(transfer.depositContract)]) {
revert MasterContract_UnknownDepositContract();
}Description: A single reverting transfer (e.g., insufficient token balance, non-payable recipient) causes the entire batch to revert. There is no skip mechanism, meaning one problematic entry can permanently block the flush path until manually removed.
Drawback: Silent per-transfer failures are harder to debug; partially executed batches require careful off-chain reconciliation.
// New event in MasterContractInvariant
event TransferFailed(uint256 indexed id, bytes reason);
// In flushPendingTransfers, replace the direct _executeNextTransfer call:
for (; nextTransferIndexLocal < pendingTransfersLengthLocal; ++nextTransferIndexLocal) {
Transfer memory transfer = pendingTransfers[nextTransferIndexLocal];
if (transfer.status != STATUS.REMOVED) {
try DepositContract(payable(transfer.transferInput.depositContract))
.executeTransaction(
transfer.transferInput.to,
transfer.transferInput.amounts,
transfer.transferInput.token,
transfer.transferInput.tokenIds,
transfer.transferInput.operation
)
{
emit TransferExecuted(transfer.id);
} catch (bytes memory reason) {
emit TransferFailed(transfer.id, reason);
}
}
}Description: _removeTransfer does not check whether a transfer already has STATUS.REMOVED. Calling it twice on the same ID emits a duplicate TransferRemoved event, which can confuse off-chain indexers and monitoring systems tracking transfer lifecycle.
Drawback: One additional SLOAD per removal call.
// New error in MasterContractInvariant
error MasterContract_TransferAlreadyRemoved();
// In _removeTransfer, add before setting status:
function _removeTransfer(uint256 transferId_) internal {
if (transferId_ < nextTransferIndex) {
revert MasterContract_TransferAlreadyExecuted();
}
if (transferId_ >= pendingTransfers.length) {
revert MasterContract_TransferIdInvalid();
}
if (pendingTransfers[transferId_].status == STATUS.REMOVED) {
revert MasterContract_TransferAlreadyRemoved(); // <-- add this
}
pendingTransfers[transferId_].status = STATUS.REMOVED;
emit TransferRemoved(transferId_);
}npx prettier --write --plugin=prettier-plugin-solidity 'src/**/*.sol'npx prettier --write --plugin=prettier-plugin-solidity 'test/**/*.sol'See ./doc/script
Foundry is a fast, portable and modular toolkit for Ethereum application development written in Rust.
Foundry consists of:
- Forge: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- Cast: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Local Ethereum node, akin to Ganache, Hardhat Network.
- Chisel: Fast, utilitarian, and verbose Solidity REPL.
forge test --gas-reportforge buildforge testforge fmtforge snapshotanvilforge script script/Counter.s.sol:CounterScript --rpc-url <your_rpc_url> --private-key <your_private_key>cast <subcommand>forge --help
anvil --help
cast --help- Perform a code coverage
forge coverage- Generate LCOV report
forge coverage --report lcov- Generate
index.html
forge coverage --ffi --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage







