Sigma Wallet · MultiSigVaultV4

Vault Security Review

Fund safety, unauthorized-transfer, and stuck-funds check of the deployed V4 vault contract — with the exact contract lines each verdict rests on.

Reviewed 2026-11-10
solidity_contract/multisig_wallet_v4.sol
solc ^0.8.20
This is an AI-assisted internal code review conducted at the project owner's request, cross-checked against the repository source. It is not a substitute for a paid third-party audit before handling significant value. Corrected 2026-11-11: an earlier draft said funds cannot get stuck while every key still exists. That holds only when every co-owner is willing to sign. Check 11 is the remaining case.
Is fund safe in the vault?
Safe
No upgrade path, no admin override, no code-level reentrancy or arithmetic bug found.
Can anyone transfer funds out without permission?
No path found
Every transfer requires a verified co-owner signature and a met vote threshold.
Can funds get stuck with no key lost?
One case
If every co-owner will sign, ERC-20 and POL can leave. One co-owner who still has their key can refuse the transfer vote and occupy the replace proposal. On a Fixed vault that holds the funds in the contract. F-2/F-1 is a different bug, and it is fixed in this source.
The one real caveat, by design, not a bug: a supermajority of the vault's own co-owners (n−1) can always out-vote a holdout and move funds — that is the multisig working as intended, not an outside attacker. Check 9 below covers exactly how, and why the project's own safeguards doc treats it as an accepted tradeoff rather than something to patch.

“Once deployed, no one can change or do anything”

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.

What was checked

Each check cites the exact lines it was verified against, read directly from the repository's source file — not from a pasted copy.

CHECK 01 · ACCESS CONTROL

No outsider can call a transfer function

Pass

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.

multisig_wallet_v4.sol · lines 117–120, 246–254
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");
    ...
CHECK 02 · PRIVILEGE MAP

Factory has no fund-moving hook

Pass

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.

multisig_wallet_v4.sol · lines 41, 168–174
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);
}
CHECK 03 · IMMUTABILITY

No upgrade, proxy or kill switch

Pass

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.

multisig_wallet_v4.sol · full file
$ grep -En "delegatecall|selfdestruct|proxy|Delegate" multisig_wallet_v4.sol (no matches)
CHECK 04 · REENTRANCY

Reentrancy is correctly blocked

Pass

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.

multisig_wallet_v4.sol · lines 110–115
modifier nonReentrant() {
    require(_reentrancyStatus != _ENTERED, "RE");
    _reentrancyStatus = _ENTERED;
    _;
    _reentrancyStatus = _NOT_ENTERED;
}
CHECK 05 · EXTERNAL DEPENDENCY

The NFT-license gate can't brick the vault

Pass

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.

multisig_wallet_v4.sol · lines 188–197
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;
    }
}
nft.sol · lines 156–165
function transferFrom(address, address, uint256) public pure {
    revert("Token is not transferable");
}
function safeTransferFrom(address, address, uint256) public pure {
    revert("Token is not transferable");
}
CHECK 06 · EXTERNAL DEPENDENCY

The Factory call behind that gate can't revert

Pass

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.

factory_contract.sol · lines 918–920
function getTokenIdForDeployment(address deployedContract) external view returns (uint256) {
    return deploymentToTokenId[deployedContract];
}
CHECK 07 · PARAMETER VALIDATION

Vote thresholds are bounds-checked

Pass

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.

multisig_wallet_v4.sol · lines 454–456
if (key == ParamKey.TransferThreshold || key == ParamKey.ReplaceThreshold) {
    require(newValue >= MIN_THRESHOLD && newValue <= coOwnerCount, "Bad threshold");
}

Finding, not a new bug — a stale status correction

F-2 / F-1 liveness bug: fixed in source, and now confirmed live on mainnet

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.

multisig_wallet_v4.sol · lines 153–161 (the fix itself)
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");
$ cast call 0x4bf2f22D58338Fb9180aD9Ed67890fE4B7228e20 \ "contractBytecodeHash()(bytes32)" --rpc-url <5 independent providers> 0x7f10e86c0b19c2f55db0cb8e2556270a7368eecccf0bbe29bc2dc1e6c6952a7c // the FIXED hash, all 5 agree
Corrected the stale claim in 4 places:
  • docs/SECURITY_FINDINGS.md — F-2 and F-1 status lines
  • PROGRESS.md — "Known deferred items" bullet
  • docs/ADMIN_ROLES.md — dated warning banner added
  • main.js — EXPECTED_V4_BYTECODE_HASHES comment
CHECK 09 · GOVERNANCE RISK

Accepted risk — n−1 co-owner collusion

Accepted risk, by design

A 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.

multisig_wallet_v4.sol · lines 335–340
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
}
CHECK 10 · LIVENESS

A met transfer round expires

Pass

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.

multisig_wallet_v4.sol · lines 283–291
function _expireTransferIfNeeded() internal {
    if (
        transferThresholdMetAt != 0 &&
        block.timestamp >= transferThresholdMetAt + transferApprovalTTL
    ) {
        emit TransferExpired(transferRound);
        _resetTransferRound();
    }
}
CHECK 11 · GOVERNANCE LIMITS

Two limits inside the co-owner set

Known limits

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.

multisig_wallet_v4.sol · lines 151–152, 231–233, 301–314
transferExecuteDelay = 0;
transferApprovalTTL = 24 hours;
...
require(transferThresholdMetAt == 0, "Already met");
...
if (_replaceVoteCount[replaceRound] == 0) {
    pendingReplaceSlot = slot;
    pendingReplaceNewOwner = newOwner;
}

Reference

ContractAddress / value
Current Factory (chain 137)0x4bf2f22D58338Fb9180aD9Ed67890fE4B7228e20
Live vault bytecode hash0x7f10e86c0b19c2f55db0cb8e2556270a7368eecccf0bbe29bc2dc1e6c6952a7c
Vault sourcesolidity_contract/multisig_wallet_v4.sol
Existing attack suitetest/attacks/*.t.sol — 35 PoCs, referenced not re-run this pass