Skip to main content

TIP-1015: Compound Transfer Policies

Protocol Version: T2
Status: In Review
Authors: Dan Robinson
Related: TIP-403, TIP-20

Abstract

This TIP extends the TIP-403 policy registry to support compound policies that allow token issuers to specify different authorization rules for senders, recipients, and mint recipients. A compound policy references three simple policies: one for sender authorization, one for recipient authorization, and one for mint recipient authorization. Compound policies are immutable once created.

Motivation

The current TIP-403 system applies the same policy to both senders and recipients of a token transfer. However, real-world requirements often differ between sending and receiving:
  • Vendor credits: A business may issue credits that can be minted to anyone and spent by holders to a specific vendor, but cannot be transferred peer-to-peer. This requires allowing all addresses as recipients (for minting) while restricting senders to only transfer to the vendor’s address.
  • Sender restrictions: An issuer may want to block sanctioned addresses from sending tokens, while allowing anyone to receive tokens (e.g., for refunds or seizure).
  • Recipient restrictions: An issuer may require recipients to be KYC-verified, while allowing any holder to send tokens out.
  • Asymmetric compliance: Different jurisdictions may have different requirements for inflows vs outflows.
Compound policies enable these use cases while maintaining backward compatibility with existing simple policies.

Specification

Policy Types

TIP-403 currently supports two policy types: WHITELIST and BLACKLIST. This TIP adds a third type:
enum PolicyType {
    WHITELIST,
    BLACKLIST,
    COMPOUND
}

Compound Policy Structure

A compound policy references three existing simple policies by their policy IDs:
struct CompoundPolicyData {
    uint64 senderPolicyId;        // Policy checked for transfer senders
    uint64 recipientPolicyId;     // Policy checked for transfer recipients
    uint64 mintRecipientPolicyId; // Policy checked for mint recipients
}
All three referenced policies MUST be simple policies (WHITELIST or BLACKLIST), not compound policies. This prevents circular references and unbounded recursion.

Storage Layout

Policy data is stored in a unified PolicyRecord struct:
struct PolicyData {
    uint8 policyType;   // 0 = WHITELIST, 1 = BLACKLIST, 2 = COMPOUND
    address admin;      // Policy administrator (zero for immutable compound policies)
}

struct PolicyRecord {
    PolicyData base;          // offset 0: base policy data
    CompoundPolicyData compound;  // offset 1: compound policy data (only used when policyType == COMPOUND)
}
This unified layout requires only 1 keccak computation + 2 SLOADs for compound policy authorization.

Interface Additions

interface ITIP403Registry {
    /// @notice Creates a new immutable compound policy
    /// @param senderPolicyId Policy ID to check for transfer senders
    /// @param recipientPolicyId Policy ID to check for transfer recipients
    /// @param mintRecipientPolicyId Policy ID to check for mint recipients
    /// @return newPolicyId ID of the newly created compound policy
    function createCompoundPolicy(
        uint64 senderPolicyId,
        uint64 recipientPolicyId,
        uint64 mintRecipientPolicyId
    ) external returns (uint64 newPolicyId);

    /// @notice Checks if a user is authorized as a sender under the given policy
    /// @param policyId Policy ID to check against
    /// @param user Address to check
    /// @return True if authorized to send, false otherwise
    function isAuthorizedSender(uint64 policyId, address user) external view returns (bool);

    /// @notice Checks if a user is authorized as a recipient under the given policy
    /// @param policyId Policy ID to check against
    /// @param user Address to check
    /// @return True if authorized to receive, false otherwise
    function isAuthorizedRecipient(uint64 policyId, address user) external view returns (bool);

    /// @notice Checks if a user is authorized as a mint recipient under the given policy
    /// @param policyId Policy ID to check against
    /// @param user Address to check
    /// @return True if authorized to receive mints, false otherwise
    function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool);

    /// @notice Returns the constituent policy IDs for a compound policy
    /// @param policyId ID of the compound policy to query
    /// @return senderPolicyId Policy ID for sender checks
    /// @return recipientPolicyId Policy ID for recipient checks
    /// @return mintRecipientPolicyId Policy ID for mint recipient checks
    function compoundPolicyData(uint64 policyId) external view returns (
        uint64 senderPolicyId,
        uint64 recipientPolicyId,
        uint64 mintRecipientPolicyId
    );
}

