Minimal sub-accounts that behave like EOAs—controlled by a single master address.
You need multiple Ethereum addresses but don't want to manage multiple private keys. Existing solutions (Safe, ERC-4337) are powerful but heavyweight—they solve account abstraction, not the simpler problem of "I need more addresses."
MultiAddrs deploys ultra-minimal 91-byte sub-accounts. Each sub-account:
- Behaves like an EOA: Can hold ETH, call any contract, receive tokens
- Controlled by one master: Only the master can execute transactions
- Zero storage: No state, no admin, no upgrades—just raw bytecode
- Deterministic addresses: Know the address before deployment
┌─────────────────┐ ┌──────────────────┐
│ Master (EOA) │───────▶│ SubAccount #1 │───▶ DeFi protocols
│ 1 private key │ │ SubAccount #2 │───▶ NFT mints
│ │ │ SubAccount #N │───▶ Airdrops, etc.
└─────────────────┘ └──────────────────┘
│
└── Full control over all sub-accounts
For Users:
- Separate DeFi positions without managing multiple keys
- Claim airdrops from multiple addresses
- Privacy through address separation
For Protocols:
- Set a wrapper contract as master to extend functionality
- Add batching, permissions, spending limits—whatever you need
- Users get isolated sub-accounts with protocol-specific features
Routers & Batching:
- External routers can batch operations across multiple sub-accounts
- Master signs once, router executes many
When the master calls a sub-account with ≥64 bytes:
[target: 32 bytes][value: 32 bytes][data: remaining bytes]
The sub-account executes: target.call{value: value}(data)
| Caller | Calldata | Behavior |
|---|---|---|
| Master | ≥64 bytes | Execute call, return result |
| Master | <64 bytes | Accept ETH, return empty |
| Anyone else | Any | Accept ETH, return empty |
Non-masters can deposit ETH but cannot trigger any execution.
ISubAccountFactory factory = ISubAccountFactory(FACTORY_ADDRESS);
// Deploy a sub-account
address subAccount = factory.deploySubAccount();
// Predict address before deployment
address predicted = factory.getAccount(msg.sender, 1);
// Execute from sub-account (as master)
bytes memory callData = abi.encode(targetAddress, ethValue);
callData = bytes.concat(callData, targetCalldata);
(bool ok, bytes memory ret) = subAccount.call(callData);
// Check deployment status
uint count = factory.getAccountsCount(msg.sender);
bool exists = factory.isAccountDeployed(msg.sender, 1);Protocols can create wrappers that act as the master:
contract ProtocolWrapper {
ISubAccountFactory factory;
// Deploy sub-account owned by this wrapper
function createUserAccount() external returns (address) {
return factory.deploySubAccount();
}
// Add protocol-specific logic (batching, limits, etc.)
function executeOnBehalf(address subAccount, Call[] calldata calls) external {
for (uint i = 0; i < calls.length; i++) {
bytes memory payload = abi.encode(calls[i].target, calls[i].value);
payload = bytes.concat(payload, calls[i].data);
(bool ok,) = subAccount.call(payload);
require(ok);
}
}
}forge installforge testThe factory deploys to the same address on all chains via CREATE3.
| Chain | Chain ID |
|---|---|
| Ethereum | 1 |
| Arbitrum | 42161 |
| Optimism | 10 |
| Base | 8453 |
| Polygon | 137 |
| BSC | 56 |
| Avalanche | 43114 |
| Gnosis | 100 |
| Sepolia | 11155111 |
| Hoodi | 560048 |
./script/deploy-all.sh --dryexport PRIVATE_KEY=0x...
./script/deploy-all.sh --prod# Dry run
./script/deploy-all.sh --dry --chains Sepolia,Hoodi
# Production
export PRIVATE_KEY=0x...
./script/deploy-all.sh --prod --chains Ethereum,Arbitrum,Base# Dry run
forge script script/Deploy.s.sol --rpc-url <rpc>
# Deploy
forge script script/Deploy.s.sol --rpc-url <rpc> --broadcast --private-key $PRIVATE_KEYDeployments are written to deployments/<chainId>.json.
- Immutable master: Embedded in bytecode at deploy time, cannot be changed
- No storage: Nothing to exploit via storage manipulation
- No admin: No owner, no upgrades, no backdoors
- Revert propagation: Failed calls revert (but revert data not forwarded for gas efficiency)
UNLICENSED