diff --git a/tips/tip-1028.md b/tips/tip-1028.md new file mode 100644 index 0000000000..662c57a923 --- /dev/null +++ b/tips/tip-1028.md @@ -0,0 +1,497 @@ +--- +id: TIP-1028 +title: Address-Level Receive Policies +description: Extends TIP-403 with address-level receive policies, token filters, and receipt-based escrow for blocked TIP-20 transfers and mints. +authors: Mallesh Pai, 0xrusowsky, 0xKitsune +status: Draft +related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 +protocolVersion: TBD +--- + +# TIP-1028: Address-Level Receive Policies + +
+ +## Abstract + +TIP-1028 extends TIP-403 with address-level receive policies, letting a receiver control which TIP-20 tokens they accept and who can send them. + +When a receive policy blocks a TIP-20 transfer or mint, the operation still succeeds, but the funds go to `ESCROW_ADDRESS` instead of the receiver and an escrow receipt is recorded. + +The receiver or a designated recovery contract can later claim these funds. Claims back to the receiver resume the original transfer while claims to other addresses are treated as new transfers. + +TIP-403 authorization checks are unchanged and continue to revert on failure. TIP-1028 policies only apply to TIP-20 precompile flows. Ordinary contracts and other precompiles are unaffected. + +
+ +## Motivation + +TIP-403 allows token issuers to control who may use a token, but it does not give receivers control over which transfers or mints they accept. However, receivers may wish to restrict incoming funds based on the sender or the token. For example: + +- A regulated entity might wish to receive incoming transfers only from addresses held by individuals they have KYC'ed, +- Orchestrators and exchanges may wish to receive only specific tokens at their deposit addresses to prevent unrecoverable funds. + +If a receiver rejects transfers, sending them tokens will fail. This can cause swaps, payouts, and other operations to revert, even though the sender and token are valid. + +TIP-1028 lets receivers filter incoming transfers without causing those transfers to fail. If a receiver blocks a transfer or mint, the call still succeeds and the funds are sent to escrow instead. + +The receiver (or a recovery contract designated by the receiver) can claim those funds later. Each escrow entry keeps enough information to identify the original transfer, so it can be handled correctly offchain. + +
+ +# Specification + +TIP-1028 introduces three main changes: + +- The TIP-403 precompile is extended to add per-address receive policies that define which tokens and senders are allowed to send to a given address. Receive policies are configured via newly added `setReceivePolicy(...)` and enforced via `validateReceivePolicy(...)` functions. +- A new escrow precompile is introduced that holds blocked funds at `ESCROW_ADDRESS` and records a receipt for each blocked transfer or mint, which can be claimed later by the receiver or a recovery contract. +- TIP-20 transfer and mint flows are updated to check receive policies before crediting a receiver. The existing TIP-20 checks (pause, balance, allowance) and the token-level TIP-403 / TIP-1015 checks still run first and continue to revert on failure. Only a receive policy failure diverts the funds to the escrow precompile, where a receipt is recorded. + +The sequence diagram below shows the high level flow for a transfer that is blocked by a receive policy and later claimed from escrow. + +```mermaid +sequenceDiagram + participant Sender + participant TIP20 + participant TIP403 + participant Escrow + participant Receiver + + Receiver->>TIP403: setReceivePolicy(...) + Sender->>TIP20: transfer(receiver, amount) + TIP20->>TIP403: validateReceivePolicy(token, sender, receiver) + TIP403-->>TIP20: blocked + TIP20->>Escrow: storeBlocked(...) + TIP20-->>Sender: success (funds escrowed) + + Receiver->>Escrow: claimBlocked(...) + Escrow->>TIP20: release from ESCROW_ADDRESS + TIP20-->>Receiver: transfer funds +``` + +
+ +## TIP-20 Operations + +TIP-1028 applies to the following TIP-20 operations: `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, `systemTransferFrom`, `mint`, `mintWithMemo`. + +For each of these operations, TIP-20 checks the receiver's policy configuration before crediting them. It verifies whether the token is allowed by the receiver's token filter and whether the sender is allowed by the receiver's policy. For `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, and `systemTransferFrom`, the policy checks the `from` parameter as the sender. For `mint` and `mintWithMemo`, the policy checks `msg.sender` as the sender. + +If both checks pass, the transfer or mint proceeds as normal. If either check fails, the call still succeeds but the funds are sent to `ESCROW_ADDRESS` instead. + +Token level checks controlled by the issuer (`TIP-403` / `TIP-1015`) are unchanged and continue to revert on failure. + +Note that TIP-1028 only applies to the TIP-20 operations listed above. It does not apply to `approve`, `permit`, or `burn` and does not affect fee deposits or refunds via `transfer_fee_pre_tx` or `transfer_fee_post_tx`. Additionally, TIP-1028 does not interact with TIP-20 escrowed rewards or internal balances. + +### TIP-1022 Interaction + +If `to` is a TIP-1022 virtual address, it is resolved to its master address before any checks run. All receive policy checks use the master address. If resolution fails, the operation reverts as before. + +If the transfer or mint is allowed, it follows normal TIP-1022 forwarding behavior. If it is blocked, the receipt is recorded for the master address while preserving the original `to` for attribution. + +
+ +## Receive Policies + +A receive policy defines which transfers and mints an address accepts. It controls two things: + +- Which TIP-20 tokens are allowed. +- Which senders are allowed. + +Receive policies are stored per address in the TIP-403 registry. Conceptually, each account has: + +```text +ReceivePolicy(account) = ( + senderPolicyId, // TIP-403 policy ref indicating which senders are allowed + tokenFilterId, // TIP-403 policy ref indicating which TIP-20 tokens are allowed + recoveryContract +) +``` + +`recoveryContract` is optional. If not set, only the receiver can claim blocked funds directly. + +If no receive policy is set, all transfers and mints are allowed. + +Each address sets its receive policy using `setReceivePolicy(...)`. + +When a TIP-20 transfer or mint executes, it calls `validateReceivePolicy(token, sender, receiver)` on TIP-403. This checks the token against the receiver's token filter and the sender against the receiver's policy. + +If both checks pass, the transfer or mint proceeds as normal. If either check fails, the funds are sent to escrow. + +### Receive Policy Storage Layout + +TIP-403 stores receive policy configuration per address. + +```solidity +mapping(address => uint256) public addressReceiveConfig; +mapping(address => address) public addressRecoveryContract; +``` + +`addressReceiveConfig[account]` is a packed `uint256` with the following layout: + +| Bits | Size | Field | +|---|---:|---| +| `0` | 1 | `hasReceivePolicy` | +| `1..64` | 64 | `senderPolicyId` | +| `65..72` | 8 | `senderPolicyType` | +| `73..136` | 64 | `tokenFilterId` | +| `137..144` | 8 | `tokenFilterType` | +| `145..255` | 111 | reserved, MUST be zero | + +When `hasReceivePolicy == 0`, the address has no receive policy and all transfers and mints are allowed. The cached type fields are valid because policy type and token filter type are immutable after creation. + +`addressRecoveryContract[account]` stores the recovery contract for the address. If it is `address(0)`, the receiver claims blocked funds directly. + +`recoveryContract` is stored separately because a 160-bit address does not fit in the packed config slot. + +An address that wants to functionally disable filtering SHOULD set `senderPolicyId = 1` and `tokenFilterId = 1`. The slot remains allocated. + +Constraints on `setReceivePolicy(...)`: + +- `senderPolicyId` MUST reference a simple `WHITELIST` or `BLACKLIST` TIP-403 policy, or built-in policy `0` or `1`. `COMPOUND` policies are not valid. +- `tokenFilterId` MUST reference a simple `WHITELIST` or `BLACKLIST` TIP-403 policy, or built-in policy `0` or `1`. `COMPOUND` policies are not valid. +- The caller MUST NOT be a TIP-1022 virtual address. Virtual addresses are forwarding aliases and the user should instead configure receive policies on their resolved master address. + +
+ +## Sender Policies + +A receive policy points at an existing TIP-403 policy through `senderPolicyId`. The sender side of receive checks reuses TIP-403 policy evaluation directly. A receiver can reuse an existing simple TIP-403 `WHITELIST` or `BLACKLIST` policy, create a new one through the existing TIP-403 interface, or use built-in policy `0` (reject all) or `1` (allow all). + +`COMPOUND` policies are not valid for `senderPolicyId`. A receive check only asks one question: may this sender send to this receiver. A `COMPOUND` policy splits authorization across sender, transfer-recipient, and mint-recipient roles. Allowing `COMPOUND` would conflate those roles. + +
+ +## Token Filters + +Token filters use the existing TIP-403 `PolicyData` and policy membership set. A receive policy references one by `tokenFilterId`, and the policy's members are interpreted as TIP-20 token addresses. + +The registry does not need to validate that policy members are TIP-20 contracts. Non-token addresses in a token filter are inert configuration mistakes. + +
+ +## Receive Policy Evaluation + +`validateReceivePolicy(token, sender, receiver)` returns whether a transfer or mint to `receiver` is allowed and, if not, why. If the receiver has not configured a receive policy (`hasReceivePolicy == 0`), it returns `(true, NONE)`. + +Otherwise, evaluation uses a fixed order and short-circuits on the first failed check: + +1. Check `token` against the receiver's token filter. If this rejects, return `(false, TOKEN_FILTER)`. +2. Check `sender` against the receiver's receive policy. If this rejects, return `(false, RECEIVE_POLICY)`. +3. If both checks pass, return `(true, NONE)`. + +If both the token filter and sender policy would reject, `TOKEN_FILTER` is returned because the token filter is the first canonical check. + +The `BlockedReason` values used in the second return slot are: + +```solidity +enum BlockedReason { + NONE, + TOKEN_FILTER, + RECEIVE_POLICY +} +``` + +`NONE` is used only when the call is allowed. Blocked events MUST NOT use `NONE`. + +
+ +## Escrow Precompile + +The escrow precompile is a new system precompile that holds blocked funds and records a receipt for each blocked transfer or mint. It stores one keyed amount per open receipt. The rest of the receipt is emitted in the blocked event when the receipt is created and supplied as a witness at claim time. + +### Escrow Address + +```solidity +address constant ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000; +``` + +The blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot. + +### Restrictions on `ESCROW_ADDRESS` + +The following restrictions apply: + +- A TIP-20 transfer or mint with `to == ESCROW_ADDRESS` MUST revert with `EscrowAddressReserved()`. This applies to `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, `systemTransferFrom`, `mint`, and `mintWithMemo`. +- A reroute claim with `to == ESCROW_ADDRESS` MUST revert. +- `setReceivePolicy(...)` MUST reject `account == ESCROW_ADDRESS`. +- The TIP-20 `burnBlocked` function MUST revert when `from == ESCROW_ADDRESS`. Funds leave escrow only by claiming a receipt. Burning the escrow balance directly would leave receipts pointing at money that no longer exists. + +Without intervention, the first blocked transfer or mint for a token would pay an unexpected cold-sstore surcharge (~22,100 gas) for the zero-to-nonzero write to `balances[ESCROW_ADDRESS]`, charged to whichever user happens to trigger the first block. To avoid this, the TIP-20 initializer MUST charge the deployer a one-time fee equivalent to a cold sstore on the token's `balances[ESCROW_ADDRESS]` slot, and every subsequent write to `balances[ESCROW_ADDRESS]` for that token MUST be priced as a warm sstore. No tokens are minted to escrow at deployment and no implementation-private reserve is required; the cold cost is paid exactly once, at deploy time, by the issuer. + +### Escrow Model + +When a transfer or mint is blocked, the funds are credited to `ESCROW_ADDRESS` instead of the receiver. The escrow precompile records one receipt per blocked transfer or mint. Each receipt captures the full context of the blocked operation, including the original sender, the requested recipient, whether it was a transfer or mint, the memo, and the reason for blocking. This gives a claimer (or a recovery contract acting on the receiver's behalf) enough information to apply arbitrary rules when deciding whether and how to claim. + +The receipt is identified by a `receiptKey` derived from these fields. The escrow precompile only stores the keyed amount per receipt. The other fields are emitted in the blocked event when the receipt is created, and the claimer supplies them again as a witness at claim time. This keeps onchain state minimal while letting recovery logic stay flexible. + +### Escrow State + +```solidity +uint8 public constant BLOCKED_RECEIPT_VERSION = 1; +uint64 public blockedReceiptNonce = 1; +mapping(bytes32 => uint256) internal blockedReceiptAmount; +``` + +Each blocked transfer or mint is identified by a `receiptKey`. The format is versioned by `receiptVersion` so future TIPs can change the layout. v1 uses this receipt body: + +```solidity +struct ClaimReceiptV1 { + address originator; + address recipient; + uint64 blockedAt; + uint64 blockedNonce; + BlockedReason blockedReason; + InboundKind kind; + bytes32 memo; +} +``` + +The v1 `receiptKey` is computed as: + +```text +receiptKey = keccak256( + abi.encode( + receiptVersion, + token, + originator, + recipient, + recoveryContract, + blockedReason, + kind, + memo, + blockedAt, + blockedNonce + ) +) +``` + +where: + +- `receiptVersion`: one-byte outer discriminator. MUST be `1` for receipts created under this TIP. Future receipt-key formats MUST use a different value and MAY define a different version-specific receipt body. +- `token`: the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS`. +- `originator`: `from` for transfers, `msg.sender` for mints. +- `recipient`: the literal `to` supplied at the TIP-20 entrypoint. For non-virtual inbounds this is the receiver itself; for TIP-1022 inbounds this is the virtual alias. The canonical owner is derived at claim time as `tip1022.resolve(recipient)`, which is deterministic. +- `recoveryContract`: the receiver's recovery contract at the time the receipt was created, or `address(0)`. +- `blockedReason`: `TOKEN_FILTER` or `RECEIVE_POLICY`. +- `kind`: `TRANSFER` or `MINT`. +- `memo`: original memo for memo-bearing paths, `bytes32(0)` otherwise. +- `blockedAt`: block timestamp captured at receipt creation. +- `blockedNonce`: monotonically increasing global disambiguator assigned at receipt creation. + +`blockedReceiptAmount[receiptKey]` stores the full amount for that open receipt. + +Storing one fine-grained receipt per blocked transfer or mint is more expensive than aggregating, but it preserves the literal `recipient` for TIP-1022 attribution, the original `originator`, the `blockedAt`, the transfer-vs-mint distinction, and the memo and reason data needed for programmable recovery rules. The resolved master is intentionally not stored: it is a deterministic function of `recipient` and is recomputed when needed at claim time and offchain. Persistent state stays minimal: one keyed amount per receipt. The richer fields live in the witness and the blocked event. + +The escrow precompile does not enumerate receipts onchain. Claimers MUST supply the receipt witness for the receipt they want to consume, typically by indexing the blocked events offchain. + +### Storing Blocked Transfers and Mints + +When `validateReceivePolicy(...)` returns blocked, the TIP-20 path credits `ESCROW_ADDRESS` instead of the receiver, then calls `storeBlocked(...)`. The escrow precompile assigns the receipt's `blockedAt` and `blockedNonce`, computes `receiptKey`, sets `blockedReceiptAmount[receiptKey] = amount`, and returns the assigned `(blockedNonce, blockedAt)` to the caller. A blocked transfer emits the regular `Transfer` event naming `ESCROW_ADDRESS` as the recipient, then emits `TransferBlocked(...)`; a blocked mint emits the regular `Transfer` and `Mint` events naming `ESCROW_ADDRESS` as the recipient, then emits `MintBlocked(...)`. Memo-bearing variants preserve the original memo in the attribution event. + +`storeBlocked(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. User callers MUST NOT be able to fabricate receipts. Without this restriction, an attacker could mint synthetic receipts by replaying or fabricating witnesses without any backing escrow balance. + +### Claiming + +A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes `receiptVersion`, an encoded receipt witness, and a destination `to`. For `receiptVersion == 1`, it decodes the witness as `ClaimReceiptV1`, derives the canonical receiver as `tip1022.resolve(receipt.recipient)`, recomputes `receiptKey`, requires `blockedReceiptAmount[receiptKey] > 0`, zeroes the slot to free its storage, and releases the stored amount. Once consumed, a receipt is permanently retired: its `receiptKey` slot is empty and any subsequent claim against the same witness MUST revert with `InvalidReceiptClaim()`. Partial claims are not allowed. Claims MUST consume whole receipts. + +The release path inside the TIP-20 token debits `balances[ESCROW_ADDRESS]`, credits the destination, emits `Transfer(ESCROW_ADDRESS, to, amount)` followed by `BlockedReceiptClaimed(...)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. Releasing a previously blocked mint does not emit a fresh `Mint` event because the claim is a transfer out of escrow, not a new mint. + +The receipt witness only tells the escrow which receipt to consume. It does not grant any right to claim. Claim rights flow from the receiver or the snapshotted recovery contract. Blocked events are public, so anyone can build a valid witness, but only the authorized claimer may call `claimBlocked(...)`. + +Each receipt is governed by the `recoveryContract` captured for that receipt at block time. If the captured `recoveryContract` is `address(0)`, only `receiver` may call `claimBlocked(...)` for that receipt. If it is nonzero, only that contract may call it. Changing `addressRecoveryContract[receiver]` affects future receipts only. Existing receipts remain governed by the recovery contract captured in their key, so receivers that rotate `recoveryContract` SHOULD keep the previous contract callable until receipts keyed to it are drained. Any delegate whitelist, originator self-claim, multisig approval, timelock, or batching is the responsibility of the recovery contract. + +The destination `to` decides whether the claim **resumes** the original transfer back to the receiver or **reroutes** it to a new address. The two cases differ only in what they enforce on the destination side. + +When `to == receiver`, the claim resumes a previously authorized inbound. It bypasses the receiver's receive policy, token-level recipient authorization for the receiver, and AccountKeychain spending-limit metering. This is intentional. A blocked mint, for example, has already passed the token's original mint-recipient check on the inbound path. Releasing escrow back to the same receiver is not a new transfer and MUST NOT be rechecked against the recipient's receive policy. + +When `to != receiver`, the claim is a reroute and is treated as a new spend by the claimant. The reroute MUST reject `to == ESCROW_ADDRESS`. If `to` is a TIP-1022 virtual address, it is resolved to its master address before destination checks and final credit; if resolution fails, the call MUST revert with `ClaimDestinationUnauthorized()`. The reroute enforces token-level transfer-recipient authorization and receive policy checks against the resolved destination, using the consumed receipt's `originator` for the receive policy check. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. The `BlockedReceiptClaimed` event preserves the literal `to` supplied by the caller for attribution. If `recoveryContract == address(0)` and the reroute is initiated through an access key, the claim MUST meter the claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 spend by the receiver. If the receiver uses a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key-policy enforcement is the responsibility of that contract. + +The sequence diagram below shows the high level flow for a claim. + +```mermaid +sequenceDiagram + participant Claimer + participant Escrow + participant TIP20 + participant Destination + + Claimer->>Escrow: claimBlocked(receipt, to) + Escrow->>Escrow: check claimer is receiver or recoveryContract + Escrow->>Escrow: recompute receiptKey, require amount > 0 + Escrow->>Escrow: delete blockedReceiptAmount[receiptKey] + Escrow->>TIP20: release amount from ESCROW_ADDRESS to `to` + TIP20-->>Destination: transfer funds +``` + +For previously blocked virtual transfers, `receiver` is the resolved `master`, so resume releases directly to `master` without a forwarding leg. The original virtual target is preserved in the `BlockedReceiptClaimed.recipient` field for attribution. + +
+ +## Events and Errors + +### Receive Policy Events + +```solidity +event ReceivePolicyUpdated( + address indexed account, + uint64 senderPolicyId, + uint64 tokenFilterId, + address recoveryContract +); +``` + +### Token Filter Events + +Token filters use the existing TIP-403 policy events: `PolicyCreated`, `PolicyAdminUpdated`, `WhitelistUpdated`, and `BlacklistUpdated`. TIP-1028 does not add dedicated token-filter events. + +### Escrow Events + +```solidity +event TransferBlocked( + address indexed token, + address indexed from, + address indexed receiver, + uint8 receiptVersion, + uint64 blockedNonce, + uint64 blockedAt, + address recipient, + uint256 amount, + BlockedReason blockedReason, + address recoveryContract, + bytes32 memo +); + +event MintBlocked( + address indexed token, + address indexed operator, + address indexed receiver, + uint8 receiptVersion, + uint64 blockedNonce, + uint64 blockedAt, + address recipient, + uint256 amount, + BlockedReason blockedReason, + address recoveryContract, + bytes32 memo +); + +event BlockedReceiptClaimed( + address indexed token, + address indexed receiver, + uint8 receiptVersion, + uint64 indexed blockedNonce, + uint64 blockedAt, + address originator, + address recipient, + address recoveryContract, + address caller, + address to, + uint256 amount +); +``` + +### Errors + +```solidity +error InvalidReceivePolicyType(); +error InvalidRecoveryContract(); +error EscrowAddressReserved(); +error UnauthorizedClaimer(); +error InvalidReceiptClaim(); +error ClaimDestinationUnauthorized(); +error InsufficientEscrowBalance(); +``` + +A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. + +
+ +## Interfaces + +### TIP-403 Receive Policy Interface + +```solidity +interface IReceivePolicies { + function setReceivePolicy( + uint64 senderPolicyId, + uint64 tokenFilterId, + address recoveryContract + ) external; + + function receivePolicy(address account) + external + view + returns ( + bool hasReceivePolicy, + uint64 senderPolicyId, + PolicyType senderPolicyType, + uint64 tokenFilterId, + PolicyType tokenFilterType, + address recoveryContract + ); + + function validateReceivePolicy(address token, address sender, address receiver) + external + view + returns (bool authorized, BlockedReason blockedReason); +} +``` + +Implementations SHOULD read `addressRecoveryContract[receiver]` only after `validateReceivePolicy(...)` returns `authorized = false`. + +Token filters are managed through the existing TIP-403 policy interface. TIP-1028 does not add a dedicated token-filter interface. + +### Escrow Interface + +```solidity +interface IBlockedInboundEscrow { + enum InboundKind { + TRANSFER, + MINT + } + + struct ClaimReceiptV1 { + address originator; + address recipient; + uint64 blockedAt; + uint64 blockedNonce; + BlockedReason blockedReason; + InboundKind kind; + bytes32 memo; + } + + function blockedReceiptBalance( + address token, + address recoveryContract, + uint8 receiptVersion, + bytes calldata receipt + ) external view returns (uint256 amount); + + function claimBlocked( + address token, + address recoveryContract, + uint8 receiptVersion, + bytes calldata receipt, + address to + ) external; + + function storeBlocked( + address token, + address originator, + address receiver, + address recipient, + address recoveryContract, + uint256 amount, + BlockedReason blockedReason, + InboundKind kind, + bytes32 memo + ) external returns (uint64 blockedNonce, uint64 blockedAt); +} +``` + +
+ +## Invariants + +- For every TIP-20 token, `balances[ESCROW_ADDRESS]` equals exactly the sum of `blockedReceiptAmount[receiptKey]` over all open receipts for that token.