diff --git a/ERCS/erc-8063.md b/ERCS/erc-8063.md new file mode 100644 index 00000000000..c83c2b9d27d --- /dev/null +++ b/ERCS/erc-8063.md @@ -0,0 +1,147 @@ +--- +eip: 8063 +title: Groups - Multi-Member Containers +description: Onchain groups with membership and direct member management. +author: James Savechives (@jamesavechives) +discussions-to: https://ethereum-magicians.org/t/erc-8063-groups-multi-member-onchain-containers-for-shared-resources/25999 +status: Draft +type: Standards Track +category: ERC +created: 2025-10-28 +requires: 165 +--- + +## Abstract + +This proposal specifies a general-purpose "Group" primitive as a first-class, onchain object. Each group is deployed as its own contract (identified by its contract address) and has an owner, an extensible set of members, and optional metadata. The standard defines a minimal interface for direct member management and membership introspection. Unlike token standards (e.g., [ERC-20](./eip-20.md)/[ERC-721](./eip-721.md)) that model units of transferable ownership, Groups model multi-party membership and coordination. The goal is to enable interoperable social, organizational, and collaborative primitives. + +## Motivation + +Tokens typically model ownership and transfer. Many applications instead need an addressable set of accounts with controlled join/leave semantics and shared state—e.g., project teams, DAOs, game parties, channels, or access cohorts. While [ERC-7743](./eip-7743.md) (MO-NFT) explores multi-ownership for a single token, it still anchors the abstraction to token semantics. Groups generalize this into a token-agnostic container where members can be added over time, without implying transfer or unitized ownership. Following the [ERC-20](./eip-20.md) design pattern, each group is its own contract identified by its address. External resources (tokens, NFTs, etc.) can be associated with groups through their own contracts, just as they would with any EOA or contract address. + +## Specification + +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. + +Contracts that implement this standard MUST support [ERC-165](./eip-165.md). + +### Interface + +```solidity +/// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +/// @title IERC8063 — Minimal interface for onchain groups +/// @notice A group is a contract with an owner and members +interface IERC8063 is IERC165 { + /// @dev Emitted when a member is added to the group + event MemberAdded(address indexed account, address indexed by); + + /// @dev Emitted when a member voluntarily leaves the group + event MemberLeft(address indexed account); + + /// @dev Emitted when a member transfers their membership to another address + event MembershipTransferred(address indexed from, address indexed to); + + /// @notice Returns the owner of the group + function owner() external view returns (address); + + /// @notice Returns the human-readable name of the group (may be empty) + function name() external view returns (string memory); + + /// @notice Returns true if `account` is a member of the group + function isMember(address account) external view returns (bool); + + /// @notice Returns current number of members (including owner) + function getMemberCount() external view returns (uint256); + + /// @notice Owner adds an account as a member + function addMember(address account) external; + + /// @notice Member voluntarily leaves the group (owner cannot leave) + function leaveGroup() external; + + /// @notice Transfer membership to another address (caller loses membership) + /// @param to Address to receive membership (must not already be a member) + function transferMembership(address to) external; +} +``` + +### Semantics + +- Group creation: The contract constructor MUST set the deployer as the group owner and initial member. Implementations SHOULD accept a name and optional metadataURI as constructor parameters. +- Adding members: Only the group owner MAY call `addMember`. The account MUST NOT already be a member. The implementation MUST add them as a member and emit `MemberAdded`. +- Leaving: Any member (except the owner) MAY call `leaveGroup` to voluntarily exit. The implementation MUST remove their membership and emit `MemberLeft`. The owner MUST NOT be allowed to leave. +- Membership transfer: Any member (except the owner) MAY call `transferMembership` to transfer their membership to a non-member address. The caller MUST lose membership, the recipient MUST become a member, and `MembershipTransferred` MUST be emitted. Member count remains unchanged. +- Introspection: `supportsInterface` MUST return true for `type(IERC165).interfaceId` and for `type(IERC8063).interfaceId`. + +### Deployment model + +Following the [ERC-20](./eip-20.md) pattern, each group is its own contract deployment: + +- **Group identity**: A group is uniquely identified by its contract address, just as an ERC-20 token is identified by its contract address. +- **Deployment**: To create a group, deploy a new contract implementing `IERC8063`. The deployer becomes the owner and initial member. +- **Discovery**: Applications can discover groups through events, registries, or direct address references, similar to token discovery patterns. +- **Naming**: Implementations SHOULD accept a human-readable name in the constructor and expose it via `name()`. Empty names are permitted. + +### Optional extensions (non-normative) + +- Admin roles: Owner-delegated administrators that can add members. +- Multi-signature member addition: Require multiple approvals before adding members. +- Open groups: Allow anyone to join without owner approval. +- Transferable ownership: Allow the owner to transfer ownership to another member. +- Removal by governance: Implement member removal through voting or consensus mechanisms for specific use cases requiring expulsion. +- Factory contracts: A factory pattern for deploying groups with standardized initialization. +- Resource associations: External contracts (e.g., [ERC-20](./eip-20.md), [ERC-721](./eip-721.md)) MAY track group ownership or membership-based access using the group's contract address. + +#### ERC-20 compatibility (decimals=0) + +Implementations MAY expose an [ERC-20](./eip-20.md) interface for group membership under the following constraints: + +- `decimals()` MUST return `0`. +- `balanceOf(account)` MUST be either `0` (non-member) or `1` (member). +- `totalSupply()` MUST equal the current member count. +- `transfer(to, amount)` MUST require `amount == 1` and MUST transfer membership of the caller to `to`. +- `approve(spender, amount)` and `allowance(owner, spender)` MAY be implemented with values clamped to `{0,1}`; `transferFrom(from, to, amount)` MUST require `amount == 1` and, if permitted, MUST transfer membership from `from` to `to`. +- All operations MUST preserve the invariant that no account's balance exceeds `1` and MUST NOT allow the owner account to be transferred. + +Reference: see the ERC-20 compatibility implementation in the assets directory. + +## Rationale + +- **One contract per group**: Following the [ERC-20](./eip-20.md) model where each token is its own contract creates a simpler, more composable design. The contract address becomes the natural group identifier. +- **Token-agnostic**: Separates membership from asset ownership/transfer semantics, suitable for social and coordination use cases. +- **Direct member management**: Owner can add members directly without invitation/acceptance flow, simplifying the interface and reducing transaction costs. +- **Voluntary exit only**: Members can leave voluntarily via `leaveGroup()` or transfer their position, but cannot be forcibly removed by the owner. This prevents centralization and ensures membership stability, similar to how ERC-20 token holders cannot have their tokens revoked. +- **Minimal surface**: Add members, voluntary leave, membership queries, and transfers are sufficient for broad interoperability while allowing richer policies via extensions. +- **Transferable membership**: Enables members to delegate or reassign their position without owner intervention, supporting use cases like account migration or role handoffs. +- **External resource model**: Resources (tokens, NFTs) can be associated with groups externally, just as they would with any address, avoiding tight coupling and enabling flexible composition. +- **Relationship to [ERC-7743](./eip-7743.md)**: MO-NFT models multi-ownership of a single token; Groups model multi-membership of a container. Implementations MAY associate a Group with tokens, but the standard does not require token interfaces. + +## Backwards Compatibility + +No known backward compatibility issues. This is a new interface with [ERC-165](./eip-165.md) detection. + +## Reference Implementation + +Reference contracts are provided in the `assets/` directory: + +- `IERC8063.sol` — Interface definition +- `ERC8063.sol` — Minimal reference implementation + +## Security Considerations + +- Membership griefing: Implementations SHOULD bound per-tx iterations and avoid unbounded member enumeration onchain. +- Owner privileges: The owner has unilateral power to add members. Applications relying on group membership SHOULD consider this trust assumption. +- Permanent membership: Members cannot be forcibly removed by the owner, only voluntarily leave or transfer. This design choice prioritizes decentralization but means malicious members cannot be expelled without governance extensions. +- Access control: Clearly document who can add members; the baseline implementation grants this power exclusively to the owner. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). + + diff --git a/assets/erc-8063/ERC8063.sol b/assets/erc-8063/ERC8063.sol new file mode 100644 index 00000000000..2a5a36dfec9 --- /dev/null +++ b/assets/erc-8063/ERC8063.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "./IERC8063.sol"; + +/// @title ERC8063 — minimal reference implementation of IERC8063 +contract ERC8063 is IERC8063 { + address private immutable _owner; + string private _name; + string private _metadataURI; + uint256 private _memberCount; + + mapping(address => bool) private _isMember; + + /// @notice Create a new group; caller becomes owner and initial member + /// @param groupName Human-readable group name + /// @param metadataURI Optional offchain metadata (e.g., JSON document) + constructor(string memory groupName, string memory metadataURI) { + _owner = msg.sender; + _name = groupName; + _metadataURI = metadataURI; + _isMember[msg.sender] = true; + _memberCount = 1; + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IERC8063).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function owner() public view override returns (address) { + return _owner; + } + + function name() public view override returns (string memory) { + return _name; + } + + function isMember(address account) public view override returns (bool) { + return _isMember[account]; + } + + function getMemberCount() external view override returns (uint256) { + return _memberCount; + } + + function addMember(address account) external override { + require(msg.sender == _owner, "Only owner can add"); + require(account != address(0), "Zero address"); + require(!_isMember[account], "Already member"); + _isMember[account] = true; + unchecked { _memberCount += 1; } + emit MemberAdded(account, msg.sender); + } + + function leaveGroup() external override { + require(_isMember[msg.sender], "Not a member"); + require(msg.sender != _owner, "Owner cannot leave"); + _isMember[msg.sender] = false; + unchecked { _memberCount -= 1; } + emit MemberLeft(msg.sender); + } + + function transferMembership(address to) external override { + require(_isMember[msg.sender], "Not a member"); + require(msg.sender != _owner, "Owner cannot transfer"); + require(to != address(0), "Zero address"); + require(!_isMember[to], "Already a member"); + + _isMember[msg.sender] = false; + _isMember[to] = true; + emit MembershipTransferred(msg.sender, to); + } +} diff --git a/assets/erc-8063/ERC8063ERC20.sol b/assets/erc-8063/ERC8063ERC20.sol new file mode 100644 index 00000000000..f94012f8058 --- /dev/null +++ b/assets/erc-8063/ERC8063ERC20.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "./IERC8063.sol"; + +interface IERC20Minimal { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +/// @title ERC8063ERC20 — ERC-8063 group with optional ERC-20 compatibility (decimals=0) +/// @notice Balances are constrained to 0 or 1. Transfers MUST be exactly 1. +contract ERC8063ERC20 is IERC8063, IERC20Minimal { + address private immutable _owner; + string private _name; + string private _symbol; + string private _metadataURI; + uint256 private _memberCount; + + mapping(address => bool) private _isMember; + mapping(address => mapping(address => uint256)) private _allowances; // only 0 or 1 is valid + + constructor(string memory groupName, string memory symbol_, string memory metadataURI) { + _owner = msg.sender; + _name = groupName; + _symbol = symbol_; + _metadataURI = metadataURI; + _isMember[msg.sender] = true; + _memberCount = 1; + emit Transfer(address(0), msg.sender, 1); + emit MemberAdded(msg.sender, msg.sender); + } + + // IERC165 + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IERC8063).interfaceId; + } + + // IERC8063 + function owner() public view override returns (address) { return _owner; } + function isMember(address account) public view override returns (bool) { return _isMember[account]; } + function getMemberCount() public view override returns (uint256) { return _memberCount; } + + function addMember(address account) external override { + require(msg.sender == _owner, "Only owner"); + require(account != address(0), "Zero address"); + require(!_isMember[account], "Already member"); + _isMember[account] = true; + unchecked { _memberCount += 1; } + emit MemberAdded(account, msg.sender); + emit Transfer(address(0), account, 1); + } + + function leaveGroup() external override { + require(_isMember[msg.sender], "Not member"); + require(msg.sender != _owner, "Owner cannot leave"); + _isMember[msg.sender] = false; + unchecked { _memberCount -= 1; } + emit MemberLeft(msg.sender); + emit Transfer(msg.sender, address(0), 1); + } + + function transferMembership(address to) external override { + require(_isMember[msg.sender], "Not member"); + require(msg.sender != _owner, "Owner cannot transfer"); + require(to != address(0), "Zero address"); + require(!_isMember[to], "Already member"); + _isMember[msg.sender] = false; + _isMember[to] = true; + emit MembershipTransferred(msg.sender, to); + emit Transfer(msg.sender, to, 1); + } + + function name() public view override(IERC20Minimal, IERC8063) returns (string memory) { return _name; } + function symbol() public view override returns (string memory) { return _symbol; } + function decimals() public pure override returns (uint8) { return 0; } + + // ERC-20 views + function totalSupply() public view override returns (uint256) { return _memberCount; } + function balanceOf(address account) public view override returns (uint256) { return _isMember[account] ? 1 : 0; } + + // ERC-20 actions (amount MUST be exactly 1) + function transfer(address to, uint256 amount) external override returns (bool) { + require(amount == 1, "amount must be 1"); + transferMembership(to); + return true; + } + + function allowance(address owner_, address spender) public view override returns (uint256) { + uint256 a = _allowances[owner_][spender]; + return a > 0 ? 1 : 0; + } + + function approve(address spender, uint256 amount) external override returns (bool) { + require(spender != address(0), "Zero spender"); + // Only 0 or 1 are meaningful; clamp to {0,1} + _allowances[msg.sender][spender] = amount > 0 ? 1 : 0; + emit Approval(msg.sender, spender, _allowances[msg.sender][spender]); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + require(amount == 1, "amount must be 1"); + require(_allowances[from][msg.sender] >= 1, "insufficient allowance"); + require(_isMember[from], "from not member"); + require(from != _owner, "Owner cannot transfer"); + require(to != address(0), "Zero address"); + require(!_isMember[to], "to already member"); + _allowances[from][msg.sender] = 0; + _isMember[from] = false; + _isMember[to] = true; + emit Transfer(from, to, 1); + emit MembershipTransferred(from, to); + return true; + } +} + + diff --git a/assets/erc-8063/IERC8063.sol b/assets/erc-8063/IERC8063.sol new file mode 100644 index 00000000000..baeaa95355d --- /dev/null +++ b/assets/erc-8063/IERC8063.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +/// @title IERC8063 — Minimal interface for onchain groups +/// @notice A group is a contract with an owner and members +interface IERC8063 is IERC165 { + /// @dev Emitted when a member is added to the group + event MemberAdded(address indexed account, address indexed by); + + /// @dev Emitted when a member voluntarily leaves the group + event MemberLeft(address indexed account); + + /// @dev Emitted when a member transfers their membership to another address + event MembershipTransferred(address indexed from, address indexed to); + + /// @notice Returns the owner of the group + function owner() external view returns (address); + + /// @notice Returns the human-readable name of the group (may be empty) + function name() external view returns (string memory); + + /// @notice Returns true if `account` is a member of the group + function isMember(address account) external view returns (bool); + + /// @notice Returns current number of members (including owner) + function getMemberCount() external view returns (uint256); + + /// @notice Owner adds an account as a member + function addMember(address account) external; + + /// @notice Member voluntarily leaves the group (owner cannot leave) + function leaveGroup() external; + + /// @notice Transfer membership to another address (caller loses membership) + /// @param to Address to receive membership (must not already be a member) + function transferMembership(address to) external; +} diff --git a/assets/erc-8063/README.md b/assets/erc-8063/README.md new file mode 100644 index 00000000000..f0137449e00 --- /dev/null +++ b/assets/erc-8063/README.md @@ -0,0 +1,8 @@ +Groups reference implementation assets + +- After creating the Ethereum Magicians discussion, update `discussions-to` in `ERCS/EIP-0000.md` with the live URL. +- Contracts: + - `IERCGroup.sol`: interface + - `GroupContainer.sol`: minimal implementation + +