Authorization Logic

isAuthorizedSender

function isAuthorizedSender(uint64 policyId, address user) external view returns (bool) {
    PolicyRecord storage record = policyRecords[policyId];

    if (record.base.policyType == PolicyType.COMPOUND) {
        return isAuthorized(record.compound.senderPolicyId, user);
    }

    // For simple policies, sender authorization equals general authorization
    return isAuthorized(policyId, user);
}

isAuthorizedRecipient

function isAuthorizedRecipient(uint64 policyId, address user) external view returns (bool) {
    PolicyRecord storage record = policyRecords[policyId];

    if (record.base.policyType == PolicyType.COMPOUND) {
        return isAuthorized(record.compound.recipientPolicyId, user);
    }

    // For simple policies, recipient authorization equals general authorization
    return isAuthorized(policyId, user);
}

isAuthorized (updated)

The existing isAuthorized function is updated to check both sender and recipient authorization:
function isAuthorized(uint64 policyId, address user) external view returns (bool) {
    return isAuthorizedSender(policyId, user) && isAuthorizedRecipient(policyId, user);
}
This maintains backward compatibility: for simple policies both functions return the same result.

Required Code Changes

This TIP requires exactly 6 replacements of isAuthorized calls:

Direct Replacements

LocationCurrentReplace With
TIP-20 _mintisAuthorized(to)isAuthorizedMintRecipient(to)
TIP-20 burnBlockedisAuthorized(from)isAuthorizedSender(from)
DEX cancelStaleOrderisAuthorized(maker)isAuthorizedSender(maker)
Fee payer can_fee_payer_transferisAuthorized(fee_payer)isAuthorizedSender(fee_payer)

Core Authorization Logic

LocationCurrentReplace With
TIP-20 isTransferAuthorizedisAuthorized(from)isAuthorizedSender(from)
TIP-20 isTransferAuthorizedisAuthorized(to)isAuthorizedRecipient(to)

TIP-20 Integration

TIP-20 tokens MUST be updated to use the new sender/recipient authorization functions:

Transfer Authorization

function isTransferAuthorized(address from, address to) internal view returns (bool) {
    uint64 policyId = transferPolicyId;
    
    bool fromAuthorized = TIP403_REGISTRY.isAuthorizedSender(policyId, from);
    bool toAuthorized = TIP403_REGISTRY.isAuthorizedRecipient(policyId, to);
    
    return fromAuthorized && toAuthorized;
}

Mint Operations

function _mint(address to, uint256 amount) internal {
    if (!TIP403_REGISTRY.isAuthorizedMintRecipient(transferPolicyId, to)) {
        revert PolicyForbids();
    }
    // ... mint logic
}

Burn Blocked Operations

function burnBlocked(address from, uint256 amount) external {
    require(hasRole(BURN_BLOCKED_ROLE, msg.sender));
    
    // Only allow burning from addresses blocked from sending
    if (TIP403_REGISTRY.isAuthorizedSender(transferPolicyId, from)) {
        revert PolicyForbids();
    }
    // ... burn logic
}

Immutability

Compound policies are immutable once created. To change policy behavior, token issuers must:
  1. Create a new compound policy with the desired configuration
  2. Update the token’s transferPolicyId to the new policy

Backward Compatibility

This TIP is fully backward compatible:
  • Existing simple policies continue to work unchanged
  • Tokens using simple policies will see identical behavior
  • The existing isAuthorized function continues to work for both simple and compound policies

Invariants

  1. Simple Policy Constraint: All three policy IDs in a compound policy MUST reference simple policies (WHITELIST or BLACKLIST).
  2. Immutability: Once created, a compound policy’s constituent policy IDs cannot be changed.
  3. Existence Check: createCompoundPolicy MUST revert if any of the referenced policy IDs does not exist.
  4. Delegation Correctness: For simple policies, isAuthorizedSender(p, u) MUST equal isAuthorizedRecipient(p, u) MUST equal isAuthorizedMintRecipient(p, u).
  5. isAuthorized Equivalence: isAuthorized(p, u) MUST equal isAuthorizedSender(p, u) && isAuthorizedRecipient(p, u).
  6. Built-in Policy Compatibility: Compound policies MAY reference built-in policies (0 = always-reject, 1 = always-allow) as any of their constituent policies.