Robinhood MainnetChain ID: 4663
Contract detail

PayoutGroupFacet

PayoutGroupFacet is a verified Robinhood Chain smart contract at 0xdc3ef8ab…73aa1fe5 with 0 indexed transactions.

Address type
Smart contract
Transactions
0
History coverage
No indexed activity found
Native balance
0 ETH
Balance observed at
Block #35676155
Verification
Verified
Compiler
v0.8.28+commit.7893614a
Verification match
full
Creation transaction
0x62e70e55…4160af63
Created at block
#55
Language
solidity
EVM version
paris
License
none
Compiler optimization
Enabled · 200 runs
Verified contract

Contract source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import { ClaimableRewardsBase } from "../ClaimableRewardsBase.sol";
import { SharesLib } from "../lib/SharesLib.sol";
import { StorageLib } from "../lib/StorageLib.sol";
import { Roles } from "../lib/Roles.sol";
import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";

/**
 * @title PayoutGroupFacet
 * @notice Diamond facet for payout group and registration management
 * @dev Handles payout group CRUD, account registration, and related view functions
 *
 * @dev STORAGE: All storage variables inherited from ClaimableRewardsStorageV3 for shared access.
 *      Storage is shared with main contract through ClaimableRewardsStorageV3 inheritance.
 *
 * Functions:
 * - Payout group management (10 functions)
 * - Registration (6 functions)
 * - View functions (9 functions)
 *
 * @custom:security-contact smart-contract-security@paxos.com
 */
