Sigma Wallet · MultiSigVaultV4
Fund safety, unauthorized-transfer, and stuck-funds check of the deployed V4 vault contract — with the exact contract lines each verdict rests on.
Mostly true, with one necessary nuance. True for the code: no delegatecall, no proxy, no selfdestruct — the logic is frozen forever the moment it's deployed. Not quite true for the state: the co-owners themselves can still replace a member or, on a Configurable vault, change the six listed parameters — by design, through their own vote. That's what makes it a working multisig instead of a time capsule.
Each check cites the exact lines it was verified against, read directly from the repository's source file — not from a pasted copy.
Both fund-moving functions require onlyCoOwner, a plain mapping check on msg.sender — there is no delegatecall or proxy context anywhere in the contract that could let an outsider spoof it.
modifier onlyCoOwner() { require(isCoOwner[msg.sender], "Not co-owner"); _; } function transferToken(address token, address to, uint256 amount) external onlyCoOwner hasNFT nonReentrant { _expireTransferIfNeeded(); require(isTransferExecutable(), "Not executable"); ...
The Factory that deployed the vault keeps exactly one callable entry point into it, and it's a one-time ID writer — it never touches a balance. factory is immutable, set once at construction.
address public immutable factory; ... function setDeploymentTokenId(uint256 id) external { require(msg.sender == factory, "Only factory"); require(tokenId == 0 && id != 0, "Bad id"); tokenId = id; emit DeploymentTokenIdSet(id); }
Searched the full contract for every mechanism that could ever change its logic or destroy it post-deployment. None exist — this is the basis for confirming the "no one can change anything" part of the isolation claim.
Both transferToken and withdrawNative set the guard before any external call and clear it only after — a revert anywhere inside unwinds the guard too, so it can never get stuck "on" across transactions.
modifier nonReentrant() { require(_reentrancyStatus != _ENTERED, "RE"); _reentrancyStatus = _ENTERED; _; _reentrancyStatus = _NOT_ENTERED; }
Every co-owner function — transfers included — requires the vault to hold a specific license NFT. That NFT's own contract makes every transfer and approval function revert unconditionally, for everyone, including its own owner, and has no burn function. Once minted to the vault, the NFT itself cannot be transferred or burned, so this check does not fail because someone moved the license.
function _hasNFT() internal view returns (bool) {
if (nftContract == address(0) || factory == address(0)) return false;
uint256 id = IFactoryV4(factory).getTokenIdForDeployment(address(this));
if (id == 0) return false;
try INonTransferableNFT(nftContract).ownerOf(id) returns (address own) {
return own == address(this);
} catch {
return false;
}
}
function transferFrom(address, address, uint256) public pure { revert("Token is not transferable"); } function safeTransferFrom(address, address, uint256) public pure { revert("Token is not transferable"); }
The one external call inside _hasNFT() that is not wrapped in try/catch is a bare mapping read with no require in it at all, and the Factory itself has no selfdestruct — so in practice it cannot be the thing that bricks a vault. Worth wrapping anyway as defense-in-depth; not exploitable today.
function getTokenIdForDeployment(address deployedContract) external view returns (uint256) {
return deploymentToTokenId[deployedContract];
}
A Configurable vault's co-owners can change the transfer threshold, but never below 2 or above the real co-owner count — so a param change can't accidentally create an unreachable or meaningless threshold.
if (key == ParamKey.TransferThreshold || key == ParamKey.ReplaceThreshold) { require(newValue >= MIN_THRESHOLD && newValue <= coOwnerCount, "Bad threshold"); }
Finding, not a new bug — a stale status correction
Two previously-found bugs (co-owner replacement and parameter changes both permanently unexecutable, from a constructor window that was exactly zero-width) were fixed in this exact source file. Four project docs still said the fix hadn't been pushed to the live Factory. It has — since the Factory redeploy on 2026-11-07.
transferExecuteDelay = 0; transferApprovalTTL = 24 hours; replaceApprovalTTL = 72 hours; // was 24 hours — this is the fix require(replaceExecuteDelay < replaceApprovalTTL, "delay>=TTL"); require(transferExecuteDelay < transferApprovalTTL, "t delay>=TTL");
docs/SECURITY_FINDINGS.md — F-2 and F-1 status linesPROGRESS.md — "Known deferred items" bulletdocs/ADMIN_ROLES.md — dated warning banner addedmain.js — EXPECTED_V4_BYTECODE_HASHES commentA supermajority of a vault's own co-owners can replace the holdout, then meet whatever transfer threshold remains — and on a Configurable vault, can reach the same result faster by voting the transfer threshold straight down to 2. This is the project's own documented, deliberate multisig tradeoff (lowering the replace threshold instead would make recovering from 2+ lost keys harder), not an overlooked bug. The practical mitigation already recommended elsewhere in the project: deploy with 5 co-owners, not the 3 minimum, so collusion needs 4 of 5 instead of 2 of 3.
function isReplaceExecutable() public view returns (bool) { if (replaceThresholdMetAt == 0) return false; if (block.timestamp < replaceThresholdMetAt + replaceExecuteDelay) return false; if (block.timestamp >= replaceThresholdMetAt + replaceApprovalTTL) return false; return _replaceVoteCount[replaceRound] >= replaceThreshold; // default n-1 }
Once a transfer round has reached its threshold, it expires at transferApprovalTTL (default 24 hours) and the next call resets it. That expiry does not cover a replace or parameter proposal that never reached its threshold: those rounds have no cancel function and do not time out. executeReplace also does not clear an already-open transfer round, so a removed co-owner's vote still counts until that transfer round expires.
function _expireTransferIfNeeded() internal {
if (
transferThresholdMetAt != 0 &&
block.timestamp >= transferThresholdMetAt + transferApprovalTTL
) {
emit TransferExpired(transferRound);
_resetTransferRound();
}
}
approveTransfer records a vote for the round, not a token, amount, or recipient. The default transfer delay is 0, and a Fixed vault cannot change it. After the threshold is met, revoke is refused, and any current co-owner can call transferToken or withdrawNative with arguments chosen at execution time. Separately, the first approveReplace locks the slot and the new address. If that proposal never reaches n−1, the round does not expire. On a Fixed vault, one co-owner who still holds their key can refuse the transfer vote and occupy that replace proposal, so ERC-20 and POL stay in the contract.
transferExecuteDelay = 0;
transferApprovalTTL = 24 hours;
...
require(transferThresholdMetAt == 0, "Already met");
...
if (_replaceVoteCount[replaceRound] == 0) {
pendingReplaceSlot = slot;
pendingReplaceNewOwner = newOwner;
}
| Contract | Address / value |
|---|---|
| Current Factory (chain 137) | 0x4bf2f22D58338Fb9180aD9Ed67890fE4B7228e20 |
| Live vault bytecode hash | 0x7f10e86c0b19c2f55db0cb8e2556270a7368eecccf0bbe29bc2dc1e6c6952a7c |
| Vault source | solidity_contract/multisig_wallet_v4.sol |
| Existing attack suite | test/attacks/*.t.sol — 35 PoCs, referenced not re-run this pass |