mymultisig-contract
v0.5.1
Published
Multi-Signatures Solidity Smart Contract for mymultisig.app
Maintainers
Readme
💰 MyMultiSig.app Smart Contract (Beta) 🚀
A minimalistic Solidity smart contract designed for secure and streamlined transactions, MyMultiSig.app simplifies the multisig process for an easy and convenient experience 💻. The contract is integrated with the mymultisig.app web app for an enhanced user experience 📱.
🔥 This smart contract is a multi-signature wallet, which means that a certain number of owners need to sign off on a transaction before it's executed. 💰
💻 The code is written in Solidity and uses three external libraries, ReentrancyGuard, EIP712 and IERC1271, for added security and interop. 🔒
✨ EIP-1271 support: the wallet exposes the standard isValidSignature(bytes32,bytes) entry point so it can act as a signer for other Safe / multisig instances, SIWE verifiers, NFT marketplaces, etc. Contract owners ("nested wallets") can also vote on the wallet's transactions via their own EIP-1271 entry — the wallet's signature validation is agnostic to whether each vote is an EOA ECDSA signature or a contract-owned EIP-1271 blob.
📈 The contract keeps track of various important details, like the name of the contract, the transaction nonce, the number of owners, and who the owners are. 📝
🎉 The contract also has events for adding and removing owners, changing the threshold, executing and failing transactions, and reaching the end of its life (when the nonce hits a certain limit). 💬
💬 When you're ready to make a transaction, you can call the execTransaction function and pass along the destination address, the amount of Ether to transfer, any data you want to include, the gas limit, and the signatures from the necessary owners. 💸
💻 The signatures are verified using the _validateSignature function, and the transaction is executed using the call opcode in assembly. 💻
🎉 If the signatures are valid and there's enough gas, the transaction is a success and a TransactionExecuted event is emitted. If not, a TransactionFailed event is emitted. 💬
🔒 Advantages of Using MyMultiSig.app
Security: 🔒 A multisig contract requires multiple signatures before a transaction can be executed, making it more secure compared to a single signature transaction.
Decentralization: 💪 The multisig contract can be managed by multiple parties, promoting decentralization and reducing the risk of a single point of control.
Flexibility: 💡 The contract can be customized to fit the specific requirements of different organizations, including the number of signatures required and the threshold.
Transparency: 🔍 All transactions executed by the multisig contract are recorded on the Ethereum blockchain, providing a transparent and auditable trail of all transactions.
Note: This smart contract is currently in beta, use at your own risk.
🔌 EIP-1271
The wallet implements the EIP-1271 standard:
function isValidSignature(bytes32 hash, bytes calldata signature)
external view returns (bytes4 magicValue);signature is an ABI-encoded (address owner, bytes sig)[] of owner votes. For each entry the wallet either ecrecovers a 65-byte ECDSA signature (EOA owner) or staticcalls isValidSignature(hash, sig) on the owner with a 200k gas stipend (contract owner). If the count of valid votes reaches threshold, returns the magic value 0x1626ba7e; otherwise 0xffffffff.
Off-chain, building the signature blob is one line:
import { ethers } from 'ethers'
const blob = ethers.utils.defaultAbiCoder.encode(
['tuple(address owner, bytes sig)[]'],
[[{ owner: owner1, sig: sig1 }, { owner: owner2, sig: sig2 }]],
)The same encoding is used by execTransaction and isValidSignature(address,...,bytes). The pre-0.2.0 flat 65-byte chunk format is no longer supported — this is a breaking change documented in the v0.2.0 release notes.
⏰ v0.3.0 — deadlines, revokes, atomic batches
v0.3.0 hardens the wallet against three treasury pain points:
validUntildeadline. Every EIP-712 transaction hash now binds auint256 validUntilfield. Signers set a Unix timestamp past which the signature is invalid;execTransactionreverts withSignatureExpired()if a stale payload shows up.validUntil == 0means "no expiry" — legacy wallets keep working as long as they pass0. Breaking: every v0.2.0 off-chain payload is invalidated; update your signer to includevalidUntilin the typed-dataTransactionstruct and pass it through toexecTransaction. The new 6-arg base-wallet overloadexecTransaction(to, value, data, gas, validUntil, signatures)and the 7-arg Extended overloadexecTransaction(to, value, data, gas, nonce, validUntil, signatures)are the supported entry points.revokeApproval(bytes32 hash). An owner can now withdraw their own on-chain approval without burning the whole nonce. Self-only — no admin override — reverts withNotApproved()if you try to revoke something you never approved. EmitsRevokeApproval(address indexed owner, bytes32 indexed hash).multiRequestStrict(address[], uint256[], bytes[], uint256[]). New atomic-batch entry point: reverts the whole transaction on first failure (no partial side effects, noMultiRequestExecutedevent). Use it when the second call depends on the first (e.g. approve-then-swap). Failure bubbles asBatchCallFailed(uint256 index, bytes reason). The originalmultiRequestcontinues to be best-effort — every call runs, partial failures are surfaced via the existingsuccesses[]/returnData[]arrays.
As of v0.5.0 every wallet class — MyMultiSig, MyMultiSigExtended, MyMultiSigFactorable, and the MyMultiSigFactory proxy — returns the same canonical '0.5.0' from version(). The EIP-712 domain separator is fixed at deploy time and is now shared across the wallet family; only the typehash differs (base 6-field vs extended 7-field with operation). Helpers in test/shared/signatures.ts, test/shared/functions.ts, and the Foundry equivalents have been updated to thread validUntil / operation through the right typehash; see the test suite for usage patterns.
🛡️ v0.4.0 — Timelock, Guard, Allowances, Modules
MyMultiSigExtended v0.4.0 adds four optional features, all disabled by default so previously-deployed wallets behave unchanged until the new setters are called. Every wallet class returns '0.5.0' from version() (as of v0.5.0).
1. ⏰ Timelock on sensitive calls
Schedule/ready pattern. A call is "sensitive" when it targets address(this) at a registered sensitive selector, or when its value meets the configured wei threshold. The constructor pre-registers the wallet's admin selectors (incl. enableModule/disableModule) so the timelock applies to every privileged action by default.
wallet.execTransaction(address(wallet), 0,
abi.encodeWithSignature("setTimelockDelay(uint256)", 1 days), gas, sigs);
// Direct addOwner(...) now reverts SensitiveCallRequiresDelay. Schedule instead:
wallet.scheduleTransaction(target, 0, addOwnerCalldata, gas, nonce, validUntil, sigs);
// After timelockDelay seconds:
wallet.executeScheduled(target, 0, addOwnerCalldata, gas, nonce, validUntil, sigs);Gotchas: schedule is keyed by txHash, so any payload mutation produces a new id. Replays are blocked by a type(uint256).max sentinel. validUntil bounds the whole schedule window (re-checked at execute). executeScheduled deliberately does NOT bump _txnNonce (the (nonce, owner) anti-replay slots consumed at schedule time already block any further tx at this nonce without fresh sigs).
2. 🛡️ Transaction guard + built-in allowlist
Pluggable ITransactionGuard (pre-call checkTransaction + silent post-call checkAfterExecution). Reverts are wrapped into GuardReverted(guard, reason). A built-in target allowlist is also available for off-chain / no-guard use.
wallet.execTransaction(address(wallet), 0,
abi.encodeWithSignature("setGuard(address)", guardAddr), gas, sigs);
wallet.execTransaction(address(wallet), 0,
abi.encodeWithSignature("setAllowedTarget(address,bool)", safeTarget, true), gas, sigs);Gotchas: the allowlist is OFF until the first setAllowedTarget(...) flips it on. Guard + allowlist apply inside multiRequest* (per inner call) and execTransactionFromModule. checkAfterExecution failures are NEVER reverts.
3. 💸 Per-owner daily spending allowance
Single-signer entry point: execTransactionWithSpendingAllowance(to, value, data, gas, validUntil, signatures). Requires a single 65-byte ECDSA sig that recovers to msg.sender, who must be a current owner with a non-zero daily cap.
wallet.execTransaction(address(wallet), 0,
abi.encodeWithSignature("setDailySpendingLimit(address,uint256)", owner01, 5 ether), gas, sigs);
bytes memory sig = sign(owner01, wallet, recipient, 1 ether, "0x", gas, nonce, 0);
wallet.execTransactionWithSpendingAllowance(recipient, 1 ether, "0x", gas, 0, sig);Gotchas: 24h relative window per owner (not UTC midnight). Commit-on-success: failed inner calls don't burn the cap. The allowance path does NOT bump _txnNonce (it's a UX shortcut).
4. 🧩 Modules / plugins
Linked-list enabled module registry (Safe ModuleManager pattern). Modules bypass the signature threshold — they're operational plugins (recovery, streaming, automation). Use MyMultiSigFactory.createMyMultiSigAdvanced for distinct factory bookkeeping.
wallet.execTransaction(address(wallet), 0,
abi.encodeWithSignature("enableModule(address)", moduleAddr), gas, sigs);
module.execCall(target, value, data); // CALL (op=0)
module.execDelegateCall(calldata); // DELEGATECALL (op=1, to == wallet only)Gotchas: disableModule(prev, module) follows Safe's strict pattern — when the module is the head, prev MUST be address(0); otherwise _modulesNext[prev] == module must hold. Module-driven calls do NOT bump _txnNonce. Guard + allowlist still apply; timelock does NOT.
Factory + introspection
MyMultiSigFactory.createMyMultiSigAdvanced(...) produces the v0.4.0 wallet through a new MyMultiSigAdvancedDeployer (a tiny wrapper that defers to MyMultiSigExtendedDeployer; the v0.4.0 wallet bytecode is currently identical to v0.3.0 Extended, and the distinction lives in factory bookkeeping until a future Advanced-only release).
advancedFeaturesEnabled() returns a bitmask (1=timelock, 2=guard, 4=allowlist, 8=allowance, 16=module) for UI/explorer use.
Factory bookkeeping (v0.4.0)
The factory now tracks per-type counts and exposes an address-keyed type lookup:
uint256 simpleCount(); // base MyMultiSig wallets
uint256 extendedCount(); // MyMultiSigExtended (incl. Advanced)
uint256 advancedCount(); // wallets created via the Advanced deployer
uint256 creationTypeCount(MyMultiSigFactorableModels.CreationType);
MyMultiSigFactorableModels.CreationType creationTypeOf(address wallet);
bool isExtended(address wallet); // true for Extended + AdvancedMyMultiSigAdvancedTests() is the new exported test group in test/shared/tests.ts (entry files test/MyMultiSigAdvanced.test.ts and test/MyMultiSigAdvancedFromFactory.test.ts); Foundry mirrors under contracts/test/shared/tests.t.sol. Helpers (setTimelockDelay, enableModule, …) live in test/shared/functions.ts.
Cleanups bundled with v0.4.0
MyMultiSig.verifyNonce(uint256)removed (zero references).MyMultiSig._changeThresholdnow emitsThresholdChanged(uint256)(event declared at line 52 but never previously emitted).
🚀 v0.5.0 — DELEGATECALL op + ERC-4337 rolled into MyMultiSigExtended
⚠️ v0.5.0 ships the new wallet features as additions to the existing
MyMultiSigExtendedclass — there is no separate wallet contract beyondMyMultiSigExtended. All wallets deployed through the factory after this release areMyMultiSigExtendedinstances that now also expose theoperationbyte and theIAccountsurface.
MyMultiSigExtended v0.5.0 adds three forward-looking capabilities on top of the existing v0.4.0 features (timelock, guard, allowance, modules):
1. 🛡️ operation byte on the owner-signed execTransaction
A new uint8 operation parameter is bound into the EIP-712 typed-data payload. Three new execTransaction overloads:
execTransaction(to, value, data, gas, operation, signatures) -> success
execTransaction(to, value, data, gas, validUntil, operation, signatures) -> success
execTransaction(to, value, data, gas, txnNonce, validUntil, operation, signatures) -> successoperation == 0 (default) → standard CALL. operation == 1 → DELEGATECALL into to, gated to to == address(this) (mirrors the same gating already used by MyMultiSigExtended.execTransactionFromModule). Anything else reverts with the new custom error InvalidOperation(uint8 op).
⚠️ Breaking: the pre-existing 5/6/7-arg execTransaction overloads on MyMultiSigExtended are disabled — they now revert with RequiresOperationByte(). All frontends must switch to the new overloads that include operation. The base MyMultiSig wallet is untouched and continues to expose the old 5/6-arg signatures against the v0.4.0 typehash.
⚠️ EIP-712 footer changed. The new typehash binds operation:
Transaction(address to, uint256 value, bytes data, uint256 gas, uint96 nonce, uint256 validUntil, uint8 operation)Existing v0.4.0 MyMultiSigExtended signatures do not validate on v0.5.0 wallets because the typehash includes operation. Migrate your off-chain signer to bind operation (default 0) into the typed data before deploying.
2. ⚡ ERC-4337 v0.7 account abstraction
MyMultiSigExtended implements IAccount against an immutable IEntryPoint v0.7 address passed at construction. The canonical EntryPoint v0.7 address (same on every EVM chain) is 0x0000000071727De22E5E9d8BDe0dFeC0CEB6a7d7.
Constructor signature: new MyMultiSigExtended(name, owners, threshold, isOnlyOwnerRequest, entryPoint). Bundler flow:
- Bundler calls
EntryPoint.handleOps([userOp], beneficiary). - EntryPoint calls the wallet's
validateUserOp(PackedUserOperation, bytes32 userOpHash, uint256 missingAccountFunds). Returns0on success /1(SIG_VALIDATION_FAILED) on failure. Requiresmsg.sender == ENTRY_POINT,userOp.sender == address(this),operation == 0,userOp.nonce == _txnNonce, and threshold reached via the same signature-validation path used byexecTransaction. - EntryPoint funds execution and calls
executeUserOp(PackedUserOperation), which reconstructs the inner call fromuserOp.callData = abi.encode(address to, uint256 value, bytes data, uint256 gas, uint256 validUntil, uint8 operation)and runs it through the gated_execExtendedpath.
Paymaster support drops out naturally: a paymaster can pre-fund via EntryPoint.depositTo(address(this)); bundlers assemble UserOps with paymasterAndData != 0 and the wallet's validateUserOp is permissive on value-funded flows.
3. 🌐 CREATE2 deterministic wallets
The factory exposes a deterministic (CREATE2) creation path next to each classic one. Wallets deploy via salted creation (new MyMultiSig{salt: ...}(...) inside the deployers) — full creation bytecode, ordinary constructors, no proxies and no Initializable — so the deployed wallets are byte-identical to the classic-path ones.
| Classic (CREATE) | Deterministic (CREATE2) | Predict without deploying |
| --- | --- | --- |
| createMultiSig(name, owners, threshold) | createDeterministicMultiSig(..., salt) | predictMultiSigAddress(creator, ..., salt) |
| createMyMultiSigExtended(...) | createDeterministicMyMultiSigExtended(..., salt) | predictMyMultiSigExtendedAddress(creator, ..., salt) |
| createMyMultiSigAdvanced(...) | createDeterministicMyMultiSigAdvanced(..., salt) | predictMyMultiSigAdvancedAddress(creator, ..., salt) |
Address derivation:
- The factory forwards
computeSalt(msg.sender, salt) = keccak256(abi.encode(creator, salt))to the deployer, so two creators using the same salt never race for one address. - Each deployer additionally namespaces the salt by its direct caller (
keccak256(abi.encode(msg.sender, salt))) before the CREATE2. A third party calling a deployer directly therefore lands in its own address space and can never squat an address that a factory-mediated deploy resolves to. It also keeps Advanced deploys (routed throughMyMultiSigAdvancedDeployer→MyMultiSigExtendedDeployer) collision-free against Extended deploys that share a salt and constructor arguments. - The final wallet address is a pure function of
(deployer address, creator, salt, constructor arguments). Deploy the factory + deployer set at the same addresses on several chains (seeFACTORY_SALT/CANONICAL_CREATE2_DEPLOYERinconstants/extended.ts) and the same creator + salt + arguments yield the same wallet address on every chain.
Re-using a salt with identical arguments reverts (the CREATE2 target address is occupied). ERC-4337 note: validateUserOp requires the wallet to be already deployed (userOp.sender == address(this)); the deterministic path gives counterfactual addresses, not initCode-based counterfactual deployment.
Migration story
Existing v0.4.0 wallets (deployed before this release) stay frozen at their original bytecode — they bind to the old 6-field EIP-712 typehash and the same EIP-712 domain separator format but with version() == '0.5.0' (matching the unified v0.5.0 versioning) and do not have the v0.5.0 operation-byte surface. To migrate, the owners redeploy MyMultiSigExtended with the new constructor (passing entryPoint) and move funds.
Cleanups bundled with v0.5.0
- Single canonical version
0.5.0across every contract.MyMultiSig.version(),MyMultiSigExtended.version(),MyMultiSigFactorable.version(),MyMultiSigFactory.version()(inherited),package.json, and theCONTRACT_VERSIONTS constant all return'0.5.0'. The EIP-712 domain separator is therefore shared across the whole wallet family; only the typehash differs. MyMultiSigExtendedDeployer.deployMyMultiSigExtended(...)andMyMultiSigAdvancedDeployer.deployMyMultiSigAdvanced(...)now take anentryPointargument and forward it through.MyMultiSigFactorable.createMyMultiSigExtended(...)andcreateMyMultiSigAdvanced(...)now take anentryPointand forward it.- New custom errors:
InvalidOperation(uint8),NotEntryPoint(),InvalidNonce(expected, got),RequiresOperationByte(). New events:TransactionExecutedOp(...),TxFailureOp(...),UserOpExecuted(bytes32 userOpHash, uint256 nonce).
🔧 Install Dependencies
To install all necessary dependencies, run the following command:
yarn💻 Run Tests
To run tests, you have the option to use either Hardhat or Foundry.
🔨 Hardhat Tests
To run tests using Hardhat, use the following command:
yarn hardhat testAdditionally, you can run a coverage report using Hardhat with the following command:
yarn coverage🔥 Foundry Tests
To run tests using Foundry, use the following command:
forge test💥 Hardhat & Foundry Tests
To run tests using both Hardhat and Foundry, use the following command:
yarn test🚀 Deploy Locally
To deploy the contract locally, you'll need to run a node with Hardhat.
In a first terminal, run the following command to start the Hardhat node:
npx hardhat nodeIn a second terminal, while the node is active, run the following command to deploy the contract:
yarn deploy-localhost🙏 Acknowledgements
This project relies on the amazing work done by the Hardhat and Foundry teams. Thank you for your contributions to the Ethereum community!
