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