---
id: TIP-1015
title: Compound Transfer Policies
description: Extends TIP-403 with compound policies that specify different authorization rules for senders and recipients.
authors: Dan Robinson
status: Mainnet
related: TIP-403, TIP-20
protocolVersion: T2
---

# 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 structurally immutable once created — their constituent policy ID references cannot be changed. However, the referenced simple policies themselves remain mutable and can be modified by their respective admins, which will affect the compound policy's effective authorization behavior.

## 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:

```solidity
enum PolicyType {
    WHITELIST,
    BLACKLIST,
    COMPOUND
}
```

## Compound Policy Structure

A compound policy references three existing simple policies by their policy IDs:

```solidity
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:

```solidity
struct PolicyData {
    uint8 policyType;   // 0 = WHITELIST, 1 = BLACKLIST, 2 = COMPOUND
    address admin;      // Policy administrator (zero for compound policies — compound structure is immutable)
}

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:

| Slot | Field | Description |
|------|-------|-------------|
| 0 | `policyIdCounter` | Counter for generating unique policy IDs |
| 1 | `policyRecords` (private) | `mapping(uint64 => PolicyRecord)` - Policy ID to policy record |
| 2 | `policySet` | `mapping(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:

```solidity
interface ITIP403Registry {
    // ... existing interface ...

    // =========================================================================
    //                      Compound Policy Creation
    // =========================================================================

    /// @notice Creates a new compound policy (structurally immutable — references cannot be changed after creation)
    /// @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 policy references are immutable — the constituent policy IDs cannot be changed after creation.
    /// Note: the referenced simple policies themselves remain mutable by their admins.
    /// 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

```solidity
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

```solidity
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

```solidity
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:

```solidity
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

| Location | Current | Replace With |
|----------|---------|--------------|
| TIP-20 `_mint` | `isAuthorized(to)` | `isAuthorizedMintRecipient(to)` |
| TIP-20 `burnBlocked` | `isAuthorized(from)` | `isAuthorizedSender(from)` |
| DEX `cancelStaleOrder` | `isAuthorized(maker)` | `isAuthorizedSender(maker)` |
| Fee payer `can_fee_payer_transfer` | `isAuthorized(fee_payer)` | `isAuthorizedSender(fee_payer)` |

### Core Authorization Logic

| Location | Current | Replace With |
|----------|---------|--------------|
| TIP-20 `isTransferAuthorized` | `isAuthorized(from)` | `isAuthorizedSender(from)` |
| TIP-20 `isTransferAuthorized` | `isAuthorized(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)

```solidity
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:

```solidity
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:

```solidity
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:

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

## Mutability

Compound policies are **structurally immutable** once created — their constituent policy ID references cannot be changed, and they have no admin. However, the referenced simple policies remain independently mutable by their respective admins. Modifications to a referenced simple policy's whitelist or blacklist will immediately affect the authorization behavior of any compound policy that references it.

To change which simple policies a compound policy references, token issuers must:

1. Create a new compound policy with the desired configuration
2. Update the token's `transferPolicyId` to the new policy

To modify authorization behavior without changing the compound policy itself, the admin of a referenced simple policy can modify that simple policy's whitelist or blacklist directly.

## 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. **Structural Immutability**: Once created, a compound policy's constituent policy ID references cannot be changed. The compound policy itself has no admin. Note that the referenced simple policies remain mutable by their respective admins.

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.

7. **Non-existent Policy Revert**: All authorization functions (`isAuthorized`, `isAuthorizedSender`, `isAuthorizedRecipient`, `isAuthorizedMintRecipient`) MUST revert with `PolicyNotFound()` when called with a policy ID that does not exist. Built-in policies (0 and 1) always exist and are exempt from this check.

## 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.