contract PayoutGroupFacet is ClaimableRewardsBase {
    // EIP-712 typehashes for signature-based registration
    bytes32 public constant REGISTER_REWARD_ADDRESS_TYPEHASH =
        keccak256("RegisterRewardAddress(address account,uint32 payoutGroupId,bytes32 nonce,uint256 deadline)");

    // NOTE: Events inherited from ClaimableRewardsEvents
    // NOTE: Shared errors are inherited via ClaimableRewardsBase:
    //       - From ClaimableRewardsErrors: MultiplierIndexNotFound, InactivePayoutGroup, InvalidClaimer, NotAccountClaimer
    //       - From ClaimableRewardsBase: AddressFrozen
    //       - From PaxosBaseAbstract (via ClaimableRewardsBase): InvalidSignature

    // Facet-specific errors
    error PayoutGroupAddressExists();
    error SignatureExpired();
    error PartnerSignedRegistrationsDisabled();
    error AuthorizationAlreadyUsed();
    error UnauthorizedRegistration();

    /**
     * @notice Returns the token name for EIP-712 domain
     * @return The token name
     */
    function name() public view virtual override returns (string memory) {
        return "Global Dollar";
    }

    // Modifiers
    modifier onlyPayoutGroupAdminRole() {
        if (!hasRole(Roles.PAYOUT_GROUP_ADMIN_ROLE, msg.sender)) revert InvalidClaimer();
        _;
    }

    modifier onlyPayoutGroupRegistrarRole() {
        if (!hasRole(Roles.PAYOUT_GROUP_REGISTRAR_ROLE, msg.sender)) revert InvalidClaimer();
        _;
    }

    // ============ PAYOUT GROUP MANAGEMENT FUNCTIONS ============

    /**
     * @notice Create a new payout group
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Associates claimer with multiplier.
     * @param multiplierId The multiplier ID to use for this group
     * @param claimer The claimer address for the payout group
     * @return payoutId The ID of the newly created payout group
     */
    function createPayoutGroup(uint32 multiplierId, address claimer) external onlyPayoutGroupRegistrarRole returns (uint32 payoutId) {
        return _createPayoutGroup(multiplierId, claimer, address(0), address(0));
    }

    /**
     * @notice Create a new payout group with optional manager and destination
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Use address(0) for manager/destination to skip.
     * @param multiplierId The multiplier ID to use for this group
     * @param claimer The claimer address for the payout group
     * @param manager The manager address (address(0) to skip setting manager)
     * @param destination The destination address (address(0) defaults to claimer)
     * @return payoutId The ID of the newly created payout group
     */
    function createPayoutGroupWithRoles(
        uint32 multiplierId,
        address claimer,
        address manager,
        address destination
    ) external onlyPayoutGroupRegistrarRole returns (uint32 payoutId) {
        return _createPayoutGroup(multiplierId, claimer, manager, destination);
    }

    /**
     * @dev Internal function to create a payout group with optional manager and destination
     * @param multiplierId The multiplier ID to use for this group
     * @param claimer The claimer address for the payout group
     * @param manager The manager address (address(0) to skip setting manager)
     * @param destination The destination address (address(0) defaults to claimer)
     * @return payoutId The ID of the newly created payout group
     */
    function _createPayoutGroup(
        uint32 multiplierId,
        address claimer,
        address manager,
        address destination
    ) internal returns (uint32 payoutId) {
        if (claimer == address(0)) revert InvalidClaimer();
        // Check if multiplier exists using helper
        if (!_multiplierExists(multiplierId)) revert MultiplierIndexNotFound(multiplierId);
        if (destination != address(0) && _isFrozen(destination)) revert AddressFrozen();

        // Initialize nextPayoutId if not set (diamond pattern storage access issue)
        if (nextPayoutId == 0) {
            nextPayoutId = 1;
        }
        payoutId = nextPayoutId++;

        // Set claimer mapping (note: multiple payout groups can share same claimer)
        payoutIdToClaimer[payoutId] = claimer;

        // Initialize payout group data using PayoutGroupData struct (shares model)
        uint256 currentMultiplier = _getActiveMultiplier(multiplierId); // Already at 12 decimals
        payoutData[payoutId] = PayoutGroupData({
            balance: 0,
            shares: 0,
            multiplierId: uint16(multiplierId),
            lastClaimAllTime: 0, // No claimAll has occurred yet
            lastClaimAllMultiplier: StorageLib.toUint48Multiplier(currentMultiplier) // Already 12 decimals
        });

        // Increment payout group count for this multiplier
        multiplierPayoutGroupCount[multiplierId]++;

        emit PayoutGroupCreated(payoutId, claimer, multiplierId);

        // Set optional manager
        if (manager != address(0)) {
            payoutIdToManager[payoutId] = manager;
            emit PayoutGroupManagerSet(payoutId, address(0), manager);
        }

        // Set optional destination
        if (destination != address(0)) {
            payoutIdToDestination[payoutId] = destination;
            emit PayoutGroupDestinationSet(payoutId, address(0), destination);
        }

        return payoutId;
    }

    /**
     * @notice Delete a payout group
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Requires claiming to configured destination first.
     * @param payoutGroupId The payout group ID to remove
     */
    function deletePayoutGroup(uint32 payoutGroupId) external onlyPayoutGroupRegistrarRole {
        // Idempotent: if already deleted/inactive, skip (no-op)
        if (!_isPayoutGroupActive(payoutGroupId)) {
            return;
        }

        // Force claim all pending rewards before deletion
        address destination = _getPayoutDestination(payoutGroupId);
        _executeClaimAll(payoutGroupId, destination);

        // Decrement payout group count for this multiplier
        uint32 multiplierId = payoutData[payoutGroupId].multiplierId;
        multiplierPayoutGroupCount[multiplierId]--;

        // Get claimer before deletion for event emission
        address claimer = payoutIdToClaimer[payoutGroupId];

        // Clear all mappings by setting to zero
        delete payoutIdToClaimer[payoutGroupId];
        delete payoutIdToManager[payoutGroupId];
        delete payoutIdToDestination[payoutGroupId];
        delete payoutData[payoutGroupId];

        emit PayoutGroupDeleted(payoutGroupId, claimer);
    }

    /**
     * @notice Change the multiplier for a payout group (forces claim)
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Forces claim before changing.
     * @param payoutGroupId The payout group ID
     * @param multiplierId The new multiplier ID
     */
    function adminSetPayoutGroupMultiplier(uint32 payoutGroupId, uint32 multiplierId) external onlyPayoutGroupRegistrarRole {
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();
        // Check if multiplier exists using helper
        if (!_multiplierExists(multiplierId)) revert MultiplierIndexNotFound(multiplierId);

        PayoutGroupData storage payoutGroup = payoutData[payoutGroupId];
        uint32 oldMultId = payoutGroup.multiplierId;

        if (oldMultId == multiplierId) {
            return; // No change needed
        }

        // Force claim before changing multiplier
        address destination = _getPayoutDestination(payoutGroupId);
        _executeClaimAll(payoutGroupId, destination);

        // Update payout group counts for both multipliers
        multiplierPayoutGroupCount[oldMultId]--;  // Decrement old multiplier
        multiplierPayoutGroupCount[multiplierId]++;     // Increment new multiplier

        // Update payout group to new multiplier
        payoutGroup.multiplierId = uint16(multiplierId);

        // Update lastClaimAllMultiplier to the NEW multiplier's current value.
        // lastClaimAllMultiplier is the multiplier value from which token accounts accrue
        // rewards since the last claimAll. By setting it to the new multiplier's value,
        // wallets start accruing rewards immediately at the new rate.
        uint256 newMultiplierValue = _getActiveMultiplier(multiplierId);
        payoutGroup.lastClaimAllMultiplier = StorageLib.toUint48Multiplier(newMultiplierValue);

        // Recalculate group shares with new multiplier to prevent phantom rewards
        // when switching to a higher-valued multiplier
        uint256 groupBalance = uint256(payoutGroup.balance);
        payoutGroup.shares = StorageLib.toUint64Shares(SharesLib.calcShares(groupBalance, newMultiplierValue));

        address claimer = payoutIdToClaimer[payoutGroupId];
        emit PayoutGroupMultiplierUpdated(payoutGroupId, claimer, oldMultId, multiplierId);
    }

    /**
     * @notice Admin set payout group claimer
     * @dev Restricted to PAYOUT_GROUP_ADMIN_ROLE
     * @param payoutGroupId The payout group ID
     * @param newClaimer The new claimer address
     */
    function adminSetPayoutGroupClaimer(uint32 payoutGroupId, address newClaimer) external onlyPayoutGroupAdminRole {
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();
        if (newClaimer == address(0)) revert InvalidClaimer();

        address oldClaimer = payoutIdToClaimer[payoutGroupId];

        // Update claimer mapping (note: multiple payout groups can share same claimer)
        payoutIdToClaimer[payoutGroupId] = newClaimer;

        emit PayoutClaimerUpdated(payoutGroupId, oldClaimer, newClaimer);
    }

    /**
     * @notice Admin set payout group manager
     * @dev Restricted to PAYOUT_GROUP_ADMIN_ROLE
     * @param payoutGroupId The payout group ID
     * @param newManager The new manager address (address(0) to remove)
     */
    function adminSetPayoutGroupManager(uint32 payoutGroupId, address newManager) external onlyPayoutGroupAdminRole {
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();

        address oldManager = payoutIdToManager[payoutGroupId];
        payoutIdToManager[payoutGroupId] = newManager;

        emit PayoutGroupManagerSet(payoutGroupId, oldManager, newManager);
    }

    /**
     * @notice Admin set payout group destination
     * @dev Restricted to PAYOUT_GROUP_ADMIN_ROLE
     * @param payoutGroupId The payout group ID
     * @param newDestination The new destination address (address(0) defaults to claimer)
     */
    function adminSetPayoutGroupDestination(uint32 payoutGroupId, address newDestination) external onlyPayoutGroupAdminRole {
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();
        if (newDestination != address(0) && _isFrozen(newDestination)) revert AddressFrozen();

        address oldDestination = payoutIdToDestination[payoutGroupId];
        payoutIdToDestination[payoutGroupId] = newDestination;

        emit PayoutGroupDestinationSet(payoutGroupId, oldDestination, newDestination);
    }

    /**
     * @notice Manager set payout group claimer (manager can change claimer)
     * @dev Only callable by the manager of this payout group
     * @param payoutGroupId The payout group ID
     * @param newClaimer The new claimer address
     */
    function setPayoutGroupClaimer(uint32 payoutGroupId, address newClaimer) external {
        // Verify msg.sender is the manager for this payout group
        if (payoutIdToManager[payoutGroupId] != msg.sender) revert InvalidClaimer();
        if (newClaimer == address(0)) revert InvalidClaimer();

        address oldClaimer = payoutIdToClaimer[payoutGroupId];

        // Update claimer mapping (note: multiple payout groups can share same claimer)
        payoutIdToClaimer[payoutGroupId] = newClaimer;

        emit PayoutClaimerUpdated(payoutGroupId, oldClaimer, newClaimer);
    }

    /**
     * @notice Manager set payout group manager
     * @dev Only callable by the manager of this payout group
     * @param payoutGroupId The payout group ID
     * @param newManager The new manager address (address(0) to remove)
     */
    function setPayoutGroupManager(uint32 payoutGroupId, address newManager) external {
        // Verify msg.sender is the manager for this payout group
        if (payoutIdToManager[payoutGroupId] != msg.sender) revert InvalidClaimer();

        address oldManager = payoutIdToManager[payoutGroupId];
        payoutIdToManager[payoutGroupId] = newManager;

        emit PayoutGroupManagerSet(payoutGroupId, oldManager, newManager);
    }

    /**
     * @notice Manager set payout group destination
     * @dev Only callable by the manager of this payout group
     * @param payoutGroupId The payout group ID
     * @param newDestination The new destination address (address(0) defaults to claimer)
     */
    function setPayoutGroupDestination(uint32 payoutGroupId, address newDestination) external {
        // Verify msg.sender is the manager for this payout group
        if (payoutIdToManager[payoutGroupId] != msg.sender) revert InvalidClaimer();
        if (newDestination != address(0) && _isFrozen(newDestination)) revert AddressFrozen();

        address oldDestination = payoutIdToDestination[payoutGroupId];
        payoutIdToDestination[payoutGroupId] = newDestination;

        emit PayoutGroupDestinationSet(payoutGroupId, oldDestination, newDestination);
    }

    /**
     * @notice Enable or disable partner-initiated signature-based registrations
     * @dev Restricted to PAYOUT_GROUP_ADMIN_ROLE
     * @param enabled Whether to enable partner-initiated signature-based registrations
     */
    function setPartnerSignedRegistrationsEnabled(bool enabled) external onlyPayoutGroupAdminRole {
        globalTransferSettings.partnerSignedRegistrationsEnabled = enabled;
        emit PartnerSignedRegistrationsEnabledSet(enabled);
    }

    // ============ REGISTRATION FUNCTIONS ============

    /**
     * @notice Register an account to a payout group (Paxos registrar)
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Preserves any available rewards.
     * @param payoutGroupId The payout group ID
     * @param account The account to register
     */
    function registrarRegisterRewardAddress(uint32 payoutGroupId, address account) external onlyPayoutGroupRegistrarRole {
        _internalRegisterRewardAddress(payoutGroupId, account);
    }

    /**
     * @notice Unregister an account from a payout group (Paxos registrar)
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Includes claiming to configured destination.
     * @param payoutGroupId The payout group ID
     * @param account The account to unregister
     */
    function registrarUnregisterRewardAddress(uint32 payoutGroupId, address account) external onlyPayoutGroupRegistrarRole {
        _internalUnregisterRewardAddress(payoutGroupId, account);
    }

    /**
     * @notice Register multiple accounts to a payout group (Paxos registrar batch)
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Preserves any available rewards.
     * @param payoutGroupId The payout group ID
     * @param accounts Array of accounts to register
     */
    function registrarRegisterRewardAddressBatch(uint32 payoutGroupId, address[] calldata accounts) external onlyPayoutGroupRegistrarRole {
        for (uint256 i = 0; i < accounts.length; i++) {
            _internalRegisterRewardAddress(payoutGroupId, accounts[i]);
        }
    }

    /**
     * @notice Unregister multiple accounts from a payout group (Paxos registrar batch)
     * @dev Restricted to PAYOUT_GROUP_REGISTRAR_ROLE. Includes claiming to configured destination.
     * @param payoutGroupId The payout group ID
     * @param accounts Array of accounts to unregister
     */
    function registrarUnregisterRewardAddressBatch(uint32 payoutGroupId, address[] calldata accounts) external onlyPayoutGroupRegistrarRole {
        for (uint256 i = 0; i < accounts.length; i++) {
            _internalUnregisterRewardAddress(payoutGroupId, accounts[i]);
        }
    }

    /**
     * @notice Register an account to a payout group with signature validation (bytes signature version)
     * @dev Account signs EIP-712 structured data to authorize registration. Supports both EOA and contract wallets via EIP-1271.
     * @param payoutGroupId The payout group ID to register with
     * @param account The account to register (must match signature signer)
     * @param nonce A unique random nonce for this authorization
     * @param deadline Deadline for signature validity
     * @param signature Packed signature bytes (65 bytes for EOA, variable for contract wallets)
     */
    function registerRewardAddress(
        uint32 payoutGroupId,
        address account,
        bytes32 nonce,
        uint256 deadline,
        bytes memory signature
    ) external {
        _registerRewardAddress(payoutGroupId, account, nonce, deadline, signature);
    }

    /**
     * @notice Unregister an account from a payout group (claimer or manager-initiated, no signature required)
     * @dev Claimer OR manager of payoutGroupId calls this to unregister an address. Forces claim too.
     * @param payoutGroupId The payout group ID
     * @param account The account to unregister
     */
    function unregisterRewardAddress(uint32 payoutGroupId, address account) external whenNotPaused {
        // Verify caller is the claimer OR manager for this payout group
        if (!_isClaimerOrManager(payoutGroupId)) revert NotAccountClaimer();

        _internalUnregisterRewardAddress(payoutGroupId, account);
    }

    /**
     * @notice Propose registration of an account to a payout group (for smart contracts that cannot sign)
     * @dev Callable by claimer/manager OR registrar role. Creates pending proposal that must be accepted by account.
     * @param payoutGroupId The payout group ID
     * @param account The account to propose for registration
     */
    function proposeRegisterRewardAddress(uint32 payoutGroupId, address account) external {
        // Check authorization: must be claimer/manager OR registrar
        bool isClaimerOrManager = _isClaimerOrManager(payoutGroupId);
        bool isRegistrar = hasRole(Roles.PAYOUT_GROUP_REGISTRAR_ROLE, msg.sender);

        if (!isClaimerOrManager && !isRegistrar) {
            revert UnauthorizedRegistration();
        }

        // Validate inputs
        if (account == address(0)) revert InvalidAccount();
        if (account == address(this)) revert ContractCannotBeRegistered();
        if (!_isPayoutGroupActive(payoutGroupId)) revert PayoutGroupNotFound();

        // Prevent claim source from being registered to payout groups
        if (account == adminConfig.claimSource) revert ClaimSourceCannotBeRegistered();

        // Note: If account is already registered to another active payout group,
        // _internalRegisterRewardAddress will auto-unregister when the proposal is accepted

        // Store the pending proposal
        _pendingRegistrations[account] = PendingRegistration({
            payoutGroupId: payoutGroupId,
            proposer: msg.sender
        });

        emit RegistrationProposed(account, payoutGroupId, msg.sender);
    }

    /**
     * @notice Accept a pending registration proposal
     * @dev Must be called by the account that was proposed for registration
     * @param payoutGroupId The payout group ID to register to
     */
    function acceptRegisterRewardAddress(uint32 payoutGroupId) external {
        // Load the pending proposal for this caller
        PendingRegistration memory proposal = _pendingRegistrations[msg.sender];

        // Validate proposal exists and matches
        if (proposal.proposer == address(0)) revert NoRegistrationProposal();
        if (proposal.payoutGroupId != payoutGroupId) revert PayoutGroupMismatch(proposal.payoutGroupId, payoutGroupId);

        // Delete the proposal first (checks-effects-interactions pattern)
        // Note: If account is already registered, _internalRegisterRewardAddress will auto-unregister
        delete _pendingRegistrations[msg.sender];

        // Execute the registration
        _internalRegisterRewardAddress(payoutGroupId, msg.sender);

        emit RegistrationAccepted(msg.sender, payoutGroupId);
    }

    /**
     * @notice Cancel a pending registration proposal
     * @dev Can only be called by the address that made the proposal
     * @param account The account whose proposal to cancel
     */
    function cancelRegistrationProposal(address account) external {
        PendingRegistration memory proposal = _pendingRegistrations[account];

        // Validate proposal exists
        if (proposal.proposer == address(0)) revert NoRegistrationProposal();

        // Validate caller is the proposer
        if (proposal.proposer != msg.sender) revert NotProposer();

        // Store payoutGroupId before deletion for event
        uint32 payoutGroupId = proposal.payoutGroupId;

        // Delete the proposal
        delete _pendingRegistrations[account];

        emit RegistrationProposalCancelled(account, payoutGroupId, msg.sender);
    }

    // ============ VIEW FUNCTIONS ============

    /**
     * @notice Get available rewards for an account (spec-compliant, shares model)
     * @param account The account to check
     * @return Available rewards amount
     */
    function availableRewardsOf(address account) external view returns (uint256) {
        return _availableRewardsOf(account);
    }

    /**
     * @notice Get available rewards for multiple accounts
     * @param accounts The accounts to check
     * @return rewards Array of available rewards amounts (same order as input accounts)
     */
    function availableRewardsOfBatch(address[] calldata accounts) external view returns (uint256[] memory rewards) {
        rewards = new uint256[](accounts.length);
        for (uint256 i = 0; i < accounts.length;) {
            rewards[i] = _availableRewardsOf(accounts[i]);
            unchecked { ++i; }
        }
        return rewards;
    }

    /**
     * @dev Internal function to calculate available rewards for an account
     * @param account The account to check
     * @return Available rewards amount
     */
    function _availableRewardsOf(address account) internal view returns (uint256) {
        TokenAccountData memory wallet = _getBalanceData(account);

        if (wallet.payoutGroupId == 0) {
            return 0; // No payout group
        }

        if (!_isPayoutGroupActive(wallet.payoutGroupId)) {
            return 0; // Inactive payout group
        }

        // Standard calculation for all accounts (shares model)
        // Get the multiplier for this account's payout group
        PayoutGroupData memory payoutGroup = payoutData[wallet.payoutGroupId];
        uint32 multiplierId = payoutGroup.multiplierId;
        uint256 currentMultiplier = _getActiveMultiplier(multiplierId);

        // Handle claimAll detection using timestamps (monotonically non-decreasing)
        uint256 shares = SharesLib.handleClaimAllDetection(
            uint256(wallet.balance),
            uint256(wallet.shares),
            wallet.lastUpdateTime,
            payoutGroup.lastClaimAllTime,
            uint256(payoutGroup.lastClaimAllMultiplier)
        );

        // Calculate rewards from shares using current multiplier
        // This gives rewards "up to now" by applying multiplier growth to the preserved shares
        return SharesLib.calcRewards(shares, currentMultiplier, uint256(wallet.balance));
    }

    /**
     * @notice Get payout group ID for an account
     * @param account The account to check
     * @return The payout group ID (0 if none)
     */
    function payoutGroupIdOf(address account) external view returns (uint32) {
        return _getBalanceData(account).payoutGroupId;
    }


    /**
     * @notice Get payout group available rewards by ID (spec-compliant, shares model)
     * @param payoutGroupId The payout group ID
     * @return Total available rewards for this payout group
     */
    function getPayoutGroupAvailableRewards(uint32 payoutGroupId) external view returns (uint256) {
        if (!_isPayoutGroupActive(payoutGroupId)) return 0;

        PayoutGroupData memory payoutGroup = payoutData[payoutGroupId];
        uint32 multiplierId = payoutGroup.multiplierId;

        // Calculate on-demand using shares model (same logic as _executeClaimAll)
        uint256 currentMultiplier = _getActiveMultiplier(multiplierId);
        uint256 groupBalance = uint256(payoutGroup.balance);
        uint256 groupShares = uint256(payoutGroup.shares);

        // Calculate rewards from shares
        return SharesLib.calcRewards(groupShares, currentMultiplier, groupBalance);
    }

    /**
     * @notice Get payout group multiplier ID (spec-compliant)
     * @param payoutGroupId The payout group ID
     * @return The multiplier ID for this payout group
     */
    function getPayoutGroupMultId(uint32 payoutGroupId) external view returns (uint32) {
        if (!_isPayoutGroupActive(payoutGroupId)) return 0;

        return payoutData[payoutGroupId].multiplierId;
    }

    /**
     * @notice Get payout group claimer
     * @param payoutGroupId The payout group ID
     * @return The claimer address
     */
    function getPayoutGroupClaimer(uint32 payoutGroupId) external view returns (address) {
        return payoutIdToClaimer[payoutGroupId];
    }

    /**
     * @notice Get payout group manager
     * @param payoutGroupId The payout group ID
     * @return The manager address (address(0) if not set)
     */
    function getPayoutGroupManager(uint32 payoutGroupId) external view returns (address) {
        return payoutIdToManager[payoutGroupId];
    }

    /**
     * @notice Get payout group destination
     * @param payoutGroupId The payout group ID
     * @return The destination address (defaults to claimer if not set)
     */
    function getPayoutGroupDestination(uint32 payoutGroupId) external view returns (address) {
        return _getPayoutDestination(payoutGroupId);
    }

    /**
     * @notice Get pending registration proposal for an account
     * @param account The account to check
     * @return payoutGroupId The payout group ID in the proposal (0 if no proposal)
     * @return proposer The address that proposed the registration (zero address if no proposal)
     */
    function getPendingRegistration(address account) external view returns (uint32 payoutGroupId, address proposer) {
        PendingRegistration memory proposal = _pendingRegistrations[account];
        return (proposal.payoutGroupId, proposal.proposer);
    }

    /**
     * @notice Get payout group balance by ID (spec-compliant)
     * @param payoutGroupId The payout group ID
     * @return The balance for this payout group
     */
    function getPayoutGroupBalance(uint32 payoutGroupId) external view returns (uint256) {
        if (!_isPayoutGroupActive(payoutGroupId)) return 0;
        return uint256(payoutData[payoutGroupId].balance);
    }

    /**
     * @notice Check if partner-initiated signature-based registrations are enabled
     * @return Whether partner-initiated signature-based registrations are enabled
     */
    function isPartnerSignedRegistrationsEnabled() external view returns (bool) {
        return globalTransferSettings.partnerSignedRegistrationsEnabled;
    }


    // ============ INTERNAL HELPER FUNCTIONS ============

    /**
     * @dev Internal function to handle reward address registration with bytes signature
     * @param payoutGroupId The payout group ID to register with
     * @param account The account to register (must match signature signer)
     * @param nonce A unique random nonce for this authorization
     * @param deadline Deadline for signature validity
     * @param signature Packed signature bytes
     */
    function _registerRewardAddress(
        uint32 payoutGroupId,
        address account,
        bytes32 nonce,
        uint256 deadline,
        bytes memory signature
    ) internal {
        // Check if feature is enabled
        if (!globalTransferSettings.partnerSignedRegistrationsEnabled) revert PartnerSignedRegistrationsDisabled();

        // Verify caller is the claimer OR manager for this payout group
        if (!_isClaimerOrManager(payoutGroupId)) revert NotAccountClaimer();
        if (block.timestamp > deadline) revert SignatureExpired();

        // Check nonce hasn't been used
        if (_registrationAuthState[account][nonce]) revert AuthorizationAlreadyUsed();

        // Mark nonce as used
        _registrationAuthState[account][nonce] = true;

        // Build EIP-712 digest
        bytes32 digest = keccak256(abi.encodePacked(
            EIP712_VERSION_PREFIX,
            DOMAIN_SEPARATOR(),
            keccak256(abi.encode(
                REGISTER_REWARD_ADDRESS_TYPEHASH,
                account,
                payoutGroupId,
                nonce,
                deadline
            ))
        ));

        // Verify signature matches account
        if (!SignatureChecker.isValidSignatureNow(account, digest, signature)) revert InvalidSignature();

        _internalRegisterRewardAddress(uint32(payoutGroupId), account);
    }

    /**
     * @dev Internal function to register an account to a payout group (shares model)
     */
    function _internalRegisterRewardAddress(uint32 payoutGroupId, address account) internal {
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();

        // Prevent claim source from being registered to payout groups
        if (account == adminConfig.claimSource) revert ClaimSourceCannotBeRegistered();

        // Prevent frozen accounts from being registered
        if (_isFrozen(account)) revert AddressFrozen();

        // Prevent contract address from being registered to payout groups
        if (account == address(this)) revert ContractCannotBeRegistered();

        // Load account balance data
        TokenAccountData memory wallet = _getBalanceData(account);

        // Idempotent: if already registered to the SAME payout group, skip (no-op)
        if (wallet.payoutGroupId == payoutGroupId) {
            return; // Already in desired state
        }

        // Auto-unregister from current payout group if registered to a different active group
        // This allows users to switch payout groups with a single signature
        // (claims rewards to old destination before switching)
        if (wallet.payoutGroupId != 0 && _isPayoutGroupActive(wallet.payoutGroupId)) {
            _internalUnregisterRewardAddress(wallet.payoutGroupId, account);
            // Reload wallet data after unregister (shares were reset)
            wallet = _getBalanceData(account);
        }

        // Load payout group data
        PayoutGroupData storage payoutGroup = payoutData[payoutGroupId];
        uint32 multiplierId = payoutGroup.multiplierId;
        uint256 currentMultiplier = _getActiveMultiplier(multiplierId);

        // Calculate new shares based on actual balance
        uint256 newShares = SharesLib.calcShares(uint256(wallet.balance), currentMultiplier);

        // Update account with new payout group (shares model)
        uint40 currentTime = uint40(block.timestamp);
        _setBalanceData(
            account,
            uint256(wallet.balance),
            newShares,
            uint256(payoutGroupId),
            currentTime
        );

        // Update payout group balance and shares (wallet.balance is never negative)
        // Load payoutGroup to memory for optimization
        PayoutGroupData memory payoutGroupMem = payoutData[payoutGroupId];

        _updatePayoutGroupBals(payoutGroupId, payoutGroupMem, int256(uint256(wallet.balance)), int256(newShares));

        address claimer = payoutIdToClaimer[payoutGroupId];
        emit AccountRegistered(account, payoutGroupId, claimer);
    }

    /**
     * @dev Internal function to unregister an account from a payout group (shares model)
     */
    function _internalUnregisterRewardAddress(uint32 payoutGroupId, address account) internal {
        TokenAccountData memory wallet = _getBalanceData(account);

        // Idempotent: if already unregistered (payoutGroupId == 0), skip (no-op)
        if (wallet.payoutGroupId == 0) {
            return; // Already in desired state
        }

        // Verify account is registered to the correct payout group
        if (wallet.payoutGroupId != payoutGroupId) revert NotAccountClaimer();

        // Verify payout group is active (prevents unregistering from deleted groups)
        if (!_isPayoutGroupActive(payoutGroupId)) revert InactivePayoutGroup();

        // Claim any available rewards to configured destination before unregistering
        address destination = _getPayoutDestination(payoutGroupId);
        _claimIndividualRewards(account, destination);

        // Reload wallet data after claim (shares were reset)
        TokenAccountData memory updatedWallet = _getBalanceData(account);

        // Calculate deltas for hierarchical updates (removing this account)
        int256 balanceDelta = -int256(uint256(updatedWallet.balance));
        int256 sharesDelta = -int256(uint256(updatedWallet.shares));

        // Deregister - clear payout group assignment (set shares=0, payoutGroupId=0, lastUpdateTime=0)
        _setBalanceData(account, uint256(updatedWallet.balance), 0, 0, 0);

        // Get payout group data
        PayoutGroupData memory payoutGroup = payoutData[payoutGroupId];
        // Update payout group balance and shares
        _updatePayoutGroupBals(payoutGroupId, payoutGroup, balanceDelta, sharesDelta);

        address claimer = payoutIdToClaimer[payoutGroupId];
        emit AccountDeregistered(account, payoutGroupId, claimer);
    }
}