Minimal Avatar Smart Wallet (MASW) is an immutable delegate‑wallet that any EOA can designate via EIP‑7702 (txType 0x04). Once designated, the wallet's code remains active for every subsequent transaction until the owner sends a new 0x04 to clear or replace it. During each delegated call the EOA is the avatar and MASW's code executes as the delegate at the same address, enabling atomic batched calls (EIP‑712 signed) and optional sponsor gas reimbursement in ETH or ERC‑20.
The contract offers one primary function, executeBatch, plus two plug‑in hooks: a Policy Module for pre/post guards and a Recovery Module for alternate signature validation. Replay attacks are prevented by a global metaNonce, an expiry, and a chain‑bound EIP‑712 domain separator. Standardising this seven‑parameter ABI removes wallet fragmentation while still allowing custom logic through modules.
A single‑transaction code‑injection model (EIP‑7702) grants EOAs full implementation freedom, but unconstrained diversity would impose high coordination costs:
By standardising the immutable byte‑code, signature domain, and minimal ABI while exposing clearly defined extension hooks, MASW minimizes fragmentation and maximizes composability across the Ethereum tooling stack.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
MASW with constructor argument _owner = EOA.0x04) referencing the contract's byte‑code hash.function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
address token,
uint256 fee,
uint256 expiry,
bytes calldata signature
) external;
function setPolicyModule(address newModule) external;
function setRecoveryModule(address newModule) external;
event BatchExecuted(bytes32 indexed structHash);
event ModuleChanged(bytes32 indexed kind, address oldModule, address newModule);
bytes32 constant BATCH_TYPEHASH = keccak256(
"Batch(address[] targets,uint256[] values,bytes[] calldatas,address token,uint256 fee,uint256 exp,uint256 metaNonce)"
);
| Slot | Name | Type | Description |
|---|---|---|---|
| 0 | metaNonce |
uint256 | Monotonically increasing meta‑nonce |
| 1 | _entered |
uint256 | Re‑entrancy guard flag |
| 2 | policyModule |
address | Optional IPolicyModule (zero = none) |
| 3 | recoveryModule |
address | Optional IRecoveryModule (zero = none) |
owner and DOMAIN_SEPARATOR are immutable and occupy no storage slots.
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MASW"),
keccak256("1"),
block.chainid, // MUST be the live chain‑ID; using 0 is disallowed
_owner // keeps separator stable before & after delegation
)
);
executeBatch)| Stage | Behaviour |
|---|---|
| Validation | ‑ targets.length == values.length == calldatas.length > 0‑ block.timestamp ≤ expiry‑ metaNonce matches then increments‑ EIP712 digest recovers owner or is approved by recoveryModule |
| Policy pre‑hook | If policyModule != address(0), preCheck MUST return true; a revert or false vetoes the batch |
| Calls | For each index i: targets[i].call{value:values[i]}(calldatas[i]); revert on first failure |
| Policy post‑hook | Same semantics as pre‑hook |
| Fee reimbursement | If fee > 0: native transfer (token == address(0)) or ERC20 transfer with OpenZeppelin‑style return‑value check |
| Emit | BatchExecuted(structHash) |
The relayer and owner agree off‑chain on (token, fee) prior to submission.
Because the fee is part of the signed batch, a relayer cannot unilaterally raise it.
If a rival relayer broadcasts the same signed batch first, they earn the fee and the original relayer's transaction reverts—aligning incentives naturally.
Relayers MUST confirm the avatar's balance up‑front; insufficient funds render the transaction invalid in the mem‑pool.
interface IPolicyModule {
function preCheck (address sender, bytes calldata rawData, uint256 value) external view returns (bool);
function postCheck(address sender, bytes calldata rawData, uint256 value) external view returns (bool);
}
false.value parameter represents the total ETH sent with the transaction (msg.value), allowing the policy module to validate this against the batch requirements contained in rawData.false).interface IRecoveryModule {
function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4);
}
Must return 0x1626ba7e.
A single global metaNonce is used. Two relayers submitting the same nonce concurrently results in one success and one revert. The expiry field (wallets typically set ≤ 30 s) makes such races low‑impact, but UIs should surface the failure.
0x04 call.maxTargets; advanced users can bundle many calls, while conservative users install a size‑capping Policy module.chainId to mitigate cross‑chain replays.Reference implementation can be found here MASW.sol.
| Threat | Mitigation |
|---|---|
| Same‑chain replay | Global metaNonce |
| Cross‑chain replay | Chain‑bound domain separator |
| Fee grief / over‑charge | Fee is part of signed data; front‑running risk sits with relayer |
| Batch gas grief | Optional Policy can reject oversized batches |
ERC20 non‑standard returns |
OpenZeppelin SafeERC20 transfer check |
| Re‑entrancy | nonReentrant guard; state mutated only before external calls (nonce++) and after (fee transfer) |
| Malicious Module | Core logic immutable; swapping modules needs an owner‑signed tx |
Copyright and related rights waived via CC0.