Skip to content
LogoLogo

TIP-1015: Compound Transfer Policies

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 that contains both base policy data and compound policy data:

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)
}

The TIP403Registry storage layout:

SlotFieldDescription
0policyIdCounterCounter for generating unique policy IDs
1policyRecords (private)mapping(uint64 => PolicyRecord) - Policy ID to policy record
2policySetmapping(uint64 => mapping(address => bool)) - Whitelist/blacklist membership

The policyRecords mapping is private (not exposed in the ABI). The existing policyData(uint64 policyId) view function provides backwards-compatible access to PolicyData.

For a given policy ID, storage locations are:

  • PolicyData: keccak256(policyId, 1) (offset 0 within PolicyRecord)
  • CompoundPolicyData: keccak256(policyId, 1) + 1 (offset 1 within PolicyRecord)

This unified layout requires only 1 keccak computation + 2 SLOADs for compound policy authorization, compared to 2 keccak computations with separate mappings.

Interface Additions

The TIP403Registry interface is extended with the following:

interface ITIP403Registry {
    // ... existing interface ...
 
    // =========================================================================
    //                      Compound Policy Creation
    // =========================================================================
 
    /// @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
    /// @dev All three policy IDs must reference existing simple policies (not compound).
    /// Compound policies are immutable - they cannot be modified after creation.
    /// Emits CompoundPolicyCreated event.
    function createCompoundPolicy(
        uint64 senderPolicyId,
        uint64 recipientPolicyId,
        uint64 mintRecipientPolicyId
    ) external returns (uint64 newPolicyId);
 
    // =========================================================================
    //                      Sender/Recipient Authorization
    // =========================================================================
 
    /// @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
    /// @dev For simple policies: equivalent to isAuthorized()
    /// For compound policies: checks against the senderPolicyId
    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
    /// @dev For simple policies: equivalent to isAuthorized()
    /// For compound policies: checks against the recipientPolicyId
    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
    /// @dev For simple policies: equivalent to isAuthorized()
    /// For compound policies: checks against the mintRecipientPolicyId
    function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool);
 
    // =========================================================================
    //                      Compound Policy Queries
    // =========================================================================
 
    /// @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
    /// @dev Reverts if policyId is not a compound policy
    function compoundPolicyData(uint64 policyId) external view returns (
        uint64 senderPolicyId,
        uint64 recipientPolicyId,
        uint64 mintRecipientPolicyId
    );
 
    // =========================================================================
    //                      Events
    // =========================================================================
 
    /// @notice Emitted when a new compound policy is created
    /// @param policyId ID of the newly created compound policy
    /// @param creator Address that created the policy
    /// @param senderPolicyId Policy ID for sender checks
    /// @param recipientPolicyId Policy ID for recipient checks
    /// @param mintRecipientPolicyId Policy ID for mint recipient checks
    event CompoundPolicyCreated(
        uint64 indexed policyId,
        address indexed creator,
        uint64 senderPolicyId,
        uint64 recipientPolicyId,
        uint64 mintRecipientPolicyId
    );
 
    // =========================================================================
    //                      Errors
    // =========================================================================
 
    /// @notice The referenced policy is not a simple policy
    error PolicyNotSimple();
 
    /// @notice The referenced policy does not exist
    error PolicyNotFound();
}

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);
}

isAuthorizedMintRecipient

function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool) {
    PolicyRecord storage record = policyRecords[policyId];
 
    if (record.base.policyType == PolicyType.COMPOUND) {
        return isAuthorized(record.compound.mintRecipientPolicyId, user);
    }
 
    // For simple policies, mint 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, so isAuthorized behaves identically to before. For compound policies, isAuthorized returns true only if the user is authorized as both sender and recipient.

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)

All other call sites use ensureTransferAuthorized(from, to) which delegates to isTransferAuthorized, so they automatically inherit the correct behavior:

  • TIP-20: transfer, transferFrom, transferWithMemo, systemTransferFrom
  • TIP-20 Rewards: distributeReward, setRewardRecipient, claimRewards
  • Stablecoin DEX: decrementBalanceOrTransferFrom, placeLimitOrder, swapExactAmountIn

TIP-20 Integration

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

Transfer Authorization (isTransferAuthorized)

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

Mint operations check the mint recipient policy:

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

Burn Blocked Operations

The burnBlocked function checks sender authorization to verify the address is blocked:

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
}

Stablecoin DEX Integration

Cancel Stale Order

The cancelStaleOrder function checks sender authorization on the token escrowed by the maker, since if the order is filled, the maker will have to send that token:

function cancelStaleOrder(uint128 orderId) external {
    Order order = orders[orderId];
    address token = order.isBid() ? book.quote : book.base;
    uint64 policyId = TIP20(token).transferPolicyId();
    
    // Order is stale if maker can no longer send the escrowed token
    if (TIP403_REGISTRY.isAuthorizedSender(policyId, order.maker())) {
        revert OrderNotStale();
    }
    
    _cancelOrder(order);
}

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 (since isAuthorizedSender and isAuthorizedRecipient return the same result for simple policies)
  • 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). Compound policies cannot reference other compound policies.

  2. Immutability: Once created, a compound policy's constituent policy IDs cannot be changed. The compound policy itself has no admin.

  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.

Test Cases

  1. Simple policy equivalence: Verify that for simple policies, all four authorization functions return the same result.

  2. Compound policy creation: Verify that compound policies can be created with valid simple policy references.

  3. Invalid creation: Verify that createCompoundPolicy reverts when referencing non-existent policies or compound policies.

  4. Sender/recipient differentiation: Verify that a compound policy with different sender/recipient policies correctly authorizes asymmetric transfers.

  5. isAuthorized behavior: Verify that isAuthorized on a compound policy returns isAuthorizedSender() && isAuthorizedRecipient().

  6. TIP-20 mint: Verify that mints check isAuthorizedMintRecipient, not isAuthorizedRecipient.

  7. TIP-20 burnBlocked: Verify that burnBlocked checks sender authorization (and allows burning from blocked senders).

  8. Vendor credits: Verify that a compound policy with mintRecipientPolicyId = 1 (always-allow), senderPolicyId = 1 (always-allow), and recipientPolicyId = vendor whitelist allows minting to anyone but only transfers to vendors.