// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title MultiOwnable — variant ownership with both EOA and WebAuthn passkey owners /// @notice Adapted from Coinbase Smart Wallet's MultiOwnable.sol (MIT). /// Owners are stored as raw bytes: 32 bytes (left-padded EOA address) OR /// 64 bytes (P-256 uncompressed point: x||y). Each owner has an index. /// Any owner can add or remove other owners. Loss of one owner ≠ loss of account /// as long as ≥1 other owner remains. /// /// @dev Source: https://github.com/coinbase/smart-wallet/blob/main/src/MultiOwnable.sol contract MultiOwnable { /// @notice Tracks the next owner index to assign. Monotonic — never reused. uint256 public nextOwnerIndex; /// @notice Number of currently-registered owners (decreases on remove). uint256 public ownerCount; /// @notice Indexed owner storage. mapping(uint256 => bytes) internal _ownerAtIndex; /// @notice Reverse lookup: bytes-encoded owner → true if registered. mapping(bytes => bool) internal _isOwner; event AddOwner(uint256 indexed index, bytes owner); event RemoveOwner(uint256 indexed index, bytes owner); error Unauthorized(); error AlreadyOwner(bytes owner); error NoOwnerAtIndex(uint256 index); error InvalidOwnerBytesLength(uint256 length); error InvalidEthereumAddressOwner(bytes owner); error ZeroAddressOwner(); error LastOwner(); modifier onlyOwner() { _checkOwner(); _; } /// @notice Self-call only. Used so external callers can never bypass auth — every /// owner-management op must come through an authenticated execute() that /// loops back through msg.sender == address(this). function _checkOwner() internal view virtual { if (msg.sender != address(this)) revert Unauthorized(); } /// @notice Add an EOA owner by address (20 bytes left-padded to 32). function addOwnerAddress(address owner) public virtual onlyOwner { _addOwner(abi.encode(owner)); } /// @notice Add a passkey owner by uncompressed P-256 public key (64 bytes: x || y). function addOwnerPublicKey(bytes32 x, bytes32 y) public virtual onlyOwner { _addOwner(abi.encode(x, y)); } function _addOwner(bytes memory owner) internal { if (_isOwner[owner]) revert AlreadyOwner(owner); if (owner.length != 32 && owner.length != 64) revert InvalidOwnerBytesLength(owner.length); if (owner.length == 32) { // EOA owner — top 12 bytes must be zero (uint160 left-padded to bytes32). uint256 word; // solhint-disable-next-line no-inline-assembly assembly { word := mload(add(owner, 0x20)) } if (word >> 160 != 0) revert InvalidEthereumAddressOwner(owner); // Ban address(0) as an owner: paired with an ecrecover-failure returning // address(0), a zero owner would let a malformed signature authorize. if (word == 0) revert ZeroAddressOwner(); } uint256 idx = nextOwnerIndex++; _ownerAtIndex[idx] = owner; _isOwner[owner] = true; ownerCount += 1; emit AddOwner(idx, owner); } /// @notice Remove the owner at a specific index. Reverts if it would zero the set. function removeOwnerAtIndex(uint256 index) public virtual onlyOwner { bytes memory owner = _ownerAtIndex[index]; if (owner.length == 0) revert NoOwnerAtIndex(index); if (ownerCount == 1) revert LastOwner(); delete _isOwner[owner]; delete _ownerAtIndex[index]; ownerCount -= 1; emit RemoveOwner(index, owner); } function isOwnerAddress(address account) public view virtual returns (bool) { return _isOwner[abi.encode(account)]; } function isOwnerPublicKey(bytes32 x, bytes32 y) public view virtual returns (bool) { return _isOwner[abi.encode(x, y)]; } function isOwnerBytes(bytes memory account) public view virtual returns (bool) { return _isOwner[account]; } function ownerAtIndex(uint256 index) public view virtual returns (bytes memory) { return _ownerAtIndex[index]; } /// @notice Internal initializer — called by inheriting contract during init. function _initializeOwners(bytes[] memory owners) internal { for (uint256 i = 0; i < owners.length; i++) { _addOwner(owners[i]); } } }