From ee7220e46950a3045b312867495568c0fc85495a Mon Sep 17 00:00:00 2001 From: Jennifer <5339211+jenpaff@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:52:40 +0000 Subject: [PATCH 01/59] docs(tip-1028): reintroduce Address-Level Receive Policies Amp-Thread-ID: https://ampcode.com/threads/T-019cfd21-479f-705d-8141-d61952cbed4d Co-authored-by: Amp --- tips/tip-1028.md | 642 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 tips/tip-1028.md diff --git a/tips/tip-1028.md b/tips/tip-1028.md new file mode 100644 index 0000000000..7a1e64fecd --- /dev/null +++ b/tips/tip-1028.md @@ -0,0 +1,642 @@ +--- +id: TIP-1028 +title: Address-Level Receive Policies +description: Extends TIP-403 with token sets and address-level receive policies, allowing individual addresses to control who can send to them and which tokens they can receive. +authors: Mallesh Pai +status: Draft +related: TIP-403, TIP-1015, TIP-20 +protocolVersion: TBD +--- + +# TIP-1028: Address-Level Receive Policies + +## Abstract + +This TIP extends the TIP-403 transfer policy system in three ways: + +1. **Token Sets** — a new TIP-403 primitive for managing sets of token addresses, distinct from policies (which manage sets of addresses). +2. **Address-level receive policies** (counterparty filter) — the ability for individual addresses to control who can send to them. +3. **Address-level token filtering** (token filter) — the ability for individual addresses to control which tokens they can receive. + +Currently, TIP-403 policies are set at the token level — each TIP-20 token has a single `transferPolicyId` that governs all transfers. This proposal adds the ability for individual addresses (EOAs or contracts) to set their own inbound controls across two dimensions: + +1. **Receive policy** (counterparty filter): Control who can send to this address +2. **Token set** (token filter): Control which tokens can be sent to this address + +This enables regulated entities (banks, exchanges, etc.) and orchestrator contracts to enforce inbound controls at the address level, independent of and in addition to token-level policies. + +Outbound send restrictions are explicitly out of scope. Controlling what an address can send is best handled by mechanisms like multisigs, offchain policy engines, or smart contract wallets — not protocol-level transfer hooks. + +## Motivation + +Regulated entities and orchestrator contracts operating on Tempo need the ability to control *inbound* transactions, regardless of what policies the token issuer has set. For example: + +- A bank may want to only receive funds from KYC'd addresses (whitelist on receives) +- A custodian may require that all incoming transfers come from approved counterparties +- **A regulated entity may only want to hold approved stablecoins** (whitelist on tokens, e.g. a 'MiCA'-only list) +- **An institution may need to block specific tokens** known to be associated with illicit activity or memecoins (blacklist on tokens) +- **An orchestrator contract** may want to restrict which tokens it can receive to those it can orchestrate + +The current TIP-403 system only supports token-level policies: the token issuer sets a policy, and all transfers of that token must satisfy it. This does not allow individual addresses to impose their own restrictions on who can send to them or which tokens they accept. + +### Why Token Sets? + +TIP-403 policies manage sets of *counterparty addresses* — they answer "is this address authorized?" Token filtering answers a fundamentally different question: "is this token authorized?" Reusing the policy primitive for both would conflate two distinct concepts. Token sets are a parallel primitive with identical mechanics (admin, type, membership) but a separate ID space and separate storage, keeping the semantics clean. + + +# Specification + +## Part 1: Token Sets (TIP-403 Extension) + +Token sets are a new primitive in the TIP-403 Registry for managing sets of token addresses. + +### Storage + +```solidity +uint64 public tokenSetIdCounter = 2; // Skip 0 (always-reject) and 1 (always-allow) + +struct TokenSetData { + PolicyType setType; // WHITELIST or BLACKLIST (not COMPOUND) + address admin; // Administrator who can modify membership +} + +mapping(uint64 => TokenSetData) internal _tokenSetData; +mapping(uint64 => mapping(address => bool)) internal tokenSetMembers; +``` + +Token set IDs occupy a separate ID space from policy IDs. ID 0 means "always-reject" and ID 1 means "always-allow", mirroring the policy convention. + +**Note on ID assignment**: Token set IDs are assigned sequentially from a global counter. When creating token sets in Forge scripts or simulations, be aware that the ID assigned during simulation may differ from the one assigned on-chain if other transactions create token sets in the interim. + +### Interface + +```solidity +/// @notice Creates a new token set +/// @param admin The address authorized to modify this token set +/// @param setType The type of token set (WHITELIST or BLACKLIST, not COMPOUND) +/// @return newTokenSetId The ID of the newly created token set +function createTokenSet(address admin, PolicyType setType) + external returns (uint64 newTokenSetId); + +/// @notice Creates a new token set with initial token members +/// @param admin The address authorized to modify this token set +/// @param setType The type of token set (WHITELIST or BLACKLIST, not COMPOUND) +/// @param tokens The initial token addresses to add +/// @return newTokenSetId The ID of the newly created token set +function createTokenSetWithTokens( + address admin, + PolicyType setType, + address[] calldata tokens +) external returns (uint64 newTokenSetId); + +/// @notice Updates the admin address for a token set +/// @dev Can only be called by the current admin of the token set +/// @param tokenSetId The ID of the token set to update +/// @param admin The new admin address +function setTokenSetAdmin(uint64 tokenSetId, address admin) external; + +/// @notice Adds or removes a token from a whitelist token set +/// @param tokenSetId The ID of the whitelist token set +/// @param token The token address to update +/// @param allowed Whether to allow (true) or disallow (false) the token +function modifyTokenSetWhitelist(uint64 tokenSetId, address token, bool allowed) external; + +/// @notice Adds or removes a token from a blacklist token set +/// @param tokenSetId The ID of the blacklist token set +/// @param token The token address to update +/// @param restricted Whether to restrict (true) or unrestrict (false) the token +function modifyTokenSetBlacklist(uint64 tokenSetId, address token, bool restricted) external; + +/// @notice Checks if a token is authorized under a token set +/// @param tokenSetId The ID of the token set to check against +/// @param token The token address to check +/// @return True if the token is authorized +function isTokenAuthorized(uint64 tokenSetId, address token) external view returns (bool); + +/// @notice Returns whether a token set exists +/// @param tokenSetId The ID of the token set to check +/// @return True if the token set exists +function tokenSetExists(uint64 tokenSetId) external view returns (bool); + +/// @notice Returns the data for a given token set +/// @param tokenSetId The ID of the token set to query +/// @return setType The type of the token set (whitelist or blacklist) +/// @return admin The admin address of the token set +function tokenSetData(uint64 tokenSetId) + external view returns (PolicyType setType, address admin); +``` + +### Events + +```solidity +/// @notice Emitted when a new token set is created +event TokenSetCreated(uint64 indexed tokenSetId, address indexed creator, PolicyType setType); + +/// @notice Emitted when a token set's admin is updated +event TokenSetAdminUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed admin); + +/// @notice Emitted when a whitelist token set is modified +event TokenSetWhitelistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool allowed); + +/// @notice Emitted when a blacklist token set is modified +event TokenSetBlacklistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool restricted); +``` + +### Errors + +```solidity +/// @notice Error when referencing a token set that does not exist +error TokenSetNotFound(); + +/// @notice Error when creating a compound token set (not allowed) +error InvalidTokenSetType(); +``` + +### Authorization Logic + +```solidity +function isTokenAuthorized(uint64 tokenSetId, address token) public view returns (bool) { + // Special case: built-in token sets + if (tokenSetId < 2) { + return tokenSetId == 1; // 0 = always-reject, 1 = always-allow + } + + TokenSetData memory data = _tokenSetData[tokenSetId]; + + return data.setType == PolicyType.WHITELIST + ? tokenSetMembers[tokenSetId][token] + : !tokenSetMembers[tokenSetId][token]; +} +``` + +--- + +## Part 2: Address-Level Receive Policies + +### Overview + +The TIP-403 Registry is extended with a new mapping that allows any address to set its own inbound controls: + +- **Receive policy**: Controls which addresses can send tokens to this address (references a TIP-403 policy) +- **Token set**: Controls which tokens this address can receive (references a TIP-403 token set) + +These controls are checked on every TIP-20 transfer and mint in addition to the existing token-level policy check. Since TIP-20 is implemented as a precompile, a hardfork that adds this functionality will apply to all tokens automatically. + +### Storage Layout + +```solidity +mapping(address => uint256) public addressPolicies; +``` + +The `addressPolicies` mapping packs a presence flag, policy IDs, token set IDs, and their cached types into a single 256-bit storage slot: + +| Bits (inclusive) | Size | Field | Description | +|------------------|------|-------|-------------| +| 0–63 | 64 bits | `receivePolicyId` | TIP-403 policy checked when this address is the receiver | +| 64–71 | 8 bits | `receivePolicyType` | Cached type of receive policy (0 = whitelist, 1 = blacklist) | +| 72–135 | 64 bits | `tokenSetId` | TIP-403 token set checked on tokens being received | +| 136–143 | 8 bits | `tokenSetType` | Cached type of token set (0 = whitelist, 1 = blacklist) | +| 144–254 | 111 bits | Reserved | For future use (must be 0) | +| 255 | 1 bit | `hasReceivePolicy` | 1 if address has set receive policies, 0 if not | + +When `hasReceivePolicy` is 0 (the default for all uninitialized storage), no address-level controls are set and the address is always authorized at the address level. The remaining fields are ignored. + +The receive policy MUST reference a simple policy (WHITELIST or BLACKLIST) or a built-in policy (0 or 1), not a compound policy. Compound policies split sender/recipient authorization, but the address-level receive policy always checks the sender against a single list — compound semantics would be meaningless here. + +### Why the Bit Flag? + +The bit flag keeps policy ID semantics consistent everywhere — 0 always means "always-reject", 1 always means "always-allow" — and provides an unambiguous way to distinguish "no controls set" from "controls set with always-reject". + +### Why Embed Types? + +TIP-403 policy types and token set types are **immutable** — set at creation and never changed. By embedding the type in the address storage, we avoid an SLOAD to `policyData[policyId]` or `tokenSetData[tokenSetId]` on every authorization check, saving ~2,100 gas per check. + +When `setAddressReceivePolicy` is called, the types are read from the registry once and cached in the address's storage. Since types cannot change, these cached values remain valid forever. + +### Encoding + +```solidity +function encodeAddressPolicies( + uint64 receivePolicyId, + PolicyType receivePolicyType, + uint64 tokenSetId, + PolicyType tokenSetType +) internal pure returns (uint256) { + return uint256(1) // hasReceivePolicy = 1 + | (uint256(receivePolicyId) << 1) + | (uint256(uint8(receivePolicyType)) << 65) + | (uint256(tokenSetId) << 73) + | (uint256(uint8(tokenSetType)) << 137); +} + +function decodeAddressPolicies(uint256 packed) + internal pure returns ( + bool hasReceivePolicy, + uint64 receivePolicyId, + PolicyType receivePolicyType, + uint64 tokenSetId, + PolicyType tokenSetType + ) +{ + hasReceivePolicy = (packed & 1) == 1; + receivePolicyId = uint64(packed >> 1); + receivePolicyType = PolicyType(uint8(packed >> 65)); + tokenSetId = uint64(packed >> 73); + tokenSetType = PolicyType(uint8(packed >> 137)); +} +``` + +### Interface Extensions + +The following functions are added to the TIP-403 Registry: + +```solidity +/// @notice Sets the receive policy and token set for the caller's address +/// @param receivePolicyId TIP-403 policy to check when caller receives +/// @param tokenSetId TIP-403 token set to check on tokens being received +/// @dev Receive policy must be simple (WHITELIST/BLACKLIST) or built-in (0/1), not COMPOUND. +/// @dev Types are cached from the registry at set time (immutable, so always valid). +/// @dev Sets the hasReceivePolicy flag to 1. +function setAddressReceivePolicy( + uint64 receivePolicyId, + uint64 tokenSetId +) external; + +/// @notice Clears all receive controls for the caller's address +/// @dev Sets the entire addressPolicies slot to 0 (hasReceivePolicy = 0). +function clearAddressReceivePolicy() external; + +/// @notice Returns the address-level receive controls for an address +/// @param account The address to query +/// @return hasReceivePolicy Whether the address has receive policies set +/// @return receivePolicyId The policy checked when address receives +/// @return receivePolicyType The type of the receive policy +/// @return tokenSetId The token set checked on tokens being received +/// @return tokenSetType The type of the token set +function getAddressReceivePolicy(address account) + external view returns ( + bool hasReceivePolicy, + uint64 receivePolicyId, + PolicyType receivePolicyType, + uint64 tokenSetId, + PolicyType tokenSetType + ); + +/// @notice Checks if a transfer or mint is authorized under the receiver's address-level controls +/// @param from The sender address (for transfer) or minter address (for mints) +/// @param to The receiver address +/// @param token The token contract address being transferred +/// @return True if the transfer is authorized under the receiver's controls +function isAddressTransferAuthorized(address from, address to, address token) + external view returns (bool); +``` + +### Events + +```solidity +/// @notice Emitted when an address updates its receive policy +/// @param account The address that updated its controls +/// @param receivePolicyId The new receive policy +/// @param tokenSetId The new token set +event AddressReceivePolicyUpdated( + address indexed account, + uint64 receivePolicyId, + uint64 tokenSetId +); + +/// @notice Emitted when an address clears its receive policy +/// @param account The address that cleared its controls +event AddressReceivePolicyCleared(address indexed account); +``` + +### Errors + +```solidity +/// @notice Error when setting a policy that does not exist +error PolicyNotFound(); + +/// @notice Error when setting a token set that does not exist +error TokenSetNotFound(); + +/// @notice Error when setting a compound policy as the receive policy +error CompoundPolicyNotAllowed(); +``` + +### Authorization Logic + +#### setAddressReceivePolicy + +```solidity +function setAddressReceivePolicy( + uint64 receivePolicyId, + uint64 tokenSetId +) external { + PolicyType receivePolicyType; + PolicyType tokenSetType; + + // Validate and cache receive policy type + if (receivePolicyId >= 2) { + if (!policyExists(receivePolicyId)) revert PolicyNotFound(); + (receivePolicyType, ) = policyData(receivePolicyId); + if (receivePolicyType == PolicyType.COMPOUND) revert CompoundPolicyNotAllowed(); + } + // receivePolicyId 0 (always-reject) and 1 (always-allow) are valid built-ins + + // Validate and cache token set type + if (tokenSetId >= 2) { + if (!tokenSetExists(tokenSetId)) revert TokenSetNotFound(); + (tokenSetType, ) = tokenSetData(tokenSetId); + } + // tokenSetId 0 (always-reject) and 1 (always-allow) are valid built-ins + + addressPolicies[msg.sender] = encodeAddressPolicies( + receivePolicyId, receivePolicyType, + tokenSetId, tokenSetType + ); + + emit AddressReceivePolicyUpdated(msg.sender, receivePolicyId, tokenSetId); +} +``` + +#### clearAddressReceivePolicy + +```solidity +function clearAddressReceivePolicy() external { + addressPolicies[msg.sender] = 0; + emit AddressReceivePolicyCleared(msg.sender); +} +``` + +#### getAddressReceivePolicy + +```solidity +function getAddressReceivePolicy(address account) + external view returns ( + bool hasReceivePolicy, + uint64 receivePolicyId, PolicyType receivePolicyType, + uint64 tokenSetId, PolicyType tokenSetType + ) +{ + return decodeAddressPolicies(addressPolicies[account]); +} +``` + +#### isAddressTransferAuthorized + +This function checks the **receiver's** controls only — the sender has no address-level policy checked. Types are embedded in `addressPolicies`, so no extra SLOADs for `policyData` or `tokenSetData`. + +```solidity +function isAddressTransferAuthorized(address from, address to, address token) + external view returns (bool) +{ + uint256 packed = addressPolicies[to]; + + // Fast path: no controls set (bit 0 = 0) + if (packed & 1 == 0) return true; + + // Decode receiver's controls + ( + , + uint64 receivePolicy, + PolicyType receiveType, + uint64 tokenSet, + PolicyType tokenSetType + ) = decodeAddressPolicies(packed); + + // Check receive policy: "who can send to me?" + if (receivePolicy < 2) { + // Built-in: 0 = always-reject, 1 = always-allow + if (receivePolicy == 0) return false; + } else { + bool inSet = policySet[receivePolicy][from]; + bool authorized = (receiveType == PolicyType.WHITELIST) ? inSet : !inSet; + if (!authorized) return false; + } + + // Check token set: "which tokens can I receive?" + if (tokenSet < 2) { + // Built-in: 0 = always-reject, 1 = always-allow + if (tokenSet == 0) return false; + } else { + bool inSet = tokenSetMembers[tokenSet][token]; + bool authorized = (tokenSetType == PolicyType.WHITELIST) ? inSet : !inSet; + if (!authorized) return false; + } + + return true; +} +``` + + + +## Integration with TIP-20 + +### Transfer Authorization + +The `isTransferAuthorized` check in TIP-20 is updated to check both token-level and address-level controls. Token-level checks use the TIP-1015 sender/recipient functions: + +```solidity +function isTransferAuthorized(address from, address to) internal view returns (bool) { + uint64 policyId = transferPolicyId; + + // Token-level policy check (TIP-1015 compound-aware) + bool fromAuthorized = TIP403_REGISTRY.isAuthorizedSender(policyId, from); + bool toAuthorized = TIP403_REGISTRY.isAuthorizedRecipient(policyId, to); + if (!fromAuthorized || !toAuthorized) return false; + + // Address-level receive check (new) — receiver's counterparty + token controls + return TIP403_REGISTRY.isAddressTransferAuthorized(from, to, address(this)); +} +``` + +All functions that call `ensureTransferAuthorized(from, to)` automatically inherit address-level checks: + +- `transfer(address to, uint256 amount)` +- `transferFrom(address from, address to, uint256 amount)` +- `transferWithMemo(address to, uint256 amount, bytes32 memo)` +- `transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo)` +- `systemTransferFrom(address from, address to, uint256 amount)` + +### Mint Behavior + +Minting checks both the token-level policy AND the recipient's full address-level controls (receive policy and token set). This is important because TIP-20 creation is permissionless — anyone can create a token and attempt to mint to any address. + + +### Burn and burnBlocked Behavior + +Burning (`burn`, `burnBlocked`) does not deliver tokens to a new recipient, so address-level controls are not applicable. The existing behavior is unchanged. + +### Self-Transfers + +Self-transfers (`from == to`) are treated as normal transfers and are subject to the receive policy check. If an address sets a whitelist receive policy, it SHOULD include itself if self-transfers are needed. + +### Fee Transfers + +Fee transfers via `transferFeePreTx` and `transferFeePostTx` bypass address-level policy checks since they are system operations that directly manipulate balances without going through `ensureTransferAuthorized`. The fee collection path (`collect_fee_pre_tx`) checks token-level `ensureTransferAuthorized(fee_payer, FeeManager)` but does NOT check address-level controls on the FeeManager address — this is correct because FeeManager is a system precompile, not an address that opts into receive policies. + +### Rewards + +TIP-20 rewards involve three operations that move tokens: + +- **`distributeReward(amount)`**: Transfers tokens from the caller to the token contract's own address. This goes through `ensureTransferAuthorized(msg.sender, tokenAddress)`, which will now include address-level checks on the token contract address as receiver. Since token contract addresses do not set address-level policies, this adds only the baseline SLOAD cost (~2,100 gas). + +- **`setRewardRecipient(recipient)`**: Does not transfer tokens — it only changes the delegation mapping. It calls `ensureTransferAuthorized(msg.sender, recipient)` to verify the delegation is policy-compliant. This will now also check the recipient's address-level receive policy. If the recipient has a whitelist receive policy, the holder must be on it. This is correct — a regulated entity's receive policy should govern who can delegate rewards to them. + +- **`claimRewards()`**: Transfers accumulated rewards from the token contract to the caller. This calls `ensureTransferAuthorized(tokenAddress, msg.sender)`, which will now check the caller's address-level receive policy (is the token contract authorized to send to me?) and token set (is this token authorized?). This means a regulated entity that has a receive policy whitelist must include the token contract address to claim rewards, and must have the token in their token set. This is correct — claiming rewards is receiving tokens, and should respect the receiver's controls. + +## Integration with Stablecoin DEX + +### Swaps and Order Fills + +DEX swaps (`swapExactAmountIn`, `swapExactAmountOut`) transfer the output token to the sender at the end of the swap via `TIP20.transfer(DEX_ADDRESS, sender, amount)`. This goes through `ensureTransferAuthorized`, which will now check the sender's (as receiver of output) address-level controls: + +- **Receive policy**: The sender must be authorized under their own receive policy with the DEX address as counterparty. Since the DEX is a system precompile with a fixed address, addresses with whitelist receive policies must include the DEX address to use swaps. +- **Token set**: The output token must be in the sender's token set (if set). This means a regulated entity cannot accidentally receive unauthorized tokens via a swap — the token set check will reject it. + +Order fills (`partial_fill_order`, `fill_order`) credit tokens to the maker's *DEX internal balance* via `increment_balance`, which does not trigger TIP-20 transfers. Address-level controls are NOT checked on internal balance credits. Controls are checked when the maker later calls `withdraw` to move tokens out of the DEX to their wallet (see below). + +> **DEX Internal Balance Bypass**: This is an intentional design choice, not a loophole. The DEX's internal accounting is an escrow mechanism — tokens are locked in the DEX contract, not delivered to the maker's wallet. Address-level controls govern what enters an address's wallet, and the `withdraw` path enforces this. Attempting to enforce controls on internal balance credits would break order matching. + +### Order Placement + +`place` and `place_flip` call `ensureTransferAuthorized` in two directions: + +1. `ensureTransferAuthorized(sender, DEX_ADDRESS)` for the escrow token (sender sends to DEX) +2. `ensureTransferAuthorized(DEX_ADDRESS, sender)` for the non-escrow token (DEX will eventually send to sender when the order fills) + +Check (2) will now include address-level controls on the sender as receiver. This is correct — it validates upfront that the sender's receive policy and token set would allow receiving the output token from the DEX when the order is eventually filled. This prevents placing orders that would be unfillable due to the maker's own receive controls. + +### Withdraw + +`withdraw(token, amount)` transfers tokens from the DEX internal balance to the user's wallet via `TIP20.transfer(DEX_ADDRESS, user, amount)`. This goes through `ensureTransferAuthorized(DEX_ADDRESS, user)`, which will now check the user's address-level controls. A user with a token set whitelist can only withdraw tokens that are in their token set. + + + +## Integration with Fee Manager + +### Fee Collection + +`collect_fee_pre_tx` calls `ensureTransferAuthorized(fee_payer, FeeManager)` followed by `transfer_fee_pre_tx` which directly manipulates balances. The `ensureTransferAuthorized` check will now include the FeeManager's address-level controls (which FeeManager, being a system precompile does not set). + +### Fee Distribution + +`distribute_fees` transfers accumulated fees from FeeManager to the validator via `TIP20.transfer(FeeManager, validator, amount)`. This goes through `ensureTransferAuthorized(FeeManager, validator)`, which will now check the validator's address-level controls. Validators with receive policies must include the FeeManager address. Validators with token set whitelists must include their fee token. In practice, validators choose their fee token preference via `setValidatorToken`, so this should be naturally consistent. + +### Fee Refunds + +`collect_fee_post_tx` refunds unused fees directly via balance manipulation (not through `ensureTransferAuthorized`), so address-level controls do not apply to refunds. + +## Gas Cost Analysis + +### Baseline Cost Discussion + +This TIP introduces the **first per-address persistent storage read** (`addressPolicies[to]`) on the TIP-20 transfer hot path. Every transfer incurs a ~2,100 gas cold SLOAD to check whether the receiver has set address-level controls, even if they have not. This is a new class of per-transfer overhead — existing features like access keys and TIP-403 membership either short-circuit via transient storage or are keyed by policy ID rather than by address. + +The ~2,100 gas overhead (~5% of a baseline transfer) is accepted as the cost of enabling address-level receive controls. Mitigating factors: +- The SLOAD is in the TIP-403 registry contract, which is already loaded into the warm account set during token-level policy checks +- Subsequent transfers to the same recipient within the same transaction cost only ~100 gas (warm SLOAD) +- If a future protocol change adds per-address fields to the account trie (e.g., for account abstraction metadata), this flag could migrate there for zero marginal cost +- The cost is justified by enabling regulated entities and orchestrators to enforce inbound controls without resorting to wrapper contracts or offchain enforcement + +### Per-Transfer Overhead (Incremental) + +| Scenario | Additional Gas | +|----------|----------------| +| Receiver has no controls set | ~2,100 gas (1 SLOAD for addressPolicies[to]) | +| Receiver has receive policy only | ~4,200 gas (+1 policySet SLOAD) | +| Receiver has token set only | ~4,200 gas (+1 tokenSetMembers SLOAD) | +| Receiver has receive policy + token set | ~6,300 gas (+2 SLOADs) | + + +### State Creation Costs + +Per TIP-1000, Tempo charges 250,000 gas for new state element creation (SSTORE zero→non-zero). + +| Operation | Gas Cost | +|-----------|----------| +| First call to `setAddressReceivePolicy` (creates slot) | ~250,000 gas | +| Subsequent updates to address receive policy | ~5,000 gas | +| Clearing address receive policy (non-zero→zero) | ~5,000 gas (with 4,800 gas refund per EIP-3529) | +| Adding address to policy membership | ~250,000 gas | +| Adding token to token set membership | ~250,000 gas | + +## System Precompile Addresses and Whitelists + +Addresses that set a **whitelist** receive policy must include system precompile addresses as authorized senders if they want to participate in protocol operations that deliver tokens from those precompiles: + +| Operation | Requires whitelisting | +|-----------|----------------------| +| DEX swaps (`swapExactAmountIn/Out`) | DEX precompile address | +| DEX withdrawals | DEX precompile address | +| Fee distribution to validators | FeeManager precompile address | +| Reward claims (`claimRewards`) | Token contract address | +| Reward delegation (`setRewardRecipient`) | Delegating holder's address | + +Addresses using **blacklist** receive policies or **token set only** controls (with `receivePolicyId = 1`) are not affected — they allow all senders by default. + +## Shared Policy and Token Set Semantics + +An address can reference **any existing TIP-403 policy** for receive controls and **any existing TIP-403 token set** for token filtering, including those administered by other addresses. This enables shared compliance lists (e.g., a compliance provider publishes a "MiCA-approved tokens" token set that multiple institutions reference). + +--- + +# Invariants + +The following invariants must always hold: + +## Token Set Invariants + +1. **Token Set Type Restriction**: Token sets MUST have type WHITELIST or BLACKLIST. COMPOUND is not a valid token set type. + +2. **Token Set Built-in Semantics**: Token set ID 0 MUST be "always-reject" and token set ID 1 MUST be "always-allow", mirroring the policy convention. + +3. **Token Set Immutable Type**: Once created, a token set's type cannot be changed. Only membership can be modified by the admin. + +## Address Policy Invariants + +4. **Address Sovereignty**: Only an address itself can set its own receive policy via `setAddressReceivePolicy` or clear it via `clearAddressReceivePolicy`. No address can modify another address's controls. + +5. **Policy Existence**: `setAddressReceivePolicy` MUST revert if `receivePolicyId >= 2` and does not correspond to an existing policy. It MUST revert if `tokenSetId >= 2` and does not correspond to an existing token set. Built-in IDs (0 and 1) are always valid. + +6. **Simple Policy Constraint**: `setAddressReceivePolicy` MUST revert if `receivePolicyId` references a compound policy (type COMPOUND). The receive policy must be simple (WHITELIST or BLACKLIST) or a built-in (0 or 1). + +7. **Bit Flag Semantics**: When `hasReceivePolicy` (bit 0 of `addressPolicies`) is 0, the address has no address-level controls and is always authorized. When `hasReceivePolicy` is 1, the `receivePolicyId` and `tokenSetId` use standard TIP-403 semantics (0 = always-reject, 1 = always-allow). The `hasReceivePolicy` flag is set to 1 by `setAddressReceivePolicy` and cleared to 0 by `clearAddressReceivePolicy`. + +8. **Composable Authorization (Transfers)**: A transfer is authorized if and only if ALL of the following are true: + - Token-level policy authorizes `from` as sender: `isAuthorizedSender(token.transferPolicyId, from)` + - Token-level policy authorizes `to` as recipient: `isAuthorizedRecipient(token.transferPolicyId, to)` + - Address-level controls pass: `to.hasReceivePolicy == false`, OR both: + - Receive policy authorizes sender: `receivePolicyId` is 1 (always-allow), or `from` is in/not-in the policy set per the policy type + - `isTokenAuthorized(to.tokenSetId, token)` returns true + +9. **Composable Authorization (Mints)**: A mint is authorized if and only if ALL of the following are true: + - Token-level policy authorizes `to` as mint recipient: `isAuthorizedMintRecipient(token.transferPolicyId, to)` + - Address-level controls pass: `to.hasReceivePolicy == false`, OR both: + - Receive policy authorizes minter: `receivePolicyId` is 1 (always-allow), or `minter` is in/not-in the policy set per the policy type + - `isTokenAuthorized(to.tokenSetId, token)` returns true + +10. **Mint Policy Check**: Minting operations MUST check both the recipient's receive policy (against the minter) and token set (against the token). This ensures a sanctioned party cannot bypass address-level controls by minting directly instead of transferring. + +11. **Burn Exemption**: Burn operations (`burn`, `burnBlocked`) MUST NOT check address-level controls. + +12. **Fee Transfer Exemption**: Direct balance manipulations (`transferFeePreTx`, `transferFeePostTx`) MUST NOT check address-level controls. Fee distribution via `distribute_fees` goes through `ensureTransferAuthorized` and DOES check the validator's address-level controls. + +13. **Receiver-Only Enforcement**: Address-level controls are checked ONLY on the receiver (`to`). The sender's `addressPolicies` slot is never read during authorization. + +14. **DEX Internal Balances**: DEX order fills that credit tokens to a maker's internal DEX balance (`increment_balance`) do NOT trigger address-level checks. Controls are enforced when tokens leave the DEX via `withdraw` or `transfer`. + +15. **Rewards Compliance**: `claimRewards` goes through `ensureTransferAuthorized(tokenAddress, caller)` and MUST check the caller's address-level controls. `setRewardRecipient(recipient)` goes through `ensureTransferAuthorized(holder, recipient)` and MUST check the recipient's address-level receive policy. + +16. **Storage Efficiency**: Address policies MUST be stored in a single storage slot per address (packed flag, IDs, and types into 256 bits). + +17. **Gas Consistency**: Reading `addressPolicies[address]` for a non-existent entry MUST return 0 (bit 0 = 0, interpreted as no controls set), incurring only the cold SLOAD cost (~2,100 gas). + +18. **Cached Type Validity**: The types cached in `addressPolicies` MUST always match the types in `policyData[policyId]` and `tokenSetData[tokenSetId]`. This is guaranteed because types are immutable after creation. + +19. **Immutability Assumption**: This TIP assumes TIP-403 policies and token sets are never deleted and types are immutable. Any future TIP that allows deletion or type modification MUST address cache invalidation. + +20. **Self-Transfer Behavior**: Self-transfers (`from == to`) are subject to the receiver's receive policy (checking self as sender). Addresses using whitelist receive policies SHOULD include themselves if self-transfers are required. + +21. **System Precompile Baseline Cost**: System precompile addresses (FeeManager, DEX, token contracts) do not set address-level policies. When they appear as `to` in `isAddressTransferAuthorized`, the `addressPolicies[to]` SLOAD returns 0 (bit 0 = 0) and the check passes immediately. This adds only the baseline ~2,100 gas cold SLOAD cost. + +22. **Token Set Admin-Only Modification**: Only the token set's admin can modify membership (`modifyTokenSetWhitelist`, `modifyTokenSetBlacklist`) and transfer admin (`setTokenSetAdmin`). These functions MUST revert if called by any other address. From 7263b5df22ed0a6132609bfff2464d196468bc6a Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:27:29 -0400 Subject: [PATCH 02/59] docs(tip-1028): revise blocked inbound design Updates TIP-1028 to use escrowed handling for blocked TIP-20 inbounds, clarify claim and recovery semantics, and document Tempo-specific integration behavior. Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 1001 +++++++++++++++++++++++++--------------------- 1 file changed, 539 insertions(+), 462 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 7a1e64fecd..460f2b280d 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -1,10 +1,10 @@ --- id: TIP-1028 title: Address-Level Receive Policies -description: Extends TIP-403 with token sets and address-level receive policies, allowing individual addresses to control who can send to them and which tokens they can receive. +description: Extends TIP-403 with token sets, address-level receive controls, and escrowed handling for blocked TIP-20 inbounds. authors: Mallesh Pai status: Draft -related: TIP-403, TIP-1015, TIP-20 +related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 protocolVersion: TBD --- @@ -12,631 +12,708 @@ protocolVersion: TBD ## Abstract -This TIP extends the TIP-403 transfer policy system in three ways: +This TIP extends TIP-403 in three ways: -1. **Token Sets** — a new TIP-403 primitive for managing sets of token addresses, distinct from policies (which manage sets of addresses). -2. **Address-level receive policies** (counterparty filter) — the ability for individual addresses to control who can send to them. -3. **Address-level token filtering** (token filter) — the ability for individual addresses to control which tokens they can receive. +1. **Token sets** for TIP-20 token addresses. +2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. +3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked amount is recorded under `(token, receiver, originator)`. -Currently, TIP-403 policies are set at the token level — each TIP-20 token has a single `transferPolicyId` that governs all transfers. This proposal adds the ability for individual addresses (EOAs or contracts) to set their own inbound controls across two dimensions: +The design covers both transfer-like and mint-like inbound paths, so direct minting cannot bypass receiver controls. Claim rights over blocked funds are governed by a per-address recovery mode and an optional TIP-403 claimer whitelist. Receiver-side claims may reroute blocked funds to an arbitrary destination. -1. **Receive policy** (counterparty filter): Control who can send to this address -2. **Token set** (token filter): Control which tokens can be sent to this address +## Motivation -This enables regulated entities (banks, exchanges, etc.) and orchestrator contracts to enforce inbound controls at the address level, independent of and in addition to token-level policies. +TIP-403 lets token issuers decide who may use a token. Some receivers also need their own inbound controls. -Outbound send restrictions are explicitly out of scope. Controlling what an address can send is best handled by mechanisms like multisigs, offchain policy engines, or smart contract wallets — not protocol-level transfer hooks. +A revert-based receiver policy creates a liveness problem. Once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. -## Motivation +This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. To keep storage growth bounded under Tempo's gas model, blocked funds are stored per-originator bucket rather than one receipt per blocked inbound. + +# Specification -Regulated entities and orchestrator contracts operating on Tempo need the ability to control *inbound* transactions, regardless of what policies the token issuer has set. For example: +## 1. Scope and Model -- A bank may want to only receive funds from KYC'd addresses (whitelist on receives) -- A custodian may require that all incoming transfers come from approved counterparties -- **A regulated entity may only want to hold approved stablecoins** (whitelist on tokens, e.g. a 'MiCA'-only list) -- **An institution may need to block specific tokens** known to be associated with illicit activity or memecoins (blacklist on tokens) -- **An orchestrator contract** may want to restrict which tokens it can receive to those it can orchestrate +TIP-1028 applies to the following TIP-20 recipient-bearing paths: -The current TIP-403 system only supports token-level policies: the token issuer sets a policy, and all transfers of that token must satisfy it. This does not allow individual addresses to impose their own restrictions on who can send to them or which tokens they accept. +- `transfer` +- `transferFrom` +- `transferWithMemo` +- `transferFromWithMemo` +- `mint` +- `mintWithMemo` +- protocol withdrawals that execute as TIP-20 transfers from a concrete source balance -### Why Token Sets? +For all such paths, TIP-1028 adds a receiver-side authorization layer: -TIP-403 policies manage sets of *counterparty addresses* — they answer "is this address authorized?" Token filtering answers a fundamentally different question: "is this token authorized?" Reusing the policy primitive for both would conflate two distinct concepts. Token sets are a parallel primitive with identical mechanics (admin, type, membership) but a separate ID space and separate storage, keeping the semantics clean. +- the receiver's token set is checked against the TIP-20 token address; and +- the receiver's receive policy is checked against the inbound **originator**: + - `from` for transfer-like paths; + - the mint caller for `mint` / `mintWithMemo`. +If a token-level TIP-403 policy rejects the operation, the operation MUST revert exactly as it does today. If the receiver's address-level controls reject the inbound, the operation MUST be escrowed instead. -# Specification +If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur before TIP-1028 receiver-side authorization. In that case: + +- TIP-1028 applies to the resolved master address, not the literal virtual address +- if virtual-address resolution fails, the operation MUST revert rather than escrow +- blocked funds MUST be stored under `(token, resolvedMaster, originator)` +- if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics + +TIP-1028 does **not** alter: + +- `approve` +- `permit` +- `burn` +- fee refunds via `transfer_fee_post_tx` +- the opt-in reward subsystem (`distributeReward`, `setRewardRecipient`, `claimRewards`) +- non-TIP-20 tokens deployed as ordinary contracts +- future recipient-bearing system-credit paths that do not identify a concrete originator + +DEX internal balances are not TIP-20 wallet balances and are not subject to address-level receive checks until withdrawn back onto the TIP-20 ledger. + +The reward subsystem remains opt-in and is excluded from receiver-side gating. A holder that does not want reward inflows can opt out by setting `rewardRecipient = Address::ZERO`. When a transfer or mint is escrowed, reward accounting MUST follow the raw ledger movement to `ESCROW_ADDRESS`. + +High-level flow: + +1. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 checks. +2. Run the receiver's token-set and receive-policy checks. +3. If the inbound is authorized, credit the receiver normally. +4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, record `(token, receiver, originator)`, and emit a blocked-inbound event. +5. Later, the originator, the receiver, or an authorized delegate may claim according to the receiver's active recovery mode. -## Part 1: Token Sets (TIP-403 Extension) +## 2. Token Sets -Token sets are a new primitive in the TIP-403 Registry for managing sets of token addresses. +Token sets are a new TIP-403 primitive for TIP-20 token addresses. They answer a different question from address policies: -### Storage +- address policy: “is this originator authorized?” +- token set: “is this token authorized?” + +Token sets use a separate ID space from policy IDs. + +### 2.1 Storage and Constraints ```solidity -uint64 public tokenSetIdCounter = 2; // Skip 0 (always-reject) and 1 (always-allow) +uint64 public tokenSetIdCounter = 2; // 0 = reject all, 1 = allow all struct TokenSetData { - PolicyType setType; // WHITELIST or BLACKLIST (not COMPOUND) - address admin; // Administrator who can modify membership + PolicyType setType; // WHITELIST or BLACKLIST + address admin; } mapping(uint64 => TokenSetData) internal _tokenSetData; mapping(uint64 => mapping(address => bool)) internal tokenSetMembers; ``` -Token set IDs occupy a separate ID space from policy IDs. ID 0 means "always-reject" and ID 1 means "always-allow", mirroring the policy convention. +Built-in meanings: + +- `0` = always reject +- `1` = always allow -**Note on ID assignment**: Token set IDs are assigned sequentially from a global counter. When creating token sets in Forge scripts or simulations, be aware that the ID assigned during simulation may differ from the one assigned on-chain if other transactions create token sets in the interim. +Token sets MUST satisfy: -### Interface +- `setType` is `WHITELIST` or `BLACKLIST` +- `COMPOUND` token sets are forbidden +- token-set type is immutable after creation +- membership is mutable by the token-set admin + +### 2.2 Interface ```solidity -/// @notice Creates a new token set -/// @param admin The address authorized to modify this token set -/// @param setType The type of token set (WHITELIST or BLACKLIST, not COMPOUND) -/// @return newTokenSetId The ID of the newly created token set -function createTokenSet(address admin, PolicyType setType) - external returns (uint64 newTokenSetId); - -/// @notice Creates a new token set with initial token members -/// @param admin The address authorized to modify this token set -/// @param setType The type of token set (WHITELIST or BLACKLIST, not COMPOUND) -/// @param tokens The initial token addresses to add -/// @return newTokenSetId The ID of the newly created token set -function createTokenSetWithTokens( - address admin, - PolicyType setType, - address[] calldata tokens -) external returns (uint64 newTokenSetId); - -/// @notice Updates the admin address for a token set -/// @dev Can only be called by the current admin of the token set -/// @param tokenSetId The ID of the token set to update -/// @param admin The new admin address -function setTokenSetAdmin(uint64 tokenSetId, address admin) external; - -/// @notice Adds or removes a token from a whitelist token set -/// @param tokenSetId The ID of the whitelist token set -/// @param token The token address to update -/// @param allowed Whether to allow (true) or disallow (false) the token -function modifyTokenSetWhitelist(uint64 tokenSetId, address token, bool allowed) external; - -/// @notice Adds or removes a token from a blacklist token set -/// @param tokenSetId The ID of the blacklist token set -/// @param token The token address to update -/// @param restricted Whether to restrict (true) or unrestrict (false) the token -function modifyTokenSetBlacklist(uint64 tokenSetId, address token, bool restricted) external; - -/// @notice Checks if a token is authorized under a token set -/// @param tokenSetId The ID of the token set to check against -/// @param token The token address to check -/// @return True if the token is authorized -function isTokenAuthorized(uint64 tokenSetId, address token) external view returns (bool); - -/// @notice Returns whether a token set exists -/// @param tokenSetId The ID of the token set to check -/// @return True if the token set exists -function tokenSetExists(uint64 tokenSetId) external view returns (bool); - -/// @notice Returns the data for a given token set -/// @param tokenSetId The ID of the token set to query -/// @return setType The type of the token set (whitelist or blacklist) -/// @return admin The admin address of the token set -function tokenSetData(uint64 tokenSetId) - external view returns (PolicyType setType, address admin); +interface ITIP403TokenSets { + function createTokenSet(address admin, PolicyType setType) + external + returns (uint64 newTokenSetId); + + function createTokenSetWithTokens( + address admin, + PolicyType setType, + address[] calldata tokens + ) external returns (uint64 newTokenSetId); + + function setTokenSetAdmin(uint64 tokenSetId, address admin) external; + function modifyTokenSetWhitelist(uint64 tokenSetId, address token, bool allowed) external; + function modifyTokenSetBlacklist(uint64 tokenSetId, address token, bool restricted) external; + function isTokenAuthorized(uint64 tokenSetId, address token) external view returns (bool); + function tokenSetExists(uint64 tokenSetId) external view returns (bool); + function tokenSetData(uint64 tokenSetId) + external + view + returns (PolicyType setType, address admin); +} ``` -### Events +### 2.3 Authorization Logic + +`isTokenAuthorized(tokenSetId, token)` MUST behave as follows: + +- if `tokenSetId == 0`, return reject +- if `tokenSetId == 1`, return allow +- otherwise: + - read the token set's immutable `setType` + - for a `WHITELIST`, return the stored membership bit for `token` + - for a `BLACKLIST`, return the negation of the stored membership bit for `token` + +### 2.4 Events and Errors ```solidity -/// @notice Emitted when a new token set is created event TokenSetCreated(uint64 indexed tokenSetId, address indexed creator, PolicyType setType); - -/// @notice Emitted when a token set's admin is updated event TokenSetAdminUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed admin); - -/// @notice Emitted when a whitelist token set is modified event TokenSetWhitelistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool allowed); - -/// @notice Emitted when a blacklist token set is modified event TokenSetBlacklistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool restricted); -``` -### Errors - -```solidity -/// @notice Error when referencing a token set that does not exist error TokenSetNotFound(); - -/// @notice Error when creating a compound token set (not allowed) error InvalidTokenSetType(); ``` -### Authorization Logic - -```solidity -function isTokenAuthorized(uint64 tokenSetId, address token) public view returns (bool) { - // Special case: built-in token sets - if (tokenSetId < 2) { - return tokenSetId == 1; // 0 = always-reject, 1 = always-allow - } - - TokenSetData memory data = _tokenSetData[tokenSetId]; +## 3. Address-Level Receive Controls - return data.setType == PolicyType.WHITELIST - ? tokenSetMembers[tokenSetId][token] - : !tokenSetMembers[tokenSetId][token]; -} -``` +Any address MAY configure four fields: ---- +1. `receivePolicyId` — which originators may credit the address +2. `tokenSetId` — which TIP-20 token addresses may credit the address +3. `recoveryMode` — who may later claim blocked funds +4. `claimerWhitelistId` — optional TIP-403 whitelist for third-party claimers -## Part 2: Address-Level Receive Policies +If an address has no configured receive controls, address-level authorization defaults to allow. -### Overview +TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. -The TIP-403 Registry is extended with a new mapping that allows any address to set its own inbound controls: +### 3.1 Constraints -- **Receive policy**: Controls which addresses can send tokens to this address (references a TIP-403 policy) -- **Token set**: Controls which tokens this address can receive (references a TIP-403 token set) +`receivePolicyId` MUST reference: -These controls are checked on every TIP-20 transfer and mint in addition to the existing token-level policy check. Since TIP-20 is implemented as a precompile, a hardfork that adds this functionality will apply to all tokens automatically. +- a simple `WHITELIST` policy, +- a simple `BLACKLIST` policy, or +- built-in policy `0` or `1` -### Storage Layout +It MUST NOT reference a `COMPOUND` policy. -```solidity -mapping(address => uint256) public addressPolicies; -``` +`claimerWhitelistId` MUST reference: -The `addressPolicies` mapping packs a presence flag, policy IDs, token set IDs, and their cached types into a single 256-bit storage slot: +- a simple `WHITELIST` policy, or +- built-in policy `0` or `1` -| Bits (inclusive) | Size | Field | Description | -|------------------|------|-------|-------------| -| 0–63 | 64 bits | `receivePolicyId` | TIP-403 policy checked when this address is the receiver | -| 64–71 | 8 bits | `receivePolicyType` | Cached type of receive policy (0 = whitelist, 1 = blacklist) | -| 72–135 | 64 bits | `tokenSetId` | TIP-403 token set checked on tokens being received | -| 136–143 | 8 bits | `tokenSetType` | Cached type of token set (0 = whitelist, 1 = blacklist) | -| 144–254 | 111 bits | Reserved | For future use (must be 0) | -| 255 | 1 bit | `hasReceivePolicy` | 1 if address has set receive policies, 0 if not | +It MUST NOT reference a `BLACKLIST` or `COMPOUND` policy. -When `hasReceivePolicy` is 0 (the default for all uninitialized storage), no address-level controls are set and the address is always authorized at the address level. The remaining fields are ignored. +### 3.2 Recovery Modes -The receive policy MUST reference a simple policy (WHITELIST or BLACKLIST) or a built-in policy (0 or 1), not a compound policy. Compound policies split sender/recipient authorization, but the address-level receive policy always checks the sender against a single list — compound semantics would be meaningless here. +```solidity +enum RecoveryMode { + RECEIVER_ONLY, + RECEIVER_OR_CLAIMERS, + ORIGINATOR_OR_RECEIVER, + ORIGINATOR_OR_RECEIVER_OR_CLAIMERS +} +``` -### Why the Bit Flag? +```solidity +enum BlockedReason { + NONE, + TOKEN_SET, + RECEIVE_POLICY, + TOKEN_SET_AND_RECEIVE_POLICY +} +``` -The bit flag keeps policy ID semantics consistent everywhere — 0 always means "always-reject", 1 always means "always-allow" — and provides an unambiguous way to distinguish "no controls set" from "controls set with always-reject". +`BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized. -### Why Embed Types? +All blocked funds use the same storage key: -TIP-403 policy types and token set types are **immutable** — set at creation and never changed. By embedding the type in the address storage, we avoid an SLOAD to `policyData[policyId]` or `tokenSetData[tokenSetId]` on every authorization check, saving ~2,100 gas per check. +```text +(token, receiver, originator) +``` -When `setAddressReceivePolicy` is called, the types are read from the registry once and cached in the address's storage. Since types cannot change, these cached values remain valid forever. +| Mode | Allowed claimers | +|------|------------------| +| `RECEIVER_ONLY` | receiver | +| `RECEIVER_OR_CLAIMERS` | receiver or a caller in `claimerWhitelistId` | +| `ORIGINATOR_OR_RECEIVER` | originator or receiver | +| `ORIGINATOR_OR_RECEIVER_OR_CLAIMERS` | originator, receiver, or a caller in `claimerWhitelistId` | -### Encoding +### 3.3 Packed Storage ```solidity -function encodeAddressPolicies( - uint64 receivePolicyId, - PolicyType receivePolicyType, - uint64 tokenSetId, - PolicyType tokenSetType -) internal pure returns (uint256) { - return uint256(1) // hasReceivePolicy = 1 - | (uint256(receivePolicyId) << 1) - | (uint256(uint8(receivePolicyType)) << 65) - | (uint256(tokenSetId) << 73) - | (uint256(uint8(tokenSetType)) << 137); -} - -function decodeAddressPolicies(uint256 packed) - internal pure returns ( - bool hasReceivePolicy, - uint64 receivePolicyId, - PolicyType receivePolicyType, - uint64 tokenSetId, - PolicyType tokenSetType - ) -{ - hasReceivePolicy = (packed & 1) == 1; - receivePolicyId = uint64(packed >> 1); - receivePolicyType = PolicyType(uint8(packed >> 65)); - tokenSetId = uint64(packed >> 73); - tokenSetType = PolicyType(uint8(packed >> 137)); -} +mapping(address => uint256) public addressReceiveConfig; ``` -### Interface Extensions +| Bits | Size | Field | +|------|------|-------| +| `0..63` | 64 | `receivePolicyId` | +| `64..71` | 8 | cached `receivePolicyType` | +| `72..135` | 64 | `tokenSetId` | +| `136..143` | 8 | cached `tokenSetType` | +| `144..151` | 8 | `recoveryMode` | +| `152..215` | 64 | `claimerWhitelistId` | +| `216..254` | 39 | reserved, MUST be zero | +| `255` | 1 | `hasAddressPolicy` | -The following functions are added to the TIP-403 Registry: +When `hasAddressPolicy == 0`, the address is always authorized at the address level. The cached type fields are valid because policy type and token-set type are immutable after creation. + +### 3.4 Interface ```solidity -/// @notice Sets the receive policy and token set for the caller's address -/// @param receivePolicyId TIP-403 policy to check when caller receives -/// @param tokenSetId TIP-403 token set to check on tokens being received -/// @dev Receive policy must be simple (WHITELIST/BLACKLIST) or built-in (0/1), not COMPOUND. -/// @dev Types are cached from the registry at set time (immutable, so always valid). -/// @dev Sets the hasReceivePolicy flag to 1. -function setAddressReceivePolicy( - uint64 receivePolicyId, - uint64 tokenSetId -) external; - -/// @notice Clears all receive controls for the caller's address -/// @dev Sets the entire addressPolicies slot to 0 (hasReceivePolicy = 0). -function clearAddressReceivePolicy() external; - -/// @notice Returns the address-level receive controls for an address -/// @param account The address to query -/// @return hasReceivePolicy Whether the address has receive policies set -/// @return receivePolicyId The policy checked when address receives -/// @return receivePolicyType The type of the receive policy -/// @return tokenSetId The token set checked on tokens being received -/// @return tokenSetType The type of the token set -function getAddressReceivePolicy(address account) - external view returns ( - bool hasReceivePolicy, - uint64 receivePolicyId, - PolicyType receivePolicyType, +interface IAddressReceivePolicies { + function setAddressReceivePolicy( + uint64 receivePolicyId, uint64 tokenSetId, - PolicyType tokenSetType - ); - -/// @notice Checks if a transfer or mint is authorized under the receiver's address-level controls -/// @param from The sender address (for transfer) or minter address (for mints) -/// @param to The receiver address -/// @param token The token contract address being transferred -/// @return True if the transfer is authorized under the receiver's controls -function isAddressTransferAuthorized(address from, address to, address token) - external view returns (bool); + RecoveryMode recoveryMode, + uint64 claimerWhitelistId + ) external; + + function addressReceivePolicy(address account) + external + view + returns ( + bool hasAddressPolicy, + uint64 receivePolicyId, + PolicyType receivePolicyType, + uint64 tokenSetId, + PolicyType tokenSetType, + RecoveryMode recoveryMode, + uint64 claimerWhitelistId + ); + + function classifyAddressInbound(address token, address originator, address to) + external + view + returns ( + bool authorized, + RecoveryMode recoveryMode, + BlockedReason blockedReason + ); +} ``` -### Events +Updating `recoveryMode` or `claimerWhitelistId` changes claim authorization for both future and existing blocked buckets. + +### 3.5 Authorization Logic + +`classifyAddressInbound(token, originator, to)` MUST behave as follows: + +- read the packed config for `to` +- if `hasAddressPolicy == 0`, return: + - `authorized = true` + - `recoveryMode = RECEIVER_ONLY` + - `blockedReason = NONE` +- otherwise: + - decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, `tokenSetType`, and `recoveryMode` + - evaluate whether `token` is allowed by the token set + - evaluate whether `originator` is allowed by the receive policy +- if both checks pass, return: + - `authorized = true` + - `blockedReason = NONE` + - the decoded `recoveryMode` +- if both checks fail, return: + - `authorized = false` + - `blockedReason = TOKEN_SET_AND_RECEIVE_POLICY` + - the decoded `recoveryMode` +- if only the token-set check fails, return: + - `authorized = false` + - `blockedReason = TOKEN_SET` + - the decoded `recoveryMode` +- if only the receive-policy check fails, return: + - `authorized = false` + - `blockedReason = RECEIVE_POLICY` + - the decoded `recoveryMode` + +An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. + +### 3.6 Events and Errors ```solidity -/// @notice Emitted when an address updates its receive policy -/// @param account The address that updated its controls -/// @param receivePolicyId The new receive policy -/// @param tokenSetId The new token set event AddressReceivePolicyUpdated( - address indexed account, + address indexed account, uint64 receivePolicyId, - uint64 tokenSetId + uint64 tokenSetId, + RecoveryMode recoveryMode, + uint64 claimerWhitelistId ); -/// @notice Emitted when an address clears its receive policy -/// @param account The address that cleared its controls -event AddressReceivePolicyCleared(address indexed account); +error InvalidReceivePolicyType(); +error InvalidClaimerWhitelistType(); ``` -### Errors +## 4. Escrow Precompile -```solidity -/// @notice Error when setting a policy that does not exist -error PolicyNotFound(); - -/// @notice Error when setting a token set that does not exist -error TokenSetNotFound(); +Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only blocked-bucket accounting. -/// @notice Error when setting a compound policy as the receive policy -error CompoundPolicyNotAllowed(); +```solidity +ESCROW_ADDRESS = 0xFDC1000000000000000000000000000000000000 ``` -### Authorization Logic - -#### setAddressReceivePolicy +### 4.1 Storage ```solidity -function setAddressReceivePolicy( - uint64 receivePolicyId, - uint64 tokenSetId -) external { - PolicyType receivePolicyType; - PolicyType tokenSetType; - - // Validate and cache receive policy type - if (receivePolicyId >= 2) { - if (!policyExists(receivePolicyId)) revert PolicyNotFound(); - (receivePolicyType, ) = policyData(receivePolicyId); - if (receivePolicyType == PolicyType.COMPOUND) revert CompoundPolicyNotAllowed(); - } - // receivePolicyId 0 (always-reject) and 1 (always-allow) are valid built-ins - - // Validate and cache token set type - if (tokenSetId >= 2) { - if (!tokenSetExists(tokenSetId)) revert TokenSetNotFound(); - (tokenSetType, ) = tokenSetData(tokenSetId); - } - // tokenSetId 0 (always-reject) and 1 (always-allow) are valid built-ins - - addressPolicies[msg.sender] = encodeAddressPolicies( - receivePolicyId, receivePolicyType, - tokenSetId, tokenSetType - ); - - emit AddressReceivePolicyUpdated(msg.sender, receivePolicyId, tokenSetId); -} +mapping(address => mapping(address => mapping(address => uint256))) internal originatorEscrow; ``` -#### clearAddressReceivePolicy +Interpretation: -```solidity -function clearAddressReceivePolicy() external { - addressPolicies[msg.sender] = 0; - emit AddressReceivePolicyCleared(msg.sender); -} +```text +originatorEscrow[token][receiver][originator] ``` -#### getAddressReceivePolicy +The same storage shape is used for blocked transfers and blocked mints. Only the definition of `originator` differs. -```solidity -function getAddressReceivePolicy(address account) - external view returns ( - bool hasReceivePolicy, - uint64 receivePolicyId, PolicyType receivePolicyType, - uint64 tokenSetId, PolicyType tokenSetType - ) -{ - return decodeAddressPolicies(addressPolicies[account]); -} -``` +The precompile deliberately does **not** store: -#### isAddressTransferAuthorized +- one receipt per blocked inbound +- receiver-wide aggregate totals +- signer lists or multisig state -This function checks the **receiver's** controls only — the sender has no address-level policy checked. Types are embedded in `addressPolicies`, so no extra SLOADs for `policyData` or `tokenSetData`. +### 4.2 Interface ```solidity -function isAddressTransferAuthorized(address from, address to, address token) - external view returns (bool) -{ - uint256 packed = addressPolicies[to]; - - // Fast path: no controls set (bit 0 = 0) - if (packed & 1 == 0) return true; - - // Decode receiver's controls - ( - , - uint64 receivePolicy, - PolicyType receiveType, - uint64 tokenSet, - PolicyType tokenSetType - ) = decodeAddressPolicies(packed); - - // Check receive policy: "who can send to me?" - if (receivePolicy < 2) { - // Built-in: 0 = always-reject, 1 = always-allow - if (receivePolicy == 0) return false; - } else { - bool inSet = policySet[receivePolicy][from]; - bool authorized = (receiveType == PolicyType.WHITELIST) ? inSet : !inSet; - if (!authorized) return false; - } - - // Check token set: "which tokens can I receive?" - if (tokenSet < 2) { - // Built-in: 0 = always-reject, 1 = always-allow - if (tokenSet == 0) return false; - } else { - bool inSet = tokenSetMembers[tokenSet][token]; - bool authorized = (tokenSetType == PolicyType.WHITELIST) ? inSet : !inSet; - if (!authorized) return false; +interface IBlockedInboundEscrow { + enum InboundKind { + TRANSFER, + MINT } - - return true; -} -``` - - - -## Integration with TIP-20 - -### Transfer Authorization -The `isTransferAuthorized` check in TIP-20 is updated to check both token-level and address-level controls. Token-level checks use the TIP-1015 sender/recipient functions: + struct OriginatorClaimPart { + address originator; + uint256 amount; + } -```solidity -function isTransferAuthorized(address from, address to) internal view returns (bool) { - uint64 policyId = transferPolicyId; - - // Token-level policy check (TIP-1015 compound-aware) - bool fromAuthorized = TIP403_REGISTRY.isAuthorizedSender(policyId, from); - bool toAuthorized = TIP403_REGISTRY.isAuthorizedRecipient(policyId, to); - if (!fromAuthorized || !toAuthorized) return false; - - // Address-level receive check (new) — receiver's counterparty + token controls - return TIP403_REGISTRY.isAddressTransferAuthorized(from, to, address(this)); + function originatorEscrowBalance(address token, address receiver, address originator) + external + view + returns (uint256); + + function claimOriginatorBucketToOriginator(address token, address receiver, uint256 amount) + external; + + function claimOriginatorBucketsTo( + address token, + address receiver, + OriginatorClaimPart[] calldata parts, + address to + ) external; + + function recordBlockedInbound( + address token, + address originator, + address receiver, + uint256 amount, + BlockedReason blockedReason, + RecoveryMode recoveryMode, + InboundKind kind + ) external; } ``` -All functions that call `ensureTransferAuthorized(from, to)` automatically inherit address-level checks: - -- `transfer(address to, uint256 amount)` -- `transferFrom(address from, address to, uint256 amount)` -- `transferWithMemo(address to, uint256 amount, bytes32 memo)` -- `transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo)` -- `systemTransferFrom(address from, address to, uint256 amount)` - -### Mint Behavior - -Minting checks both the token-level policy AND the recipient's full address-level controls (receive policy and token set). This is important because TIP-20 creation is permissionless — anyone can create a token and attempt to mint to any address. - - -### Burn and burnBlocked Behavior - -Burning (`burn`, `burnBlocked`) does not deliver tokens to a new recipient, so address-level controls are not applicable. The existing behavior is unchanged. - -### Self-Transfers - -Self-transfers (`from == to`) are treated as normal transfers and are subject to the receive policy check. If an address sets a whitelist receive policy, it SHOULD include itself if self-transfers are needed. - -### Fee Transfers - -Fee transfers via `transferFeePreTx` and `transferFeePostTx` bypass address-level policy checks since they are system operations that directly manipulate balances without going through `ensureTransferAuthorized`. The fee collection path (`collect_fee_pre_tx`) checks token-level `ensureTransferAuthorized(fee_payer, FeeManager)` but does NOT check address-level controls on the FeeManager address — this is correct because FeeManager is a system precompile, not an address that opts into receive policies. +`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. -### Rewards +The precompile does not enumerate originators onchain. Receiver-side claimers MUST supply the buckets they want to consume, typically using logs or offchain indexing. -TIP-20 rewards involve three operations that move tokens: +It MUST emit: -- **`distributeReward(amount)`**: Transfers tokens from the caller to the token contract's own address. This goes through `ensureTransferAuthorized(msg.sender, tokenAddress)`, which will now include address-level checks on the token contract address as receiver. Since token contract addresses do not set address-level policies, this adds only the baseline SLOAD cost (~2,100 gas). +- `TransferBlocked` when `kind == InboundKind.TRANSFER` +- `MintBlocked` when `kind == InboundKind.MINT` -- **`setRewardRecipient(recipient)`**: Does not transfer tokens — it only changes the delegation mapping. It calls `ensureTransferAuthorized(msg.sender, recipient)` to verify the delegation is policy-compliant. This will now also check the recipient's address-level receive policy. If the recipient has a whitelist receive policy, the holder must be on it. This is correct — a regulated entity's receive policy should govern who can delegate rewards to them. +### 4.3 Claim Authorization -- **`claimRewards()`**: Transfers accumulated rewards from the token contract to the caller. This calls `ensureTransferAuthorized(tokenAddress, msg.sender)`, which will now check the caller's address-level receive policy (is the token contract authorized to send to me?) and token set (is this token authorized?). This means a regulated entity that has a receive policy whitelist must include the token contract address to claim rewards, and must have the token in their token set. This is correct — claiming rewards is receiving tokens, and should respect the receiver's controls. +Claim authorization is evaluated against the receiver's **current** `recoveryMode` and current `claimerWhitelistId`. Changing those fields therefore changes who may claim existing originator buckets. -## Integration with Stablecoin DEX +There are only two claim entrypoints: -### Swaps and Order Fills +1. `claimOriginatorBucketToOriginator(...)` +2. `claimOriginatorBucketsTo(...)` -DEX swaps (`swapExactAmountIn`, `swapExactAmountOut`) transfer the output token to the sender at the end of the swap via `TIP20.transfer(DEX_ADDRESS, sender, amount)`. This goes through `ensureTransferAuthorized`, which will now check the sender's (as receiver of output) address-level controls: +`claimOriginatorBucketToOriginator(...)`: -- **Receive policy**: The sender must be authorized under their own receive policy with the DEX address as counterparty. Since the DEX is a system precompile with a fixed address, addresses with whitelist receive policies must include the DEX address to use swaps. -- **Token set**: The output token must be in the sender's token set (if set). This means a regulated entity cannot accidentally receive unauthorized tokens via a swap — the token set check will reject it. +- consumes only `(token, receiver, msg.sender)`; +- releases only to `msg.sender`; and +- is allowed only when the active recovery mode allows originator claims. -Order fills (`partial_fill_order`, `fill_order`) credit tokens to the maker's *DEX internal balance* via `increment_balance`, which does not trigger TIP-20 transfers. Address-level controls are NOT checked on internal balance credits. Controls are checked when the maker later calls `withdraw` to move tokens out of the DEX to their wallet (see below). +`claimOriginatorBucketsTo(...)`: -> **DEX Internal Balance Bypass**: This is an intentional design choice, not a loophole. The DEX's internal accounting is an escrow mechanism — tokens are locked in the DEX contract, not delivered to the maker's wallet. Address-level controls govern what enters an address's wallet, and the `withdraw` path enforces this. Attempting to enforce controls on internal balance credits would break order matching. +- consumes only the explicitly listed buckets `(token, receiver, originator_i)`; +- releases only to `to`; +- is a receiver self-claim when `msg.sender == receiver`; and +- otherwise is a delegate claim, in which case `msg.sender` MUST satisfy + `isAuthorized(claimerWhitelistId, msg.sender)` whenever the active mode allows delegates. -### Order Placement +The originator addresses listed in `parts` are only bucket selectors. They do not make the caller an originator claimant. -`place` and `place_flip` call `ensureTransferAuthorized` in two directions: +Claims use ordinary caller authentication. This TIP does not impose a special key-type rule on any claimer. Delegates may be EOAs, smart wallets, multisigs, or orchestration contracts. -1. `ensureTransferAuthorized(sender, DEX_ADDRESS)` for the escrow token (sender sends to DEX) -2. `ensureTransferAuthorized(DEX_ADDRESS, sender)` for the non-escrow token (DEX will eventually send to sender when the order fills) +### 4.4 Release Semantics -Check (2) will now include address-level controls on the sender as receiver. This is correct — it validates upfront that the sender's receive policy and token set would allow receiving the output token from the DEX when the order is eventually filled. This prevents placing orders that would be unfillable due to the maker's own receive controls. +- originator claims release only to `originator` +- receiver and delegate claims release only to the specified `to` -### Withdraw +The escrow precompile MUST call an internal TIP-20 escrow-release path that: -`withdraw(token, amount)` transfers tokens from the DEX internal balance to the user's wallet via `TIP20.transfer(DEX_ADDRESS, user, amount)`. This goes through `ensureTransferAuthorized(DEX_ADDRESS, user)`, which will now check the user's address-level controls. A user with a token set whitelist can only withdraw tokens that are in their token set. +1. debits `balances[ESCROW_ADDRESS]` +2. credits the beneficiary +3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)` +4. bypasses the token-level TIP-403 **sender** check for `ESCROW_ADDRESS` +5. still enforces the token-level TIP-403 **recipient** check for the beneficiary +6. updates reward accounting as if the transfer were `ESCROW_ADDRESS -> beneficiary`, with `ESCROW_ADDRESS` treated as permanently opted out of rewards +For originator self-claims, the beneficiary is fixed to `originator`. The beneficiary's address-level receive controls are bypassed because the beneficiary is explicitly accepting the release. +For receiver-side claims: -## Integration with Fee Manager +- if `to == receiver`, the receiver's address-level receive controls are bypassed because the + receiver is explicitly accepting the release; +- if `to != receiver`, the claim MUST enforce the destination's address-level receive controls + using `ESCROW_ADDRESS` as the originator. -### Fee Collection +A claim MUST revert with `ClaimDestinationUnauthorized()` if the beneficiary fails the token-level +recipient check or, when applicable, the destination's address-level receive controls. +Receiver-side claims MUST NOT use `ESCROW_ADDRESS` itself as `to`. +Receiver-side claims MUST NOT use a TIP-1022 virtual address as `to`. -`collect_fee_pre_tx` calls `ensureTransferAuthorized(fee_payer, FeeManager)` followed by `transfer_fee_pre_tx` which directly manipulates balances. The `ensureTransferAuthorized` check will now include the FeeManager's address-level controls (which FeeManager, being a system precompile does not set). +### 4.5 Events and Errors -### Fee Distribution - -`distribute_fees` transfers accumulated fees from FeeManager to the validator via `TIP20.transfer(FeeManager, validator, amount)`. This goes through `ensureTransferAuthorized(FeeManager, validator)`, which will now check the validator's address-level controls. Validators with receive policies must include the FeeManager address. Validators with token set whitelists must include their fee token. In practice, validators choose their fee token preference via `setValidatorToken`, so this should be naturally consistent. - -### Fee Refunds - -`collect_fee_post_tx` refunds unused fees directly via balance manipulation (not through `ensureTransferAuthorized`), so address-level controls do not apply to refunds. +```solidity +event TransferBlocked( + address indexed token, + address indexed from, + address indexed intendedReceiver, + uint256 amount, + BlockedReason blockedReason, + RecoveryMode recoveryMode +); -## Gas Cost Analysis +event MintBlocked( + address indexed token, + address indexed operator, + address indexed intendedReceiver, + uint256 amount, + BlockedReason blockedReason, + RecoveryMode recoveryMode +); -### Baseline Cost Discussion +event OriginatorEscrowClaimedToOriginator( + address indexed token, + address indexed receiver, + address indexed originator, + uint256 amount +); -This TIP introduces the **first per-address persistent storage read** (`addressPolicies[to]`) on the TIP-20 transfer hot path. Every transfer incurs a ~2,100 gas cold SLOAD to check whether the receiver has set address-level controls, even if they have not. This is a new class of per-transfer overhead — existing features like access keys and TIP-403 membership either short-circuit via transient storage or are keyed by policy ID rather than by address. +event OriginatorEscrowClaimed( + address indexed token, + address indexed receiver, + address indexed originator, + address caller, + address to, + uint256 amount +); -The ~2,100 gas overhead (~5% of a baseline transfer) is accepted as the cost of enabling address-level receive controls. Mitigating factors: -- The SLOAD is in the TIP-403 registry contract, which is already loaded into the warm account set during token-level policy checks -- Subsequent transfers to the same recipient within the same transaction cost only ~100 gas (warm SLOAD) -- If a future protocol change adds per-address fields to the account trie (e.g., for account abstraction metadata), this flag could migrate there for zero marginal cost -- The cost is justified by enabling regulated entities and orchestrators to enforce inbound controls without resorting to wrapper contracts or offchain enforcement +error UnauthorizedClaimer(); +error InsufficientEscrowBalance(); +error EscrowOnlyTIP20(); +error ClaimDestinationUnauthorized(); +``` -### Per-Transfer Overhead (Incremental) +For batched receiver-side claims, `OriginatorEscrowClaimed` MUST be emitted once per consumed bucket. -| Scenario | Additional Gas | -|----------|----------------| -| Receiver has no controls set | ~2,100 gas (1 SLOAD for addressPolicies[to]) | -| Receiver has receive policy only | ~4,200 gas (+1 policySet SLOAD) | -| Receiver has token set only | ~4,200 gas (+1 tokenSetMembers SLOAD) | -| Receiver has receive policy + token set | ~6,300 gas (+2 SLOADs) | +## 5. TIP-20 Inbound Path Changes +Userland TIP-20 transfers or mints directly to `ESCROW_ADDRESS` MUST revert: -### State Creation Costs +```solidity +error EscrowAddressReserved(); +``` -Per TIP-1000, Tempo charges 250,000 gas for new state element creation (SSTORE zero→non-zero). +### 5.1 Transfer-like Paths + +For a transfer-like path: + +- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 checks +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- compute `effectiveReceiver`: + - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address + - otherwise `to` +- call `classifyAddressInbound(token, from, effectiveReceiver)` +- if the inbound is authorized: + - follow the normal transfer path using `effectiveReceiver` + - if `to` is virtual, use TIP-1022 forwarding event semantics + - update rewards exactly as on a normal transfer + - debit `from` + - credit `effectiveReceiver` + - return success +- if the inbound is blocked by the receiver: + - update rewards as if the raw recipient were `ESCROW_ADDRESS` + - debit `from` + - credit `ESCROW_ADDRESS` + - call `recordBlockedInbound(token, from, effectiveReceiver, amount, blockedReason, recoveryMode, TRANSFER)` + - emit `Transfer(from, ESCROW_ADDRESS, amount)` + - return success + +Memo-bearing transfer variants MUST follow the same ledger rules. If blocked, their raw recipient in memo-bearing events MUST be `ESCROW_ADDRESS`. + +### 5.2 Mint-like Paths + +For a mint-like path: + +- run the existing issuer-role, mint-recipient, and supply-cap checks +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- compute `effectiveReceiver`: + - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address + - otherwise `to` +- call `classifyAddressInbound(token, originator, effectiveReceiver)`, where `originator` is the mint caller +- if the inbound is authorized: + - follow the normal mint path using `effectiveReceiver` + - if `to` is virtual, use TIP-1022 forwarding event semantics + - update rewards exactly as on a normal mint + - increase total supply + - credit `effectiveReceiver` + - return success +- if the inbound is blocked by the receiver: + - update rewards as if the raw recipient were `ESCROW_ADDRESS` + - increase total supply + - credit `ESCROW_ADDRESS` + - call `recordBlockedInbound(token, originator, effectiveReceiver, amount, blockedReason, recoveryMode, MINT)` + - emit `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + - return success + +`mintWithMemo` MUST follow the same ledger rules. If blocked, the raw recipient in externally visible mint-related events MUST be `ESCROW_ADDRESS`. + +### 5.3 Reward and Event Semantics + +The reward subsystem is excluded from receiver-side gating, but blocked transfers and blocked mints MUST update reward accounting as if their raw recipient were `ESCROW_ADDRESS`. + +Blocked inbounds MUST use truthful raw TIP-20 events: + +- blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` +- blocked mint: `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + +In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: + +- `TransferBlocked(token, from, intendedReceiver, amount, blockedReason, recoveryMode)` +- `MintBlocked(token, operator, intendedReceiver, amount, blockedReason, recoveryMode)` + +`blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. + +### 5.4 Tempo-Specific Protocol Interactions + +- **Stablecoin DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 + wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. +- **DEX wallet payouts remain ordinary TIP-20 transfers.** Swap outputs and DEX withdrawals that + transfer from the DEX address to a wallet remain subject to address-level receive controls and + may therefore be escrowed. +- **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee + distributions, AMM burns, and AMM rebalance outputs remain subject to address-level receive + controls and may therefore be escrowed. +- **Higher-level protocol success events are unchanged.** A DEX, FeeManager, or AMM operation may + still emit its normal success event even if the final TIP-20 outbound was escrowed. Integrations + that care about wallet credit MUST also inspect `TransferBlocked` / `MintBlocked`. +- **Fee refunds are exempt.** `transfer_fee_post_tx` is a refund of the current transaction's + unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive + controls and MUST NOT be escrowed. +- **Rewards are exempt.** `distributeReward`, `setRewardRecipient`, and `claimRewards` remain + outside receiver-side gating as described above. +- **Blocked memo-bearing inbounds keep their raw memo event.** The raw `TransferWithMemo` or + mint-related memo event still names `ESCROW_ADDRESS`; receivers that care about memo-based + routing MUST correlate it with `TransferBlocked` / `MintBlocked` in the same transaction. +- **`ESCROW_ADDRESS` is a protected system address.** Any TIP-20 logic that protects DEX or + FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. -| Operation | Gas Cost | -|-----------|----------| -| First call to `setAddressReceivePolicy` (creates slot) | ~250,000 gas | -| Subsequent updates to address receive policy | ~5,000 gas | -| Clearing address receive policy (non-zero→zero) | ~5,000 gas (with 4,800 gas refund per EIP-3529) | -| Adding address to policy membership | ~250,000 gas | -| Adding token to token set membership | ~250,000 gas | +### 5.5 Integration Consequence -## System Precompile Addresses and Whitelists +After TIP-1028, `transfer`, `transferFrom`, `mint`, and `mintWithMemo` may succeed in either of two states: -Addresses that set a **whitelist** receive policy must include system precompile addresses as authorized senders if they want to participate in protocol operations that deliver tokens from those precompiles: +1. the intended receiver was credited; or +2. the inbound was escrowed. -| Operation | Requires whitelisting | -|-----------|----------------------| -| DEX swaps (`swapExactAmountIn/Out`) | DEX precompile address | -| DEX withdrawals | DEX precompile address | -| Fee distribution to validators | FeeManager precompile address | -| Reward claims (`claimRewards`) | Token contract address | -| Reward delegation (`setRewardRecipient`) | Delegating holder's address | +Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` / `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. -Addresses using **blacklist** receive policies or **token set only** controls (with `receivePolicyId = 1`) are not affected — they allow all senders by default. +## 6. Gas and Storage Analysis -## Shared Policy and Token Set Semantics +This section uses the gas model from TIP-1016: -An address can reference **any existing TIP-403 policy** for receive controls and **any existing TIP-403 token set** for token filtering, including those administered by other addresses. This enables shared compliance lists (e.g., a compliance provider publishes a "MiCA-approved tokens" token set that multiple institutions reference). +- fresh storage slot: **250,000 gas** +- existing nonzero slot update: **~2,900 gas** +- typical TIP-20 transfer to an existing address: **~50,000 gas** ---- +### 6.1 Main Cases -# Invariants +| Case | Rough cost | Notes | +|------|------------|-------| +| first `setAddressReceivePolicy()` for an address | `~250k + call overhead` | one new packed config slot | +| allowed inbound to address with no receive config | current path + one cold config read | no escrow writes | +| allowed inbound to configured address | current path + config read + token-set/policy membership reads | no escrow writes | +| first blocked inbound for fresh `(token, receiver, originator)` | `~300k` | `~50k` base path + one new escrow bucket | +| later blocked inbound to existing `(token, receiver, originator)` | `~53k` | base path + existing bucket update | +| first blocked inbound ever for that token | add `~250k` | creates `balances[ESCROW_ADDRESS]` in that TIP-20 | +| originator self-claim to existing beneficiary balance slot | `~53k + auth reads` | one escrow-bucket update + one transfer from escrow | +| receiver/delegate claim over `N` buckets | `~50k + N*2.9k + auth reads` | one transfer from escrow + `N` bucket updates | -The following invariants must always hold: +### 6.2 Storage Choices -## Token Set Invariants +The design intentionally stores only one blocked bucket per `(token, receiver, originator)`. It does not store: -1. **Token Set Type Restriction**: Token sets MUST have type WHITELIST or BLACKLIST. COMPOUND is not a valid token set type. +- one receipt per blocked inbound +- a receiver-wide aggregate total -2. **Token Set Built-in Semantics**: Token set ID 0 MUST be "always-reject" and token set ID 1 MUST be "always-allow", mirroring the policy convention. +Under Tempo's pricing, either of those would often add another **250,000 gas** per fresh blocked relationship. -3. **Token Set Immutable Type**: Once created, a token set's type cannot be changed. Only membership can be modified by the admin. +`claimerWhitelistId` is packed into the address config slot, so enabling delegates does not require a second per-address storage slot. If a receiver creates a new TIP-403 whitelist policy for claimers, that policy's storage costs are paid separately through TIP-403. -## Address Policy Invariants +## 7. Security and Integration Considerations -4. **Address Sovereignty**: Only an address itself can set its own receive policy via `setAddressReceivePolicy` or clear it via `clearAddressReceivePolicy`. No address can modify another address's controls. +- **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. +- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. + Contracts that are not explicitly built to inspect blocked-inbound events and claim from escrow SHOULD NOT opt in. +- **Token-level policy still controls who may hold the token.** Escrow only replaces receiver-side failure handling. Claim release bypasses the sender-side token check for `ESCROW_ADDRESS`, but still enforces token-level recipient authorization for the beneficiary. +- **Receiver-side claims may reroute.** A receiver or authorized delegate may release blocked + funds to any `to`, but rerouted claims must still satisfy token-level recipient authorization, + and rerouted claims to `to != receiver` must also satisfy that destination's address-level + receive controls. +- **Delegation is address-based.** Third-party claimers are authorized by `claimerWhitelistId`, not by signer arrays or threshold logic stored in escrow. If richer authorization is needed, the receiver SHOULD whitelist a smart wallet or multisig contract. +- **`claimerWhitelistId = 1` is dangerous.** Built-in whitelist policy `1` means any caller may act as an additional claimer and may choose the reroute destination. Production deployments SHOULD prefer a dedicated whitelist policy. +- **Recovery-mode changes are retroactive.** Because the storage shape is fixed, changing `recoveryMode` or `claimerWhitelistId` changes who may claim existing blocked buckets. +- **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. -5. **Policy Existence**: `setAddressReceivePolicy` MUST revert if `receivePolicyId >= 2` and does not correspond to an existing policy. It MUST revert if `tokenSetId >= 2` and does not correspond to an existing token set. Built-in IDs (0 and 1) are always valid. +## 8. Invariants -6. **Simple Policy Constraint**: `setAddressReceivePolicy` MUST revert if `receivePolicyId` references a compound policy (type COMPOUND). The receive policy must be simple (WHITELIST or BLACKLIST) or a built-in (0 or 1). +The following invariants MUST always hold: -7. **Bit Flag Semantics**: When `hasReceivePolicy` (bit 0 of `addressPolicies`) is 0, the address has no address-level controls and is always authorized. When `hasReceivePolicy` is 1, the `receivePolicyId` and `tokenSetId` use standard TIP-403 semantics (0 = always-reject, 1 = always-allow). The `hasReceivePolicy` flag is set to 1 by `setAddressReceivePolicy` and cleared to 0 by `clearAddressReceivePolicy`. +1. For every TIP-20 token: + ```text + balances[ESCROW_ADDRESS] + = sum_over_receivers_and_originators(originatorEscrow[token][receiver][originator]) + ``` -8. **Composable Authorization (Transfers)**: A transfer is authorized if and only if ALL of the following are true: - - Token-level policy authorizes `from` as sender: `isAuthorizedSender(token.transferPolicyId, from)` - - Token-level policy authorizes `to` as recipient: `isAuthorizedRecipient(token.transferPolicyId, to)` - - Address-level controls pass: `to.hasReceivePolicy == false`, OR both: - - Receive policy authorizes sender: `receivePolicyId` is 1 (always-allow), or `from` is in/not-in the policy set per the policy type - - `isTokenAuthorized(to.tokenSetId, token)` returns true +2. Userland `transfer(..., ESCROW_ADDRESS)`, `transferFrom(..., ESCROW_ADDRESS)`, `mint(ESCROW_ADDRESS, ...)`, and `mintWithMemo(ESCROW_ADDRESS, ...)` MUST revert. -9. **Composable Authorization (Mints)**: A mint is authorized if and only if ALL of the following are true: - - Token-level policy authorizes `to` as mint recipient: `isAuthorizedMintRecipient(token.transferPolicyId, to)` - - Address-level controls pass: `to.hasReceivePolicy == false`, OR both: - - Receive policy authorizes minter: `receivePolicyId` is 1 (always-allow), or `minter` is in/not-in the policy set per the policy type - - `isTokenAuthorized(to.tokenSetId, token)` returns true +3. If token-level checks and sender-side or issuer-side checks pass, then a receiver-side address-policy failure MUST divert to escrow rather than revert. -10. **Mint Policy Check**: Minting operations MUST check both the recipient's receive policy (against the minter) and token set (against the token). This ensures a sanctioned party cannot bypass address-level controls by minting directly instead of transferring. +4. Token-level policy failure on the original transfer or mint path MUST still revert. -11. **Burn Exemption**: Burn operations (`burn`, `burnBlocked`) MUST NOT check address-level controls. +5. Every blocked inbound MUST update exactly one bucket: `originatorEscrow[token][receiver][originator]`. -12. **Fee Transfer Exemption**: Direct balance manipulations (`transferFeePreTx`, `transferFeePostTx`) MUST NOT check address-level controls. Fee distribution via `distribute_fees` goes through `ensureTransferAuthorized` and DOES check the validator's address-level controls. +6. Originator claims release only to the originator. Receiver and delegate claims release only to + the specified `to`. -13. **Receiver-Only Enforcement**: Address-level controls are checked ONLY on the receiver (`to`). The sender's `addressPolicies` slot is never read during authorization. +7. Claim release MUST bypass only the token-level sender check for `ESCROW_ADDRESS`; it MUST + still enforce token-level recipient authorization for the beneficiary. -14. **DEX Internal Balances**: DEX order fills that credit tokens to a maker's internal DEX balance (`increment_balance`) do NOT trigger address-level checks. Controls are enforced when tokens leave the DEX via `withdraw` or `transfer`. +8. A claim MUST revert with `ClaimDestinationUnauthorized()` if the beneficiary fails the + token-level recipient check or, when applicable, the destination's address-level receive + controls. -15. **Rewards Compliance**: `claimRewards` goes through `ensureTransferAuthorized(tokenAddress, caller)` and MUST check the caller's address-level controls. `setRewardRecipient(recipient)` goes through `ensureTransferAuthorized(holder, recipient)` and MUST check the recipient's address-level receive policy. +9. A receiver-side claim with `to != receiver` MUST enforce the destination's address-level + receive controls using `ESCROW_ADDRESS` as originator. -16. **Storage Efficiency**: Address policies MUST be stored in a single storage slot per address (packed flag, IDs, and types into 256 bits). +10. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT + be escrowed. -17. **Gas Consistency**: Reading `addressPolicies[address]` for a non-existent entry MUST return 0 (bit 0 = 0, interpreted as no controls set), incurring only the cold SLOAD cost (~2,100 gas). +11. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive + TIP-20 logic, including `burnBlocked`. -18. **Cached Type Validity**: The types cached in `addressPolicies` MUST always match the types in `policyData[policyId]` and `tokenSetData[tokenSetId]`. This is guaranteed because types are immutable after creation. +12. Claim-whitelist membership MUST be checked only on claim paths, not on the transfer or mint hot paths. -19. **Immutability Assumption**: This TIP assumes TIP-403 policies and token sets are never deleted and types are immutable. Any future TIP that allows deletion or type modification MUST address cache invalidation. +13. Escrow-related raw TIP-20 events MUST be truthful: + - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` + - blocked mint: `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -20. **Self-Transfer Behavior**: Self-transfers (`from == to`) are subject to the receiver's receive policy (checking self as sender). Addresses using whitelist receive policies SHOULD include themselves if self-transfers are required. +14. Every blocked inbound MUST emit exactly one attribution event naming the intended receiver, the block reason, and the applied recovery mode. That event's `blockedReason` MUST NOT be `NONE`. -21. **System Precompile Baseline Cost**: System precompile addresses (FeeManager, DEX, token contracts) do not set address-level policies. When they appear as `to` in `isAddressTransferAuthorized`, the `addressPolicies[to]` SLOAD returns 0 (bit 0 = 0) and the check passes immediately. This adds only the baseline ~2,100 gas cold SLOAD cost. +15. Reward accounting for blocked transfers and blocked mints MUST behave as if the raw recipient were `ESCROW_ADDRESS`. -22. **Token Set Admin-Only Modification**: Only the token set's admin can modify membership (`modifyTokenSetWhitelist`, `modifyTokenSetBlacklist`) and transfer admin (`setTokenSetAdmin`). These functions MUST revert if called by any other address. +16. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. From 339c0577a5f316da1965a5738470ce7821298516 Mon Sep 17 00:00:00 2001 From: malleshpai Date: Tue, 14 Apr 2026 00:28:45 -0400 Subject: [PATCH 03/59] minor edit --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 460f2b280d..9ed64f79f6 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -2,7 +2,7 @@ id: TIP-1028 title: Address-Level Receive Policies description: Extends TIP-403 with token sets, address-level receive controls, and escrowed handling for blocked TIP-20 inbounds. -authors: Mallesh Pai +authors: Mallesh Pai, 0xrusowsky status: Draft related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 protocolVersion: TBD From f415a852c5bf436c68ec9d5ec60da4f532ae1fa4 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Apr 2026 16:38:04 +0200 Subject: [PATCH 04/59] docs: clarify tip20 rewards behavior for `ESCROW_ADDRESS` --- tips/tip-1028.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 9ed64f79f6..4bd1743dd4 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -70,7 +70,8 @@ TIP-1028 does **not** alter: DEX internal balances are not TIP-20 wallet balances and are not subject to address-level receive checks until withdrawn back onto the TIP-20 ledger. -The reward subsystem remains opt-in and is excluded from receiver-side gating. A holder that does not want reward inflows can opt out by setting `rewardRecipient = Address::ZERO`. When a transfer or mint is escrowed, reward accounting MUST follow the raw ledger movement to `ESCROW_ADDRESS`. +The reward subsystem remains opt-in and is excluded from receiver-side gating. A holder that does not want reward inflows can opt out by setting `rewardRecipient = Address::ZERO`. +For reward accounting, `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. High-level flow: @@ -449,7 +450,7 @@ The escrow precompile MUST call an internal TIP-20 escrow-release path that: 3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)` 4. bypasses the token-level TIP-403 **sender** check for `ESCROW_ADDRESS` 5. still enforces the token-level TIP-403 **recipient** check for the beneficiary -6. updates reward accounting as if the transfer were `ESCROW_ADDRESS -> beneficiary`, with `ESCROW_ADDRESS` treated as permanently opted out of rewards +6. updates reward accounting as if the transfer were `ESCROW_ADDRESS -> beneficiary`, with `ESCROW_ADDRESS` treated as a reward-exempt always-opted-out synthetic source For originator self-claims, the beneficiary is fixed to `originator`. The beneficiary's address-level receive controls are bypassed because the beneficiary is explicitly accepting the release. @@ -536,7 +537,7 @@ For a transfer-like path: - credit `effectiveReceiver` - return success - if the inbound is blocked by the receiver: - - update rewards as if the raw recipient were `ESCROW_ADDRESS` + - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - call `recordBlockedInbound(token, from, effectiveReceiver, amount, blockedReason, recoveryMode, TRANSFER)` @@ -563,7 +564,7 @@ For a mint-like path: - credit `effectiveReceiver` - return success - if the inbound is blocked by the receiver: - - update rewards as if the raw recipient were `ESCROW_ADDRESS` + - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - call `recordBlockedInbound(token, originator, effectiveReceiver, amount, blockedReason, recoveryMode, MINT)` @@ -574,7 +575,7 @@ For a mint-like path: ### 5.3 Reward and Event Semantics -The reward subsystem is excluded from receiver-side gating, but blocked transfers and blocked mints MUST update reward accounting as if their raw recipient were `ESCROW_ADDRESS`. +The reward subsystem is excluded from receiver-side gating, but blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. Blocked inbounds MUST use truthful raw TIP-20 events: @@ -714,6 +715,6 @@ The following invariants MUST always hold: 14. Every blocked inbound MUST emit exactly one attribution event naming the intended receiver, the block reason, and the applied recovery mode. That event's `blockedReason` MUST NOT be `NONE`. -15. Reward accounting for blocked transfers and blocked mints MUST behave as if the raw recipient were `ESCROW_ADDRESS`. +15. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. 16. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. From 1d417746fa3d11434bdd9252aade08d980ed674d Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:56:44 -0400 Subject: [PATCH 05/59] docs(tip-1028): tighten escrow recovery design Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 403 ++++++++++++++++++++++++++++------------------- 1 file changed, 244 insertions(+), 159 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 4bd1743dd4..7055352e32 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -16,9 +16,9 @@ This TIP extends TIP-403 in three ways: 1. **Token sets** for TIP-20 token addresses. 2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. -3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked amount is recorded under `(token, receiver, originator)`. +3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked amount is recorded under `(token, receiver, originator, recoveryContract)`. -The design covers both transfer-like and mint-like inbound paths, so direct minting cannot bypass receiver controls. Claim rights over blocked funds are governed by a per-address recovery mode and an optional TIP-403 claimer whitelist. Receiver-side claims may reroute blocked funds to an arbitrary destination. +The design covers both transfer-like and mint-like inbound paths, so direct minting cannot bypass receiver controls. Claim rights over blocked funds are governed by a receiver-chosen recovery contract that is snapshotted into each blocked bucket. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. ## Motivation @@ -26,7 +26,7 @@ TIP-403 lets token issuers decide who may use a token. Some receivers also need A revert-based receiver policy creates a liveness problem. Once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. -This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. To keep storage growth bounded under Tempo's gas model, blocked funds are stored per-originator bucket rather than one receipt per blocked inbound. +This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. To keep storage growth bounded under Tempo's gas model, blocked funds are stored per `(originator, recoveryContract)` bucket rather than one receipt per blocked inbound. # Specification @@ -38,6 +38,7 @@ TIP-1028 applies to the following TIP-20 recipient-bearing paths: - `transferFrom` - `transferWithMemo` - `transferFromWithMemo` +- `systemTransferFrom` - `mint` - `mintWithMemo` - protocol withdrawals that execute as TIP-20 transfers from a concrete source balance @@ -55,7 +56,7 @@ If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur - TIP-1028 applies to the resolved master address, not the literal virtual address - if virtual-address resolution fails, the operation MUST revert rather than escrow -- blocked funds MUST be stored under `(token, resolvedMaster, originator)` +- blocked funds MUST be stored under `(token, resolvedMaster, originator, recoveryContract)` - if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics TIP-1028 does **not** alter: @@ -78,8 +79,8 @@ High-level flow: 1. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 checks. 2. Run the receiver's token-set and receive-policy checks. 3. If the inbound is authorized, credit the receiver normally. -4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, record `(token, receiver, originator)`, and emit a blocked-inbound event. -5. Later, the originator, the receiver, or an authorized delegate may claim according to the receiver's active recovery mode. +4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, record `(token, receiver, originator, recoveryContract)`, and emit a blocked-inbound event. +5. Later, the receiver or the blocked bucket's recovery contract may claim the funds. ## 2. Token Sets @@ -167,12 +168,11 @@ error InvalidTokenSetType(); ## 3. Address-Level Receive Controls -Any address MAY configure four fields: +Any address MAY configure three fields: 1. `receivePolicyId` — which originators may credit the address 2. `tokenSetId` — which TIP-20 token addresses may credit the address -3. `recoveryMode` — who may later claim blocked funds -4. `claimerWhitelistId` — optional TIP-403 whitelist for third-party claimers +3. `recoveryContract` — an optional address authorized to claim blocked funds on behalf of the receiver; `address(0)` means the receiver claims directly If an address has no configured receive controls, address-level authorization defaults to allow. @@ -188,23 +188,13 @@ TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. It MUST NOT reference a `COMPOUND` policy. -`claimerWhitelistId` MUST reference: +`recoveryContract`: -- a simple `WHITELIST` policy, or -- built-in policy `0` or `1` - -It MUST NOT reference a `BLACKLIST` or `COMPOUND` policy. - -### 3.2 Recovery Modes +- MAY be `address(0)` +- if nonzero, designates the sole direct claimer for future blocked buckets for this receiver +- MUST NOT equal `ESCROW_ADDRESS` -```solidity -enum RecoveryMode { - RECEIVER_ONLY, - RECEIVER_OR_CLAIMERS, - ORIGINATOR_OR_RECEIVER, - ORIGINATOR_OR_RECEIVER_OR_CLAIMERS -} -``` +### 3.2 Blocked Reason and Recovery Contract ```solidity enum BlockedReason { @@ -217,23 +207,13 @@ enum BlockedReason { `BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized. -All blocked funds use the same storage key: - -```text -(token, receiver, originator) -``` - -| Mode | Allowed claimers | -|------|------------------| -| `RECEIVER_ONLY` | receiver | -| `RECEIVER_OR_CLAIMERS` | receiver or a caller in `claimerWhitelistId` | -| `ORIGINATOR_OR_RECEIVER` | originator or receiver | -| `ORIGINATOR_OR_RECEIVER_OR_CLAIMERS` | originator, receiver, or a caller in `claimerWhitelistId` | +Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked-bucket key and governs later claims for that bucket. Changing `recoveryContract` affects only future blocked buckets. ### 3.3 Packed Storage ```solidity mapping(address => uint256) public addressReceiveConfig; +mapping(address => address) public addressRecoveryContract; ``` | Bits | Size | Field | @@ -242,13 +222,13 @@ mapping(address => uint256) public addressReceiveConfig; | `64..71` | 8 | cached `receivePolicyType` | | `72..135` | 64 | `tokenSetId` | | `136..143` | 8 | cached `tokenSetType` | -| `144..151` | 8 | `recoveryMode` | -| `152..215` | 64 | `claimerWhitelistId` | -| `216..254` | 39 | reserved, MUST be zero | +| `144..254` | 111 | reserved, MUST be zero | | `255` | 1 | `hasAddressPolicy` | When `hasAddressPolicy == 0`, the address is always authorized at the address level. The cached type fields are valid because policy type and token-set type are immutable after creation. +`recoveryContract` is stored separately because a 160-bit address does not fit in the packed config slot. + ### 3.4 Interface ```solidity @@ -256,8 +236,7 @@ interface IAddressReceivePolicies { function setAddressReceivePolicy( uint64 receivePolicyId, uint64 tokenSetId, - RecoveryMode recoveryMode, - uint64 claimerWhitelistId + address recoveryContract ) external; function addressReceivePolicy(address account) @@ -269,8 +248,7 @@ interface IAddressReceivePolicies { PolicyType receivePolicyType, uint64 tokenSetId, PolicyType tokenSetType, - RecoveryMode recoveryMode, - uint64 claimerWhitelistId + address recoveryContract ); function classifyAddressInbound(address token, address originator, address to) @@ -278,13 +256,13 @@ interface IAddressReceivePolicies { view returns ( bool authorized, - RecoveryMode recoveryMode, + address recoveryContract, BlockedReason blockedReason ); } ``` -Updating `recoveryMode` or `claimerWhitelistId` changes claim authorization for both future and existing blocked buckets. +Changing `recoveryContract` affects only future blocked buckets. Existing buckets remain governed by the `recoveryContract` recorded when they were blocked. ### 3.5 Authorization Logic @@ -293,28 +271,29 @@ Updating `recoveryMode` or `claimerWhitelistId` changes claim authorization for - read the packed config for `to` - if `hasAddressPolicy == 0`, return: - `authorized = true` - - `recoveryMode = RECEIVER_ONLY` + - `recoveryContract = address(0)` - `blockedReason = NONE` - otherwise: - - decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, `tokenSetType`, and `recoveryMode` + - decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType` + - read the current `addressRecoveryContract[to]` - evaluate whether `token` is allowed by the token set - evaluate whether `originator` is allowed by the receive policy - if both checks pass, return: - `authorized = true` + - the current `recoveryContract` - `blockedReason = NONE` - - the decoded `recoveryMode` - if both checks fail, return: - `authorized = false` + - the current `recoveryContract` - `blockedReason = TOKEN_SET_AND_RECEIVE_POLICY` - - the decoded `recoveryMode` - if only the token-set check fails, return: - `authorized = false` + - the current `recoveryContract` - `blockedReason = TOKEN_SET` - - the decoded `recoveryMode` - if only the receive-policy check fails, return: - `authorized = false` + - the current `recoveryContract` - `blockedReason = RECEIVE_POLICY` - - the decoded `recoveryMode` An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. @@ -325,12 +304,11 @@ event AddressReceivePolicyUpdated( address indexed account, uint64 receivePolicyId, uint64 tokenSetId, - RecoveryMode recoveryMode, - uint64 claimerWhitelistId + address recoveryContract ); error InvalidReceivePolicyType(); -error InvalidClaimerWhitelistType(); +error InvalidRecoveryContract(); ``` ## 4. Escrow Precompile @@ -343,17 +321,17 @@ ESCROW_ADDRESS = 0xFDC1000000000000000000000000000000000000 ### 4.1 Storage -```solidity -mapping(address => mapping(address => mapping(address => uint256))) internal originatorEscrow; -``` - -Interpretation: +Each blocked bucket is keyed logically by: ```text -originatorEscrow[token][receiver][originator] +(token, receiver, originator, recoveryContract) ``` -The same storage shape is used for blocked transfers and blocked mints. Only the definition of `originator` differs. +and stores a single `uint256 amount`. + +The same key shape is used for blocked transfers and blocked mints. Only the definition of `originator` differs. + +Because this is a precompile, implementations MAY realize this storage as a flat hash key over the four tuple components rather than as nested Solidity mappings. The precompile deliberately does **not** store: @@ -370,23 +348,23 @@ interface IBlockedInboundEscrow { MINT } - struct OriginatorClaimPart { + struct ClaimPart { address originator; uint256 amount; } - function originatorEscrowBalance(address token, address receiver, address originator) - external - view - returns (uint256); - - function claimOriginatorBucketToOriginator(address token, address receiver, uint256 amount) - external; + function blockedInboundBalance( + address token, + address receiver, + address originator, + address recoveryContract + ) external view returns (uint256); - function claimOriginatorBucketsTo( + function claimBlockedInbounds( address token, address receiver, - OriginatorClaimPart[] calldata parts, + address recoveryContract, + ClaimPart[] calldata parts, address to ) external; @@ -394,9 +372,9 @@ interface IBlockedInboundEscrow { address token, address originator, address receiver, + address recoveryContract, uint256 amount, BlockedReason blockedReason, - RecoveryMode recoveryMode, InboundKind kind ) external; } @@ -404,7 +382,7 @@ interface IBlockedInboundEscrow { `recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. -The precompile does not enumerate originators onchain. Receiver-side claimers MUST supply the buckets they want to consume, typically using logs or offchain indexing. +The precompile does not enumerate originators onchain. Claimers MUST supply the buckets they want to consume, typically using logs or offchain indexing. It MUST emit: @@ -413,35 +391,22 @@ It MUST emit: ### 4.3 Claim Authorization -Claim authorization is evaluated against the receiver's **current** `recoveryMode` and current `claimerWhitelistId`. Changing those fields therefore changes who may claim existing originator buckets. - -There are only two claim entrypoints: - -1. `claimOriginatorBucketToOriginator(...)` -2. `claimOriginatorBucketsTo(...)` +Each blocked bucket is governed by the `recoveryContract` recorded in its key at block time. -`claimOriginatorBucketToOriginator(...)`: +`claimBlockedInbounds(...)`: -- consumes only `(token, receiver, msg.sender)`; -- releases only to `msg.sender`; and -- is allowed only when the active recovery mode allows originator claims. +- consumes only the explicitly listed buckets `(token, receiver, originator_i, recoveryContract)` +- releases only to `to` +- MUST require `msg.sender == receiver` when `recoveryContract == address(0)` +- MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` -`claimOriginatorBucketsTo(...)`: +The originator addresses listed in `parts` are only bucket selectors. They do not grant claim rights by themselves. -- consumes only the explicitly listed buckets `(token, receiver, originator_i)`; -- releases only to `to`; -- is a receiver self-claim when `msg.sender == receiver`; and -- otherwise is a delegate claim, in which case `msg.sender` MUST satisfy - `isAuthorized(claimerWhitelistId, msg.sender)` whenever the active mode allows delegates. - -The originator addresses listed in `parts` are only bucket selectors. They do not make the caller an originator claimant. - -Claims use ordinary caller authentication. This TIP does not impose a special key-type rule on any claimer. Delegates may be EOAs, smart wallets, multisigs, or orchestration contracts. +If a receiver wants originator self-claim, delegate whitelists, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative baseline design. ### 4.4 Release Semantics -- originator claims release only to `originator` -- receiver and delegate claims release only to the specified `to` +All claims release only to the specified `to`. The escrow precompile MUST call an internal TIP-20 escrow-release path that: @@ -449,22 +414,24 @@ The escrow precompile MUST call an internal TIP-20 escrow-release path that: 2. credits the beneficiary 3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)` 4. bypasses the token-level TIP-403 **sender** check for `ESCROW_ADDRESS` -5. still enforces the token-level TIP-403 **recipient** check for the beneficiary -6. updates reward accounting as if the transfer were `ESCROW_ADDRESS -> beneficiary`, with `ESCROW_ADDRESS` treated as a reward-exempt always-opted-out synthetic source +5. treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source + +If `to == receiver`, the claim is an unwind of a previously authorized inbound to that receiver. It MUST: -For originator self-claims, the beneficiary is fixed to `originator`. The beneficiary's address-level receive controls are bypassed because the beneficiary is explicitly accepting the release. +- bypass the receiver's address-level receive controls +- bypass token-level recipient authorization for the receiver +- bypass AccountKeychain spending-limit metering -For receiver-side claims: +If `to != receiver`, the claim is a rerouted release. It MUST: -- if `to == receiver`, the receiver's address-level receive controls are bypassed because the - receiver is explicitly accepting the release; -- if `to != receiver`, the claim MUST enforce the destination's address-level receive controls - using `ESCROW_ADDRESS` as the originator. +- reject `to == ESCROW_ADDRESS` +- reject TIP-1022 virtual addresses as `to` +- enforce token-level transfer-recipient authorization for `to` +- enforce `to`'s address-level receive controls against each consumed bucket originator individually +- revert if any consumed originator fails the destination's address-level checks +- if `recoveryContract == address(0)`, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver -A claim MUST revert with `ClaimDestinationUnauthorized()` if the beneficiary fails the token-level -recipient check or, when applicable, the destination's address-level receive controls. -Receiver-side claims MUST NOT use `ESCROW_ADDRESS` itself as `to`. -Receiver-side claims MUST NOT use a TIP-1022 virtual address as `to`. +If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key policy is a userland concern. ### 4.5 Events and Errors @@ -472,32 +439,26 @@ Receiver-side claims MUST NOT use a TIP-1022 virtual address as `to`. event TransferBlocked( address indexed token, address indexed from, - address indexed intendedReceiver, + address indexed receiver, uint256 amount, BlockedReason blockedReason, - RecoveryMode recoveryMode + address recoveryContract ); event MintBlocked( address indexed token, address indexed operator, - address indexed intendedReceiver, + address indexed receiver, uint256 amount, BlockedReason blockedReason, - RecoveryMode recoveryMode + address recoveryContract ); -event OriginatorEscrowClaimedToOriginator( - address indexed token, - address indexed receiver, - address indexed originator, - uint256 amount -); - -event OriginatorEscrowClaimed( +event BlockedInboundClaimed( address indexed token, address indexed receiver, address indexed originator, + address recoveryContract, address caller, address to, uint256 amount @@ -509,7 +470,24 @@ error EscrowOnlyTIP20(); error ClaimDestinationUnauthorized(); ``` -For batched receiver-side claims, `OriginatorEscrowClaimed` MUST be emitted once per consumed bucket. +For batched claims, `BlockedInboundClaimed` MUST be emitted once per consumed bucket. + +### 4.6 Reference Recovery-Contract Pattern (Non-Normative) + +The protocol does not mandate any particular recovery-contract design. + +A minimal receiver-controlled pattern is: + +- the receiver sets `recoveryContract = address(thisContract)` in `setAddressReceivePolicy(...)` +- the recovery contract stores a single canonical `receiver` +- the recovery contract is the only direct caller of `claimBlockedInbounds(...)` for buckets keyed to that contract +- claim-to-receiver is the default path +- reroutes to `to != receiver` are optional and SHOULD be separately permissioned +- originator self-claim, if supported, SHOULD only allow an originator to claim its own bucket and only to itself + +If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until old buckets keyed to it are drained. + +Appendix A gives a non-normative Solidity reference design for this pattern. ## 5. TIP-20 Inbound Path Changes @@ -528,7 +506,7 @@ For a transfer-like path: - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` -- call `classifyAddressInbound(token, from, effectiveReceiver)` +- call `classifyAddressInbound(token, from, effectiveReceiver)` and capture `recoveryContract` - if the inbound is authorized: - follow the normal transfer path using `effectiveReceiver` - if `to` is virtual, use TIP-1022 forwarding event semantics @@ -540,7 +518,7 @@ For a transfer-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, amount, blockedReason, recoveryMode, TRANSFER)` + - call `recordBlockedInbound(token, from, effectiveReceiver, recoveryContract, amount, blockedReason, TRANSFER)` - emit `Transfer(from, ESCROW_ADDRESS, amount)` - return success @@ -555,7 +533,7 @@ For a mint-like path: - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` -- call `classifyAddressInbound(token, originator, effectiveReceiver)`, where `originator` is the mint caller +- call `classifyAddressInbound(token, originator, effectiveReceiver)` and capture `recoveryContract`, where `originator` is the mint caller - if the inbound is authorized: - follow the normal mint path using `effectiveReceiver` - if `to` is virtual, use TIP-1022 forwarding event semantics @@ -567,7 +545,7 @@ For a mint-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, amount, blockedReason, recoveryMode, MINT)` + - call `recordBlockedInbound(token, originator, effectiveReceiver, recoveryContract, amount, blockedReason, MINT)` - emit `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - return success @@ -584,8 +562,8 @@ Blocked inbounds MUST use truthful raw TIP-20 events: In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: -- `TransferBlocked(token, from, intendedReceiver, amount, blockedReason, recoveryMode)` -- `MintBlocked(token, operator, intendedReceiver, amount, blockedReason, recoveryMode)` +- `TransferBlocked(token, from, receiver, amount, blockedReason, recoveryContract)` +- `MintBlocked(token, operator, receiver, amount, blockedReason, recoveryContract)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. @@ -634,39 +612,38 @@ This section uses the gas model from TIP-1016: | Case | Rough cost | Notes | |------|------------|-------| -| first `setAddressReceivePolicy()` for an address | `~250k + call overhead` | one new packed config slot | +| first `setAddressReceivePolicy()` with `recoveryContract == address(0)` | `~250k + call overhead` | one new packed config slot | +| first `setAddressReceivePolicy()` with nonzero `recoveryContract` | `~500k + call overhead` | packed config slot plus recovery-contract slot | | allowed inbound to address with no receive config | current path + one cold config read | no escrow writes | | allowed inbound to configured address | current path + config read + token-set/policy membership reads | no escrow writes | -| first blocked inbound for fresh `(token, receiver, originator)` | `~300k` | `~50k` base path + one new escrow bucket | -| later blocked inbound to existing `(token, receiver, originator)` | `~53k` | base path + existing bucket update | +| first blocked inbound for fresh `(token, receiver, originator, recoveryContract)` | `~300k` | `~50k` base path + one new escrow bucket | +| later blocked inbound to existing `(token, receiver, originator, recoveryContract)` | `~53k` | base path + existing bucket update | | first blocked inbound ever for that token | add `~250k` | creates `balances[ESCROW_ADDRESS]` in that TIP-20 | -| originator self-claim to existing beneficiary balance slot | `~53k + auth reads` | one escrow-bucket update + one transfer from escrow | -| receiver/delegate claim over `N` buckets | `~50k + N*2.9k + auth reads` | one transfer from escrow + `N` bucket updates | +| receiver/recovery-contract claim over `N` buckets | `~50k + N*2.9k + auth reads` | one transfer from escrow + `N` bucket updates | ### 6.2 Storage Choices -The design intentionally stores only one blocked bucket per `(token, receiver, originator)`. It does not store: +The design intentionally stores only one blocked bucket per `(token, receiver, originator, recoveryContract)`. It does not store: - one receipt per blocked inbound - a receiver-wide aggregate total Under Tempo's pricing, either of those would often add another **250,000 gas** per fresh blocked relationship. -`claimerWhitelistId` is packed into the address config slot, so enabling delegates does not require a second per-address storage slot. If a receiver creates a new TIP-403 whitelist policy for claimers, that policy's storage costs are paid separately through TIP-403. +Implementations MAY preinitialize `balances[ESCROW_ADDRESS]` during token creation to move the first-ever blocked-inbound slot cost from the first live transfer to token deployment. ## 7. Security and Integration Considerations - **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. - **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-inbound events and claim from escrow SHOULD NOT opt in. -- **Token-level policy still controls who may hold the token.** Escrow only replaces receiver-side failure handling. Claim release bypasses the sender-side token check for `ESCROW_ADDRESS`, but still enforces token-level recipient authorization for the beneficiary. -- **Receiver-side claims may reroute.** A receiver or authorized delegate may release blocked - funds to any `to`, but rerouted claims must still satisfy token-level recipient authorization, - and rerouted claims to `to != receiver` must also satisfy that destination's address-level - receive controls. -- **Delegation is address-based.** Third-party claimers are authorized by `claimerWhitelistId`, not by signer arrays or threshold logic stored in escrow. If richer authorization is needed, the receiver SHOULD whitelist a smart wallet or multisig contract. -- **`claimerWhitelistId = 1` is dangerous.** Built-in whitelist policy `1` means any caller may act as an additional claimer and may choose the reroute destination. Production deployments SHOULD prefer a dedicated whitelist policy. -- **Recovery-mode changes are retroactive.** Because the storage shape is fixed, changing `recoveryMode` or `claimerWhitelistId` changes who may claim existing blocked buckets. +- **Claim-to-receiver is an unwind.** A claim back to `receiver` is not a new inbound and therefore bypasses the receiver's token-level and address-level receive checks. +- **Rerouted claims are new transfers.** A claim to `to != receiver` must satisfy token-level recipient authorization for `to` and that destination's address-level receive controls. +- **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked-bucket originator(s), not `ESCROW_ADDRESS`. +- **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute by `receiver` must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. +- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked buckets. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, or key-spend policy for that path becomes a userland concern of the recovery contract. +- **Recovery-contract changes are not retroactive.** Each blocked bucket is governed by the `recoveryContract` recorded when the inbound was blocked. +- **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked buckets keyed to that contract may become difficult or impossible to claim. - **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. ## 8. Invariants @@ -676,7 +653,9 @@ The following invariants MUST always hold: 1. For every TIP-20 token: ```text balances[ESCROW_ADDRESS] - = sum_over_receivers_and_originators(originatorEscrow[token][receiver][originator]) + = sum_over_receivers_originators_and_recoveryContracts( + blockedInbound[token][receiver][originator][recoveryContract] + ) ``` 2. Userland `transfer(..., ESCROW_ADDRESS)`, `transferFrom(..., ESCROW_ADDRESS)`, `mint(ESCROW_ADDRESS, ...)`, and `mintWithMemo(ESCROW_ADDRESS, ...)` MUST revert. @@ -685,36 +664,142 @@ The following invariants MUST always hold: 4. Token-level policy failure on the original transfer or mint path MUST still revert. -5. Every blocked inbound MUST update exactly one bucket: `originatorEscrow[token][receiver][originator]`. +5. Every blocked inbound MUST update exactly one bucket: + `blockedInbound[token][receiver][originator][recoveryContract]`. + +6. Only `receiver` may claim a bucket whose `recoveryContract == address(0)`. Only the bucket's + `recoveryContract` may claim a bucket whose `recoveryContract != address(0)`. -6. Originator claims release only to the originator. Receiver and delegate claims release only to - the specified `to`. +7. A claim to `receiver` MUST bypass the receiver's address-level receive controls and token-level + recipient authorization. -7. Claim release MUST bypass only the token-level sender check for `ESCROW_ADDRESS`; it MUST - still enforce token-level recipient authorization for the beneficiary. +8. A rerouted claim to `to != receiver` MUST enforce token-level recipient authorization for `to` + and MUST revert with `ClaimDestinationUnauthorized()` if it fails. -8. A claim MUST revert with `ClaimDestinationUnauthorized()` if the beneficiary fails the - token-level recipient check or, when applicable, the destination's address-level receive - controls. +9. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive + controls against each consumed bucket originator individually. -9. A receiver-side claim with `to != receiver` MUST enforce the destination's address-level - receive controls using `ESCROW_ADDRESS` as originator. +10. If `recoveryContract == address(0)` and `to != receiver`, the claim MUST meter the total + claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 + spend by the receiver. -10. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT +11. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT be escrowed. -11. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive +12. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive TIP-20 logic, including `burnBlocked`. -12. Claim-whitelist membership MUST be checked only on claim paths, not on the transfer or mint hot paths. +13. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked buckets. Existing + buckets remain governed by the recovery contract stored in their key. -13. Escrow-related raw TIP-20 events MUST be truthful: +14. Escrow-related raw TIP-20 events MUST be truthful: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - blocked mint: `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -14. Every blocked inbound MUST emit exactly one attribution event naming the intended receiver, the block reason, and the applied recovery mode. That event's `blockedReason` MUST NOT be `NONE`. +15. Every blocked inbound MUST emit exactly one attribution event naming the receiver, the block + reason, and the governing `recoveryContract`. That event's `blockedReason` MUST NOT be `NONE`. + +16. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat + `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same + opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT + create, update, or consult per-user reward state for `ESCROW_ADDRESS`. + +17. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when + `authorized == true`. + +## Appendix A. Solidity Reference Recovery Contract (Non-Normative) + +The following contract is illustrative only. It is not part of the protocol, and receivers may use any recovery contract or smart wallet that obeys the rules in Sections 4.3 and 4.4. + +```solidity +pragma solidity ^0.8.24; + +interface IBlockedInboundEscrowReference { + struct ClaimPart { + address originator; + uint256 amount; + } + + function claimBlockedInbounds( + address token, + address receiver, + address recoveryContract, + ClaimPart[] calldata parts, + address to + ) external; +} + +contract BasicBlockedInboundRecovery { + error Unauthorized(); + error OriginatorSelfClaimDisabled(); + error UseClaimToReceiver(); + + address public immutable receiver; + IBlockedInboundEscrowReference public immutable escrow; + + mapping(address => bool) public claimOperators; + mapping(address => bool) public rerouteOperators; + bool public originatorSelfClaimEnabled; + + constructor(address receiver_, address escrow_) { + receiver = receiver_; + escrow = IBlockedInboundEscrowReference(escrow_); + } + + function setClaimOperator(address operator, bool allowed) external { + if (msg.sender != receiver) revert Unauthorized(); + claimOperators[operator] = allowed; + } + + function setRerouteOperator(address operator, bool allowed) external { + if (msg.sender != receiver) revert Unauthorized(); + rerouteOperators[operator] = allowed; + } + + function setOriginatorSelfClaimEnabled(bool enabled) external { + if (msg.sender != receiver) revert Unauthorized(); + originatorSelfClaimEnabled = enabled; + } + + function claimToReceiver( + address token, + IBlockedInboundEscrowReference.ClaimPart[] calldata parts + ) external { + if (msg.sender != receiver && !claimOperators[msg.sender]) revert Unauthorized(); + escrow.claimBlockedInbounds(token, receiver, address(this), parts, receiver); + } + + function claimTo( + address token, + IBlockedInboundEscrowReference.ClaimPart[] calldata parts, + address to + ) external { + if (msg.sender != receiver && !rerouteOperators[msg.sender]) revert Unauthorized(); + if (to == receiver) revert UseClaimToReceiver(); + escrow.claimBlockedInbounds(token, receiver, address(this), parts, to); + } + + function claimOwnBucket(address token, uint256 amount) external { + if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); + + IBlockedInboundEscrowReference.ClaimPart[] + memory parts = new IBlockedInboundEscrowReference.ClaimPart[](1); + parts[0] = IBlockedInboundEscrowReference.ClaimPart({ + originator: msg.sender, + amount: amount + }); + + escrow.claimBlockedInbounds(token, receiver, address(this), parts, msg.sender); + } +} +``` + +This reference design intentionally does four things: -15. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +- it makes `receiver` the only configuration authority +- it separates claims back to `receiver` from reroutes to third parties +- it allows delegated claims without forcing that delegation logic into the protocol +- it makes originator self-claim, if enabled, explicit and narrowly scoped -16. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. +Receivers that need stronger policy MAY replace this with a multisig, smart wallet, timelock, or custom contract. If they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. From 209938796c355de7ebecb641ef6b44634a420f36 Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:19:21 -0400 Subject: [PATCH 06/59] docs(tip-1028): clarify blocked attribution and payout semantics Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 48 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 7055352e32..4710230058 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -20,6 +20,8 @@ This TIP extends TIP-403 in three ways: The design covers both transfer-like and mint-like inbound paths, so direct minting cannot bypass receiver controls. Claim rights over blocked funds are governed by a receiver-chosen recovery contract that is snapshotted into each blocked bucket. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. +These controls apply only to TIP-20 precompile flows. Ordinary ERC-20 contracts deployed on Tempo remain outside this TIP and may still transfer to any address under their own contract logic. + ## Motivation TIP-403 lets token issuers decide who may use a token. Some receivers also need their own inbound controls. @@ -57,6 +59,7 @@ If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur - TIP-1028 applies to the resolved master address, not the literal virtual address - if virtual-address resolution fails, the operation MUST revert rather than escrow - blocked funds MUST be stored under `(token, resolvedMaster, originator, recoveryContract)` +- blocked-inbound attribution events MUST preserve the literal virtual address as `requestedRecipient` so offchain systems can recover the TIP-1022 `userTag` even though escrow storage aggregates at `resolvedMaster` - if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics TIP-1028 does **not** alter: @@ -187,6 +190,7 @@ TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. - built-in policy `0` or `1` It MUST NOT reference a `COMPOUND` policy. +Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver. TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles, so they do not map cleanly onto this receiver-side originator check. `recoveryContract`: @@ -331,6 +335,8 @@ and stores a single `uint256 amount`. The same key shape is used for blocked transfers and blocked mints. Only the definition of `originator` differs. +For TIP-1022 virtual-address inbounds, `receiver` in the key is the resolved master address. Attribution to the literal virtual address is carried only in blocked-inbound events via `requestedRecipient`; it is not part of the bucket key. + Because this is a precompile, implementations MAY realize this storage as a flat hash key over the four tuple components rather than as nested Solidity mappings. The precompile deliberately does **not** store: @@ -372,6 +378,7 @@ interface IBlockedInboundEscrow { address token, address originator, address receiver, + address requestedRecipient, address recoveryContract, uint256 amount, BlockedReason blockedReason, @@ -382,6 +389,8 @@ interface IBlockedInboundEscrow { `recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. +`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. + The precompile does not enumerate originators onchain. Claimers MUST supply the buckets they want to consume, typically using logs or offchain indexing. It MUST emit: @@ -422,6 +431,8 @@ If `to == receiver`, the claim is an unwind of a previously authorized inbound t - bypass token-level recipient authorization for the receiver - bypass AccountKeychain spending-limit metering +This is intentional for TIP-1015 compound policies. A blocked mint has already satisfied the token's original `mint_recipient` authorization on the inbound path. Releasing that escrow back to the same `receiver` is an unwind of that previously authorized mint-like inbound, not a new transfer, so it MUST NOT be rechecked against transfer-recipient authorization. + If `to != receiver`, the claim is a rerouted release. It MUST: - reject `to == ESCROW_ADDRESS` @@ -440,6 +451,7 @@ event TransferBlocked( address indexed token, address indexed from, address indexed receiver, + address requestedRecipient, uint256 amount, BlockedReason blockedReason, address recoveryContract @@ -449,6 +461,7 @@ event MintBlocked( address indexed token, address indexed operator, address indexed receiver, + address requestedRecipient, uint256 amount, BlockedReason blockedReason, address recoveryContract @@ -518,7 +531,7 @@ For a transfer-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, recoveryContract, amount, blockedReason, TRANSFER)` + - call `recordBlockedInbound(token, from, effectiveReceiver, to, recoveryContract, amount, blockedReason, TRANSFER)` - emit `Transfer(from, ESCROW_ADDRESS, amount)` - return success @@ -545,7 +558,7 @@ For a mint-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, recoveryContract, amount, blockedReason, MINT)` + - call `recordBlockedInbound(token, originator, effectiveReceiver, to, recoveryContract, amount, blockedReason, MINT)` - emit `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - return success @@ -562,24 +575,27 @@ Blocked inbounds MUST use truthful raw TIP-20 events: In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: -- `TransferBlocked(token, from, receiver, amount, blockedReason, recoveryContract)` -- `MintBlocked(token, operator, receiver, amount, blockedReason, recoveryContract)` +- `TransferBlocked(token, from, receiver, requestedRecipient, amount, blockedReason, recoveryContract)` +- `MintBlocked(token, operator, receiver, requestedRecipient, amount, blockedReason, recoveryContract)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. +For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the escrow bucket. + ### 5.4 Tempo-Specific Protocol Interactions - **Stablecoin DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. -- **DEX wallet payouts remain ordinary TIP-20 transfers.** Swap outputs and DEX withdrawals that - transfer from the DEX address to a wallet remain subject to address-level receive controls and - may therefore be escrowed. +- **DEX wallet payouts remain ordinary TIP-20 transfers.** DEX `withdraw` calls and swap outputs + that transfer from the DEX address to a wallet remain subject to address-level receive controls + and may therefore be escrowed. - **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee - distributions, AMM burns, and AMM rebalance outputs remain subject to address-level receive - controls and may therefore be escrowed. -- **Higher-level protocol success events are unchanged.** A DEX, FeeManager, or AMM operation may - still emit its normal success event even if the final TIP-20 outbound was escrowed. Integrations - that care about wallet credit MUST also inspect `TransferBlocked` / `MintBlocked`. + distributions, AMM burns, and TIPFeeAMM outputs such as `rebalanceSwap` payouts remain subject + to address-level receive controls and may therefore be escrowed. +- **These protocol entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or + AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than + credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect + `TransferBlocked` / `MintBlocked` or wrap these calls with additional logic. - **Fee refunds are exempt.** `transfer_fee_post_tx` is a refund of the current transaction's unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive controls and MUST NOT be escrowed. @@ -593,7 +609,8 @@ In addition, every blocked inbound MUST emit exactly one attribution event from ### 5.5 Integration Consequence -After TIP-1028, `transfer`, `transferFrom`, `mint`, and `mintWithMemo` may succeed in either of two states: +After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and +FeeManager / TIPFeeAMM wallet payouts may succeed in either of two states: 1. the intended receiver was credited; or 2. the inbound was escrowed. @@ -698,7 +715,10 @@ The following invariants MUST always hold: - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` 15. Every blocked inbound MUST emit exactly one attribution event naming the receiver, the block - reason, and the governing `recoveryContract`. That event's `blockedReason` MUST NOT be `NONE`. + reason, the governing `recoveryContract`, and the literal `requestedRecipient`. For TIP-1022 + virtual-address inbounds, `requestedRecipient` MUST preserve the literal virtual address even + though the escrow bucket is keyed to the resolved master receiver. That event's `blockedReason` + MUST NOT be `NONE`. 16. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same From d94c9b95308c8ac5ea9e6c0f22b4df3a284d8e4d Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:47:06 -0400 Subject: [PATCH 07/59] docs(tip-1028): finalize receipt-based receive policies Co-authored-by: malleshpai <5857042+malleshpai@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab7c-efa4-7608-9058-7380358fbdb7 Co-authored-by: Amp --- tips/tip-1028.md | 473 ++++++++++++++++++++++++++++------------------- 1 file changed, 286 insertions(+), 187 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 4710230058..d5a8270d73 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -1,7 +1,7 @@ --- id: TIP-1028 title: Address-Level Receive Policies -description: Extends TIP-403 with token sets, address-level receive controls, and escrowed handling for blocked TIP-20 inbounds. +description: Extends TIP-403 with token sets, address-level receive controls, and receipt-based escrow for blocked TIP-20 inbounds. authors: Mallesh Pai, 0xrusowsky status: Draft related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 @@ -16,9 +16,11 @@ This TIP extends TIP-403 in three ways: 1. **Token sets** for TIP-20 token addresses. 2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. -3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked amount is recorded under `(token, receiver, originator, recoveryContract)`. +3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt. -The design covers both transfer-like and mint-like inbound paths, so direct minting cannot bypass receiver controls. Claim rights over blocked funds are governed by a receiver-chosen recovery contract that is snapshotted into each blocked bucket. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. +Each blocked receipt snapshots the receiver, the literal requested recipient, the real originator, the governing recovery contract, the amount, and per-transfer metadata such as timestamp, memo, and in-transaction ordering. This preserves attribution for TIP-1022 virtual addresses and keeps mint-like and transfer-like blocked inbounds distinguishable offchain. + +Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. Claim-to-receiver is an unwind, not a new inbound. Reroutes are new spends and must satisfy the same recipient-side and spending-limit rules that would apply to an ordinary outbound transfer. These controls apply only to TIP-20 precompile flows. Ordinary ERC-20 contracts deployed on Tempo remain outside this TIP and may still transfer to any address under their own contract logic. @@ -28,7 +30,11 @@ TIP-403 lets token issuers decide who may use a token. Some receivers also need A revert-based receiver policy creates a liveness problem. Once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. -This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. To keep storage growth bounded under Tempo's gas model, blocked funds are stored per `(originator, recoveryContract)` bucket rather than one receipt per blocked inbound. +This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. + +The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, and offchain recovery flows may need the original timestamp and sender identity. A per-transfer receipt model preserves that data directly. Aggregate buckets do not. + +This TIP also avoids creating a new TIP-20 variant. Address-level receive policies remain an extension to TIP-403 and TIP-20 rather than a separate token standard. # Specification @@ -50,17 +56,17 @@ For all such paths, TIP-1028 adds a receiver-side authorization layer: - the receiver's token set is checked against the TIP-20 token address; and - the receiver's receive policy is checked against the inbound **originator**: - `from` for transfer-like paths; - - the mint caller for `mint` / `mintWithMemo`. + - the mint caller for `mint` and `mintWithMemo`. -If a token-level TIP-403 policy rejects the operation, the operation MUST revert exactly as it does today. If the receiver's address-level controls reject the inbound, the operation MUST be escrowed instead. +If a token-level TIP-403 or TIP-1015 policy rejects the operation, the operation MUST revert exactly as it does today. If the receiver's address-level controls reject the inbound, the operation MUST be escrowed instead. If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur before TIP-1028 receiver-side authorization. In that case: -- TIP-1028 applies to the resolved master address, not the literal virtual address -- if virtual-address resolution fails, the operation MUST revert rather than escrow -- blocked funds MUST be stored under `(token, resolvedMaster, originator, recoveryContract)` -- blocked-inbound attribution events MUST preserve the literal virtual address as `requestedRecipient` so offchain systems can recover the TIP-1022 `userTag` even though escrow storage aggregates at `resolvedMaster` -- if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics +- TIP-1028 applies to the resolved master address, not the literal virtual address; +- if virtual-address resolution fails, the operation MUST revert rather than escrow; +- the blocked receipt's `receiver` MUST be the resolved master address; +- the blocked receipt's `requestedRecipient` MUST be the literal `to` address so offchain systems can recover the TIP-1022 `userTag`; and +- if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics. TIP-1028 does **not** alter: @@ -68,31 +74,35 @@ TIP-1028 does **not** alter: - `permit` - `burn` - fee refunds via `transfer_fee_post_tx` -- the opt-in reward subsystem (`distributeReward`, `setRewardRecipient`, `claimRewards`) - non-TIP-20 tokens deployed as ordinary contracts - future recipient-bearing system-credit paths that do not identify a concrete originator DEX internal balances are not TIP-20 wallet balances and are not subject to address-level receive checks until withdrawn back onto the TIP-20 ledger. -The reward subsystem remains opt-in and is excluded from receiver-side gating. A holder that does not want reward inflows can opt out by setting `rewardRecipient = Address::ZERO`. +The reward subsystem is not receiver-side escrowable, but it is not exempt from recipient consent: + +- `distributeReward` remains token-internal accounting; +- `setRewardRecipient` MUST reject a nonzero recipient whose address-level receive controls would block the holder as originator; and +- `claimRewards` MUST revalidate the actual payout recipient's address-level receive controls against the token contract as originator and MUST revert on failure rather than escrow. + For reward accounting, `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. High-level flow: -1. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 checks. +1. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 and TIP-1015 checks. 2. Run the receiver's token-set and receive-policy checks. 3. If the inbound is authorized, credit the receiver normally. -4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, record `(token, receiver, originator, recoveryContract)`, and emit a blocked-inbound event. -5. Later, the receiver or the blocked bucket's recovery contract may claim the funds. +4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, create one blocked receipt, and emit a blocked-inbound event naming both `receiver` and `requestedRecipient`. +5. Later, the receiver or the receipt's recovery contract may claim the funds. ## 2. Token Sets -Token sets are a new TIP-403 primitive for TIP-20 token addresses. They answer a different question from address policies: +Token sets are a dedicated TIP-403 primitive for TIP-20 token addresses. They answer a different question from address policies: -- address policy: “is this originator authorized?” -- token set: “is this token authorized?” +- address policy: "is this originator authorized?" +- token set: "is this token authorized?" -Token sets use a separate ID space from policy IDs. +Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists, and they do not reuse the compound-policy surface. They do, however, mirror the ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. ### 2.1 Storage and Constraints @@ -135,8 +145,22 @@ interface ITIP403TokenSets { ) external returns (uint64 newTokenSetId); function setTokenSetAdmin(uint64 tokenSetId, address admin) external; + function modifyTokenSetWhitelist(uint64 tokenSetId, address token, bool allowed) external; function modifyTokenSetBlacklist(uint64 tokenSetId, address token, bool restricted) external; + + function modifyTokenSetWhitelistBatch( + uint64 tokenSetId, + address[] calldata tokens, + bool[] calldata allowed + ) external; + + function modifyTokenSetBlacklistBatch( + uint64 tokenSetId, + address[] calldata tokens, + bool[] calldata restricted + ) external; + function isTokenAuthorized(uint64 tokenSetId, address token) external view returns (bool); function tokenSetExists(uint64 tokenSetId) external view returns (bool); function tokenSetData(uint64 tokenSetId) @@ -146,6 +170,8 @@ interface ITIP403TokenSets { } ``` +`createTokenSetWithTokens(...)` is the token-set analogue of `createPolicyWithAccounts(...)`. The batch mutation functions are the token-set analogue of TIP-403 batch list updates for ordinary allowlists and denylists. + ### 2.3 Authorization Logic `isTokenAuthorized(tokenSetId, token)` MUST behave as follows: @@ -157,7 +183,19 @@ interface ITIP403TokenSets { - for a `WHITELIST`, return the stored membership bit for `token` - for a `BLACKLIST`, return the negation of the stored membership bit for `token` -### 2.4 Events and Errors +### 2.4 Batch Update Semantics + +For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`: + +- `tokens.length` and the corresponding boolean array length MUST match +- the caller authorization and policy-type checks are identical to the single-entry mutation functions +- the update MUST apply entries in order +- the call MUST be atomic +- the implementation MUST emit the ordinary per-token update event once for each touched token, rather than a separate batch-only event + +If TIP-403 standardizes a different canonical batch-list ABI before TIP-1028 is finalized, token sets SHOULD adopt that same ABI shape mutatis mutandis while preserving the semantics above. + +### 2.5 Events and Errors ```solidity event TokenSetCreated(uint64 indexed tokenSetId, address indexed creator, PolicyType setType); @@ -167,15 +205,16 @@ event TokenSetBlacklistUpdated(uint64 indexed tokenSetId, address indexed update error TokenSetNotFound(); error InvalidTokenSetType(); +error TokenSetBatchLengthMismatch(); ``` ## 3. Address-Level Receive Controls Any address MAY configure three fields: -1. `receivePolicyId` — which originators may credit the address -2. `tokenSetId` — which TIP-20 token addresses may credit the address -3. `recoveryContract` — an optional address authorized to claim blocked funds on behalf of the receiver; `address(0)` means the receiver claims directly +1. `receivePolicyId` - which originators may credit the address +2. `tokenSetId` - which TIP-20 token addresses may credit the address +3. `recoveryContract` - an optional address authorized to claim blocked receipts on behalf of the receiver; `address(0)` means the receiver claims directly If an address has no configured receive controls, address-level authorization defaults to allow. @@ -189,13 +228,12 @@ TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. - a simple `BLACKLIST` policy, or - built-in policy `0` or `1` -It MUST NOT reference a `COMPOUND` policy. -Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver. TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles, so they do not map cleanly onto this receiver-side originator check. +It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis - whether a given inbound originator may credit the receiver. TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles, so they do not map cleanly onto this receiver-side originator check. `recoveryContract`: - MAY be `address(0)` -- if nonzero, designates the sole direct claimer for future blocked buckets for this receiver +- if nonzero, designates the sole direct claimer for future blocked receipts for this receiver - MUST NOT equal `ESCROW_ADDRESS` ### 3.2 Blocked Reason and Recovery Contract @@ -211,7 +249,7 @@ enum BlockedReason { `BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized. -Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked-bucket key and governs later claims for that bucket. Changing `recoveryContract` affects only future blocked buckets. +Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. ### 3.3 Packed Storage @@ -266,7 +304,7 @@ interface IAddressReceivePolicies { } ``` -Changing `recoveryContract` affects only future blocked buckets. Existing buckets remain governed by the `recoveryContract` recorded when they were blocked. +Changing `recoveryContract` affects only future blocked receipts. Existing receipts remain governed by the `recoveryContract` recorded when they were created. ### 3.5 Authorization Logic @@ -317,7 +355,7 @@ error InvalidRecoveryContract(); ## 4. Escrow Precompile -Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only blocked-bucket accounting. +Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores receipt metadata and claimability. ```solidity ESCROW_ADDRESS = 0xFDC1000000000000000000000000000000000000 @@ -325,25 +363,52 @@ ESCROW_ADDRESS = 0xFDC1000000000000000000000000000000000000 ### 4.1 Storage -Each blocked bucket is keyed logically by: +Each blocked inbound creates exactly one receipt. + +```solidity +uint64 public blockedReceiptIdCounter = 1; + +struct BlockedReceipt { + address token; + address receiver; + address requestedRecipient; + address originator; + address recoveryContract; + uint256 amount; + uint64 blockedAt; + uint32 blockedIndex; + BlockedReason blockedReason; + InboundKind kind; + bytes memo; +} -```text -(token, receiver, originator, recoveryContract) +mapping(uint64 => BlockedReceipt) internal blockedReceipts; ``` -and stores a single `uint256 amount`. +Receipt fields have the following meanings: -The same key shape is used for blocked transfers and blocked mints. Only the definition of `originator` differs. +- `receiver`: the canonical TIP-20 holder that owns the blocked receipt +- `requestedRecipient`: the literal `to` address from the original inbound, preserving TIP-1022 user-tag attribution when applicable +- `originator`: `from` for transfers, mint caller for mints +- `recoveryContract`: the receiver's snapshotted recovery contract, or `address(0)` +- `amount`: the remaining unclaimed amount for that receipt +- `blockedAt`: the block timestamp when the receipt was created +- `blockedIndex`: the in-transaction ordinal among blocked inbounds in that transaction +- `blockedReason`: why the inbound was escrowed +- `kind`: `TRANSFER` or `MINT` +- `memo`: the original memo payload for memo-bearing paths, or empty bytes otherwise -For TIP-1022 virtual-address inbounds, `receiver` in the key is the resolved master address. Attribution to the literal virtual address is carried only in blocked-inbound events via `requestedRecipient`; it is not part of the bucket key. +For non-memo-bearing paths, `memo` MUST be empty. `blockedIndex` MUST be assigned monotonically within the enclosing transaction so multiple blocked receipts created in the same transaction remain distinguishable even if every other field matches. -Because this is a precompile, implementations MAY realize this storage as a flat hash key over the four tuple components rather than as nested Solidity mappings. +For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. + +Because this is a precompile, implementations MAY realize this storage with any internal layout that preserves the same externally visible semantics. The precompile deliberately does **not** store: -- one receipt per blocked inbound -- receiver-wide aggregate totals +- receiver-wide aggregate balances - signer lists or multisig state +- any global singleton recovery-policy state ### 4.2 Interface @@ -354,23 +419,21 @@ interface IBlockedInboundEscrow { MINT } - struct ClaimPart { - address originator; + struct ClaimReceipt { + uint64 receiptId; uint256 amount; } - function blockedInboundBalance( - address token, - address receiver, - address originator, - address recoveryContract - ) external view returns (uint256); + function blockedReceipt(uint64 receiptId) + external + view + returns (BlockedReceipt memory receipt); - function claimBlockedInbounds( + function claimBlockedReceipts( address token, address receiver, address recoveryContract, - ClaimPart[] calldata parts, + ClaimReceipt[] calldata receipts, address to ) external; @@ -382,8 +445,10 @@ interface IBlockedInboundEscrow { address recoveryContract, uint256 amount, BlockedReason blockedReason, - InboundKind kind - ) external; + InboundKind kind, + bytes calldata memo, + uint32 blockedIndex + ) external returns (uint64 receiptId); } ``` @@ -391,27 +456,24 @@ interface IBlockedInboundEscrow { `requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. -The precompile does not enumerate originators onchain. Claimers MUST supply the buckets they want to consume, typically using logs or offchain indexing. - -It MUST emit: - -- `TransferBlocked` when `kind == InboundKind.TRANSFER` -- `MintBlocked` when `kind == InboundKind.MINT` +The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the receipts they want to consume, typically using logs or offchain indexing. ### 4.3 Claim Authorization -Each blocked bucket is governed by the `recoveryContract` recorded in its key at block time. +Each blocked receipt is governed by the `recoveryContract` recorded in that receipt at block time. -`claimBlockedInbounds(...)`: +`claimBlockedReceipts(...)`: -- consumes only the explicitly listed buckets `(token, receiver, originator_i, recoveryContract)` +- consumes only the explicitly listed receipts - releases only to `to` - MUST require `msg.sender == receiver` when `recoveryContract == address(0)` - MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` +- MUST require every listed receipt to match the supplied `token`, `receiver`, and `recoveryContract` +- MAY partially consume a receipt, but MUST reject `amount == 0` or `amount > blockedReceipts[receiptId].amount` -The originator addresses listed in `parts` are only bucket selectors. They do not grant claim rights by themselves. +The listed receipt IDs are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. -If a receiver wants originator self-claim, delegate whitelists, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative baseline design. +If a receiver wants delegate whitelists, originator self-claim, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative standard pattern. ### 4.4 Release Semantics @@ -422,7 +484,7 @@ The escrow precompile MUST call an internal TIP-20 escrow-release path that: 1. debits `balances[ESCROW_ADDRESS]` 2. credits the beneficiary 3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -4. bypasses the token-level TIP-403 **sender** check for `ESCROW_ADDRESS` +4. bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS` 5. treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source If `to == receiver`, the claim is an unwind of a previously authorized inbound to that receiver. It MUST: @@ -438,9 +500,9 @@ If `to != receiver`, the claim is a rerouted release. It MUST: - reject `to == ESCROW_ADDRESS` - reject TIP-1022 virtual addresses as `to` - enforce token-level transfer-recipient authorization for `to` -- enforce `to`'s address-level receive controls against each consumed bucket originator individually -- revert if any consumed originator fails the destination's address-level checks -- if `recoveryContract == address(0)`, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver +- enforce `to`'s address-level receive controls against each consumed receipt's `originator` +- revert if any consumed receipt fails the destination's address-level checks +- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key policy is a userland concern. @@ -451,6 +513,7 @@ event TransferBlocked( address indexed token, address indexed from, address indexed receiver, + uint64 receiptId, address requestedRecipient, uint256 amount, BlockedReason blockedReason, @@ -461,16 +524,19 @@ event MintBlocked( address indexed token, address indexed operator, address indexed receiver, + uint64 receiptId, address requestedRecipient, uint256 amount, BlockedReason blockedReason, address recoveryContract ); -event BlockedInboundClaimed( +event BlockedReceiptClaimed( address indexed token, address indexed receiver, - address indexed originator, + uint64 indexed receiptId, + address originator, + address requestedRecipient, address recoveryContract, address caller, address to, @@ -481,24 +547,26 @@ error UnauthorizedClaimer(); error InsufficientEscrowBalance(); error EscrowOnlyTIP20(); error ClaimDestinationUnauthorized(); +error InvalidReceiptClaim(); ``` -For batched claims, `BlockedInboundClaimed` MUST be emitted once per consumed bucket. +For batched claims, `BlockedReceiptClaimed` MUST be emitted once per consumed receipt. -### 4.6 Reference Recovery-Contract Pattern (Non-Normative) +### 4.6 Standard Recovery Contract Pattern (Non-Normative) The protocol does not mandate any particular recovery-contract design. -A minimal receiver-controlled pattern is: +The standard pattern is a reusable receiver-owned implementation, not a global singleton: -- the receiver sets `recoveryContract = address(thisContract)` in `setAddressReceivePolicy(...)` -- the recovery contract stores a single canonical `receiver` -- the recovery contract is the only direct caller of `claimBlockedInbounds(...)` for buckets keyed to that contract -- claim-to-receiver is the default path +- the receiver deploys its own instance, proxy, or smart-wallet module +- the receiver sets `recoveryContract` to that instance in `setAddressReceivePolicy(...)` +- the recovery contract stores one canonical `receiver` +- the recovery contract is the only direct caller of `claimBlockedReceipts(...)` for receipts keyed to that contract +- `claimToReceiver(...)` is the default unwind path - reroutes to `to != receiver` are optional and SHOULD be separately permissioned -- originator self-claim, if supported, SHOULD only allow an originator to claim its own bucket and only to itself +- originator self-claim, if supported, SHOULD only allow an originator to claim receipts whose `originator` equals that caller, and only to itself -If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until old buckets keyed to it are drained. +If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained. Appendix A gives a non-normative Solidity reference design for this pattern. @@ -514,11 +582,12 @@ error EscrowAddressReserved(); For a transfer-like path: -- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 checks +- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 and TIP-1015 checks - if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` +- set `requestedRecipient = to` - call `classifyAddressInbound(token, from, effectiveReceiver)` and capture `recoveryContract` - if the inbound is authorized: - follow the normal transfer path using `effectiveReceiver` @@ -531,11 +600,12 @@ For a transfer-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, to, recoveryContract, amount, blockedReason, TRANSFER)` + - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo, blockedIndex)` and capture `receiptId` - emit `Transfer(from, ESCROW_ADDRESS, amount)` + - emit `TransferBlocked(token, from, effectiveReceiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` - return success -Memo-bearing transfer variants MUST follow the same ledger rules. If blocked, their raw recipient in memo-bearing events MUST be `ESCROW_ADDRESS`. +Memo-bearing transfer variants MUST preserve the original memo in the blocked receipt. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. ### 5.2 Mint-like Paths @@ -546,6 +616,7 @@ For a mint-like path: - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` +- set `requestedRecipient = to` - call `classifyAddressInbound(token, originator, effectiveReceiver)` and capture `recoveryContract`, where `originator` is the mint caller - if the inbound is authorized: - follow the normal mint path using `effectiveReceiver` @@ -558,64 +629,67 @@ For a mint-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, to, recoveryContract, amount, blockedReason, MINT)` - - emit `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo, blockedIndex)` and capture `receiptId` + - emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + - emit `MintBlocked(token, originator, effectiveReceiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` - return success -`mintWithMemo` MUST follow the same ledger rules. If blocked, the raw recipient in externally visible mint-related events MUST be `ESCROW_ADDRESS`. +`mintWithMemo` MUST preserve the original memo in the blocked receipt. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. + +### 5.3 Reward Delegation and Claims + +Reward flows are never escrowed, but they MUST respect recipient consent. + +For `setRewardRecipient(holder, recipient)`: + +- `recipient == address(0)` remains the opt-out path and bypasses receive-policy checks +- otherwise the token MUST call `classifyAddressInbound(token, holder, recipient)` +- if that check is unauthorized, the call MUST revert + +For `claimRewards(...)`: -### 5.3 Reward and Event Semantics +- the token MUST determine the actual payout recipient under its existing reward rules +- it MUST revalidate that recipient with `classifyAddressInbound(token, address(token), recipient)` before crediting rewards +- if that check is unauthorized, the call MUST revert rather than escrow -The reward subsystem is excluded from receiver-side gating, but blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. +These rules prevent a holder from routing rewards to an unwilling recipient and prevent reward claims from bypassing address-level receive policies. + +### 5.4 Reward and Event Semantics + +Blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. Blocked inbounds MUST use truthful raw TIP-20 events: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` -- blocked mint: `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` +- blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: -- `TransferBlocked(token, from, receiver, requestedRecipient, amount, blockedReason, recoveryContract)` -- `MintBlocked(token, operator, receiver, requestedRecipient, amount, blockedReason, recoveryContract)` +- `TransferBlocked(token, from, receiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` +- `MintBlocked(token, operator, receiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. -For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the escrow bucket. - -### 5.4 Tempo-Specific Protocol Interactions - -- **Stablecoin DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 - wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. -- **DEX wallet payouts remain ordinary TIP-20 transfers.** DEX `withdraw` calls and swap outputs - that transfer from the DEX address to a wallet remain subject to address-level receive controls - and may therefore be escrowed. -- **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee - distributions, AMM burns, and TIPFeeAMM outputs such as `rebalanceSwap` payouts remain subject - to address-level receive controls and may therefore be escrowed. -- **These protocol entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or - AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than - credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect - `TransferBlocked` / `MintBlocked` or wrap these calls with additional logic. -- **Fee refunds are exempt.** `transfer_fee_post_tx` is a refund of the current transaction's - unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive - controls and MUST NOT be escrowed. -- **Rewards are exempt.** `distributeReward`, `setRewardRecipient`, and `claimRewards` remain - outside receiver-side gating as described above. -- **Blocked memo-bearing inbounds keep their raw memo event.** The raw `TransferWithMemo` or - mint-related memo event still names `ESCROW_ADDRESS`; receivers that care about memo-based - routing MUST correlate it with `TransferBlocked` / `MintBlocked` in the same transaction. -- **`ESCROW_ADDRESS` is a protected system address.** Any TIP-20 logic that protects DEX or - FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. - -### 5.5 Integration Consequence - -After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and -FeeManager / TIPFeeAMM wallet payouts may succeed in either of two states: +For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the receipt. + +### 5.5 Tempo-Specific Protocol Interactions + +- **Stablecoin DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. +- **DEX wallet payouts remain ordinary TIP-20 transfers.** DEX `withdraw` calls and swap outputs that transfer from the DEX address to a wallet remain subject to address-level receive controls and may therefore be escrowed. +- **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee distributions, AMM burns, and TIPFeeAMM outputs such as `rebalanceSwap` payouts remain subject to address-level receive controls and may therefore be escrowed. +- **These protocol entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect `TransferBlocked` or `MintBlocked` or wrap these calls with additional logic. +- **Fee refunds are exempt.** `transfer_fee_post_tx` is a refund of the current transaction's unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive controls and MUST NOT be escrowed. +- **Blocked memo-bearing inbounds keep their raw memo event.** The raw memo-bearing event still names `ESCROW_ADDRESS`; receivers that care about memo-based routing MUST correlate it with the blocked-receipt event in the same transaction. +- **`ESCROW_ADDRESS` is a protected system address.** Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. + +### 5.6 Integration Consequence + +After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager or TIPFeeAMM wallet payouts may succeed in either of two states: 1. the intended receiver was credited; or 2. the inbound was escrowed. -Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` / `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. +Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` or `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. ## 6. Gas and Storage Analysis @@ -632,35 +706,48 @@ This section uses the gas model from TIP-1016: | first `setAddressReceivePolicy()` with `recoveryContract == address(0)` | `~250k + call overhead` | one new packed config slot | | first `setAddressReceivePolicy()` with nonzero `recoveryContract` | `~500k + call overhead` | packed config slot plus recovery-contract slot | | allowed inbound to address with no receive config | current path + one cold config read | no escrow writes | -| allowed inbound to configured address | current path + config read + token-set/policy membership reads | no escrow writes | -| first blocked inbound for fresh `(token, receiver, originator, recoveryContract)` | `~300k` | `~50k` base path + one new escrow bucket | -| later blocked inbound to existing `(token, receiver, originator, recoveryContract)` | `~53k` | base path + existing bucket update | -| first blocked inbound ever for that token | add `~250k` | creates `balances[ESCROW_ADDRESS]` in that TIP-20 | -| receiver/recovery-contract claim over `N` buckets | `~50k + N*2.9k + auth reads` | one transfer from escrow + `N` bucket updates | +| allowed inbound to configured address | current path + config read + token-set or policy membership reads | no escrow writes | +| blocked inbound after escrow balance slot is preinitialized | current path + receipt writes | exact cost depends on memo length and receipt implementation | +| blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | +| claim over `N` receipts | one transfer from escrow + `N` receipt updates or deletes + auth reads | partial claims keep the receipt; full claims delete it | ### 6.2 Storage Choices -The design intentionally stores only one blocked bucket per `(token, receiver, originator, recoveryContract)`. It does not store: +The design intentionally stores one receipt per blocked inbound. That is more expensive than an aggregate bucket, but it preserves: + +- the literal `requestedRecipient` needed for TIP-1022 attribution +- the original `originator` +- per-transfer memo data +- block timestamp and in-transaction ordering +- the distinction between transfer-blocked and mint-blocked funds -- one receipt per blocked inbound -- a receiver-wide aggregate total +Those fields are not reconstructible from a receiver-wide or originator-wide aggregate without losing important semantics. -Under Tempo's pricing, either of those would often add another **250,000 gas** per fresh blocked relationship. +### 6.3 Escrow Slot Strategy -Implementations MAY preinitialize `balances[ESCROW_ADDRESS]` during token creation to move the first-ever blocked-inbound slot cost from the first live transfer to token deployment. +The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly `250,000` gas to the first blocked live transfer. + +TIP-20 implementations SHOULD move that cost to deployment. One acceptable pattern is to create a non-user-claimable implementation-private escrow reserve at deployment so the `ESCROW_ADDRESS` balance slot is already live before any blocked inbound occurs. Implementations MAY use any equivalent deployment-time mechanism instead. + +If an implementation uses such a reserve: + +- it MUST be created at token deployment time +- it MUST NOT correspond to any blocked receipt +- it MUST NOT be claimable by users or recovery contracts +- release and burn logic MUST preserve it as implementation-private state ## 7. Security and Integration Considerations - **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. -- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. - Contracts that are not explicitly built to inspect blocked-inbound events and claim from escrow SHOULD NOT opt in. +- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-receipt events and claim from escrow SHOULD NOT opt in. - **Claim-to-receiver is an unwind.** A claim back to `receiver` is not a new inbound and therefore bypasses the receiver's token-level and address-level receive checks. - **Rerouted claims are new transfers.** A claim to `to != receiver` must satisfy token-level recipient authorization for `to` and that destination's address-level receive controls. -- **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked-bucket originator(s), not `ESCROW_ADDRESS`. -- **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute by `receiver` must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. -- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked buckets. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, or key-spend policy for that path becomes a userland concern of the recovery contract. -- **Recovery-contract changes are not retroactive.** Each blocked bucket is governed by the `recoveryContract` recorded when the inbound was blocked. -- **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked buckets keyed to that contract may become difficult or impossible to claim. +- **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked receipt originator, not `ESCROW_ADDRESS`. +- **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute initiated through an access key must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. +- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, or key-spend policy for that path becomes a userland concern of the recovery contract. +- **Recovery-contract changes are not retroactive.** Each blocked receipt is governed by the `recoveryContract` recorded when the inbound was blocked. +- **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked receipts keyed to that contract may become difficult or impossible to claim. +- **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. - **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. ## 8. Invariants @@ -670,9 +757,8 @@ The following invariants MUST always hold: 1. For every TIP-20 token: ```text balances[ESCROW_ADDRESS] - = sum_over_receivers_originators_and_recoveryContracts( - blockedInbound[token][receiver][originator][recoveryContract] - ) + = implementation_private_escrow_reserve[token] + + sum_over_open_blocked_receipts_for_token(receipt.amount) ``` 2. Userland `transfer(..., ESCROW_ADDRESS)`, `transferFrom(..., ESCROW_ADDRESS)`, `mint(ESCROW_ADDRESS, ...)`, and `mintWithMemo(ESCROW_ADDRESS, ...)` MUST revert. @@ -681,52 +767,40 @@ The following invariants MUST always hold: 4. Token-level policy failure on the original transfer or mint path MUST still revert. -5. Every blocked inbound MUST update exactly one bucket: - `blockedInbound[token][receiver][originator][recoveryContract]`. +5. Every blocked inbound MUST create exactly one blocked receipt. + +6. Every blocked receipt MUST store the literal requested recipient as `requestedRecipient`, even when the canonical `receiver` differs because of TIP-1022 resolution. -6. Only `receiver` may claim a bucket whose `recoveryContract == address(0)`. Only the bucket's - `recoveryContract` may claim a bucket whose `recoveryContract != address(0)`. +7. Only `receiver` may claim a receipt whose `recoveryContract == address(0)`. Only the receipt's `recoveryContract` may claim a receipt whose `recoveryContract != address(0)`. -7. A claim to `receiver` MUST bypass the receiver's address-level receive controls and token-level - recipient authorization. +8. A claim to `receiver` MUST bypass the receiver's address-level receive controls and token-level recipient authorization. -8. A rerouted claim to `to != receiver` MUST enforce token-level recipient authorization for `to` - and MUST revert with `ClaimDestinationUnauthorized()` if it fails. +9. A rerouted claim to `to != receiver` MUST enforce token-level recipient authorization for `to` and MUST revert with `ClaimDestinationUnauthorized()` if it fails. -9. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive - controls against each consumed bucket originator individually. +10. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive controls against each consumed receipt's `originator`. -10. If `recoveryContract == address(0)` and `to != receiver`, the claim MUST meter the total - claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 - spend by the receiver. +11. If `recoveryContract == address(0)`, `to != receiver`, and the claim is initiated through an access key, the claim MUST meter the total claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 spend by the receiver. -11. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT - be escrowed. +12. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT be escrowed. -12. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive - TIP-20 logic, including `burnBlocked`. +13. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive TIP-20 logic, including any escrow-release and `burnBlocked`-like paths. -13. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked buckets. Existing - buckets remain governed by the recovery contract stored in their key. +14. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked receipts. Existing receipts remain governed by the recovery contract stored in those receipts. -14. Escrow-related raw TIP-20 events MUST be truthful: +15. Escrow-related raw TIP-20 events MUST be truthful: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - - blocked mint: `Transfer(Address::ZERO, ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` + - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -15. Every blocked inbound MUST emit exactly one attribution event naming the receiver, the block - reason, the governing `recoveryContract`, and the literal `requestedRecipient`. For TIP-1022 - virtual-address inbounds, `requestedRecipient` MUST preserve the literal virtual address even - though the escrow bucket is keyed to the resolved master receiver. That event's `blockedReason` - MUST NOT be `NONE`. +16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, and the governing `recoveryContract`. That event's `blockedReason` MUST NOT be `NONE`. -16. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat - `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same - opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT - create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +17. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. -17. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when - `authorized == true`. +18. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. + +19. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. + +20. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. ## Appendix A. Solidity Reference Recovery Contract (Non-Normative) @@ -736,24 +810,44 @@ The following contract is illustrative only. It is not part of the protocol, and pragma solidity ^0.8.24; interface IBlockedInboundEscrowReference { - struct ClaimPart { + struct ClaimReceipt { + uint64 receiptId; + uint256 amount; + } + + struct BlockedReceipt { + address token; + address receiver; + address requestedRecipient; address originator; + address recoveryContract; uint256 amount; + uint64 blockedAt; + uint32 blockedIndex; + uint8 blockedReason; + uint8 kind; + bytes memo; } - function claimBlockedInbounds( + function blockedReceipt(uint64 receiptId) + external + view + returns (BlockedReceipt memory receipt); + + function claimBlockedReceipts( address token, address receiver, address recoveryContract, - ClaimPart[] calldata parts, + ClaimReceipt[] calldata receipts, address to ) external; } -contract BasicBlockedInboundRecovery { +contract BasicBlockedReceiptRecovery { error Unauthorized(); error OriginatorSelfClaimDisabled(); error UseClaimToReceiver(); + error NotReceiptOriginator(); address public immutable receiver; IBlockedInboundEscrowReference public immutable escrow; @@ -784,33 +878,38 @@ contract BasicBlockedInboundRecovery { function claimToReceiver( address token, - IBlockedInboundEscrowReference.ClaimPart[] calldata parts + IBlockedInboundEscrowReference.ClaimReceipt[] calldata receipts ) external { if (msg.sender != receiver && !claimOperators[msg.sender]) revert Unauthorized(); - escrow.claimBlockedInbounds(token, receiver, address(this), parts, receiver); + escrow.claimBlockedReceipts(token, receiver, address(this), receipts, receiver); } function claimTo( address token, - IBlockedInboundEscrowReference.ClaimPart[] calldata parts, + IBlockedInboundEscrowReference.ClaimReceipt[] calldata receipts, address to ) external { if (msg.sender != receiver && !rerouteOperators[msg.sender]) revert Unauthorized(); if (to == receiver) revert UseClaimToReceiver(); - escrow.claimBlockedInbounds(token, receiver, address(this), parts, to); + escrow.claimBlockedReceipts(token, receiver, address(this), receipts, to); } - function claimOwnBucket(address token, uint256 amount) external { + function claimOwnReceipt(address token, uint64 receiptId, uint256 amount) external { if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); - IBlockedInboundEscrowReference.ClaimPart[] - memory parts = new IBlockedInboundEscrowReference.ClaimPart[](1); - parts[0] = IBlockedInboundEscrowReference.ClaimPart({ - originator: msg.sender, + IBlockedInboundEscrowReference.BlockedReceipt memory receipt = escrow.blockedReceipt( + receiptId + ); + if (receipt.originator != msg.sender) revert NotReceiptOriginator(); + + IBlockedInboundEscrowReference.ClaimReceipt[] + memory receipts = new IBlockedInboundEscrowReference.ClaimReceipt[](1); + receipts[0] = IBlockedInboundEscrowReference.ClaimReceipt({ + receiptId: receiptId, amount: amount }); - escrow.claimBlockedInbounds(token, receiver, address(this), parts, msg.sender); + escrow.claimBlockedReceipts(token, receiver, address(this), receipts, msg.sender); } } ``` @@ -820,6 +919,6 @@ This reference design intentionally does four things: - it makes `receiver` the only configuration authority - it separates claims back to `receiver` from reroutes to third parties - it allows delegated claims without forcing that delegation logic into the protocol -- it makes originator self-claim, if enabled, explicit and narrowly scoped +- it makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own receipts Receivers that need stronger policy MAY replace this with a multisig, smart wallet, timelock, or custom contract. If they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. From bc56af45a1b5f85dba553fc8a0c0f99c4077ae5c Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:59:59 -0400 Subject: [PATCH 08/59] docs(tip-1028): clarify receipt bucketing Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 232 ++++++++++++++++++++++------------------------- 1 file changed, 109 insertions(+), 123 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index d5a8270d73..23a2d348f9 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -16,9 +16,9 @@ This TIP extends TIP-403 in three ways: 1. **Token sets** for TIP-20 token addresses. 2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. -3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt. +3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt-shaped bucket. -Each blocked receipt snapshots the receiver, the literal requested recipient, the real originator, the governing recovery contract, the amount, and per-transfer metadata such as timestamp, memo, and in-transaction ordering. This preserves attribution for TIP-1022 virtual addresses and keeps mint-like and transfer-like blocked inbounds distinguishable offchain. +Each blocked inbound gets its own fine-grained escrow bucket keyed by the receiver, the real originator, the governing recovery contract, and a unique blocked nonce. The bucket stores only the remaining claimable amount onchain. Richer per-transfer metadata such as the literal requested recipient, the block reason, whether the inbound was a transfer or mint, and any memo remains available through the emitted events. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. Claim-to-receiver is an unwind, not a new inbound. Reroutes are new spends and must satisfy the same recipient-side and spending-limit rules that would apply to an ordinary outbound transfer. @@ -32,7 +32,7 @@ A revert-based receiver policy creates a liveness problem. Once a relationship e This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. -The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, and offchain recovery flows may need the original timestamp and sender identity. A per-transfer receipt model preserves that data directly. Aggregate buckets do not. +The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, and offchain recovery flows need the original sender identity. A fine-grained per-transfer receipt bucket preserves that attribution while still using the same storage pattern as ordinary bucketing: one keyed amount per blocked inbound. This TIP also avoids creating a new TIP-20 variant. Address-level receive policies remain an extension to TIP-403 and TIP-20 rather than a separate token standard. @@ -249,7 +249,7 @@ enum BlockedReason { `BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized. -Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. +Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and its storage key and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. ### 3.3 Packed Storage @@ -260,12 +260,12 @@ mapping(address => address) public addressRecoveryContract; | Bits | Size | Field | |------|------|-------| -| `0..63` | 64 | `receivePolicyId` | -| `64..71` | 8 | cached `receivePolicyType` | -| `72..135` | 64 | `tokenSetId` | -| `136..143` | 8 | cached `tokenSetType` | -| `144..254` | 111 | reserved, MUST be zero | -| `255` | 1 | `hasAddressPolicy` | +| `0` | 1 | `hasAddressPolicy` | +| `1..64` | 64 | `receivePolicyId` | +| `65..72` | 8 | cached `receivePolicyType` | +| `73..136` | 64 | `tokenSetId` | +| `137..144` | 8 | cached `tokenSetType` | +| `145..255` | 111 | reserved, MUST be zero | When `hasAddressPolicy == 0`, the address is always authorized at the address level. The cached type fields are valid because policy type and token-set type are immutable after creation. @@ -293,48 +293,40 @@ interface IAddressReceivePolicies { address recoveryContract ); - function classifyAddressInbound(address token, address originator, address to) + function verifyAddressInbound(address token, address originator, address to) external view - returns ( - bool authorized, - address recoveryContract, - BlockedReason blockedReason - ); + returns (bool authorized, BlockedReason blockedReason); } ``` -Changing `recoveryContract` affects only future blocked receipts. Existing receipts remain governed by the `recoveryContract` recorded when they were created. +Changing `recoveryContract` affects only future blocked receipts. Existing receipts remain governed by the `recoveryContract` captured when they were created. + +Implementations SHOULD read `addressRecoveryContract[to]` only after `verifyAddressInbound(...)` returns `authorized = false`. ### 3.5 Authorization Logic -`classifyAddressInbound(token, originator, to)` MUST behave as follows: +`verifyAddressInbound(token, originator, to)` MUST behave as follows: - read the packed config for `to` - if `hasAddressPolicy == 0`, return: - `authorized = true` - - `recoveryContract = address(0)` - `blockedReason = NONE` - otherwise: - decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType` - - read the current `addressRecoveryContract[to]` - evaluate whether `token` is allowed by the token set - evaluate whether `originator` is allowed by the receive policy - if both checks pass, return: - `authorized = true` - - the current `recoveryContract` - `blockedReason = NONE` - if both checks fail, return: - `authorized = false` - - the current `recoveryContract` - `blockedReason = TOKEN_SET_AND_RECEIVE_POLICY` - if only the token-set check fails, return: - `authorized = false` - - the current `recoveryContract` - `blockedReason = TOKEN_SET` - if only the receive-policy check fails, return: - `authorized = false` - - the current `recoveryContract` - `blockedReason = RECEIVE_POLICY` An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. @@ -355,60 +347,55 @@ error InvalidRecoveryContract(); ## 4. Escrow Precompile -Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores receipt metadata and claimability. +Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only remaining claimable amounts for blocked receipts. ```solidity -ESCROW_ADDRESS = 0xFDC1000000000000000000000000000000000000 +ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000 ``` ### 4.1 Storage -Each blocked inbound creates exactly one receipt. +Each blocked inbound creates exactly one fine-grained receipt bucket. ```solidity -uint64 public blockedReceiptIdCounter = 1; - -struct BlockedReceipt { - address token; - address receiver; - address requestedRecipient; - address originator; - address recoveryContract; - uint256 amount; - uint64 blockedAt; - uint32 blockedIndex; - BlockedReason blockedReason; - InboundKind kind; - bytes memo; -} +uint64 public blockedReceiptNonce = 1; +mapping(bytes32 => uint256) internal blockedReceiptAmount; +``` + +The persistent escrow key for a blocked inbound is: -mapping(uint64 => BlockedReceipt) internal blockedReceipts; +```text +receiptKey = keccak256( + abi.encode(token, receiver, originator, recoveryContract, blockedNonce) +) ``` -Receipt fields have the following meanings: +where: + +- `receiver` is the canonical TIP-20 holder that owns the blocked receipt +- `originator` is `from` for transfers and mint caller for mints +- `recoveryContract` is the receiver's snapshotted recovery contract, or `address(0)` +- `blockedNonce` is a monotonically increasing global disambiguator assigned at receipt creation time -- `receiver`: the canonical TIP-20 holder that owns the blocked receipt -- `requestedRecipient`: the literal `to` address from the original inbound, preserving TIP-1022 user-tag attribution when applicable -- `originator`: `from` for transfers, mint caller for mints -- `recoveryContract`: the receiver's snapshotted recovery contract, or `address(0)` -- `amount`: the remaining unclaimed amount for that receipt -- `blockedAt`: the block timestamp when the receipt was created -- `blockedIndex`: the in-transaction ordinal among blocked inbounds in that transaction -- `blockedReason`: why the inbound was escrowed -- `kind`: `TRANSFER` or `MINT` -- `memo`: the original memo payload for memo-bearing paths, or empty bytes otherwise +`blockedReceiptAmount[receiptKey]` stores the remaining unclaimed amount for that receipt. -For non-memo-bearing paths, `memo` MUST be empty. `blockedIndex` MUST be assigned monotonically within the enclosing transaction so multiple blocked receipts created in the same transaction remain distinguishable even if every other field matches. +Other immutable receipt metadata is not required for onchain claimability and therefore need not be stored field-by-field in persistent state. That metadata MUST instead be preserved in the blocked-inbound events: + +- `requestedRecipient`, which preserves the literal `to` address and therefore TIP-1022 attribution +- `blockedReason` +- `kind` +- `memo` for memo-bearing paths For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. -Because this is a precompile, implementations MAY realize this storage with any internal layout that preserves the same externally visible semantics. +This is intentionally the same storage pattern as ordinary bucketing, but with a much finer key: one keyed amount per blocked inbound rather than one keyed amount per originator-wide aggregate. The precompile deliberately does **not** store: - receiver-wide aggregate balances - signer lists or multisig state - any global singleton recovery-policy state +- a field-by-field receipt struct in persistent storage ### 4.2 Interface @@ -420,14 +407,21 @@ interface IBlockedInboundEscrow { } struct ClaimReceipt { - uint64 receiptId; + address originator; + uint64 blockedNonce; uint256 amount; } - function blockedReceipt(uint64 receiptId) + function blockedReceiptBalance( + address token, + address receiver, + address originator, + address recoveryContract, + uint64 blockedNonce + ) external view - returns (BlockedReceipt memory receipt); + returns (uint256 amount); function claimBlockedReceipts( address token, @@ -446,9 +440,8 @@ interface IBlockedInboundEscrow { uint256 amount, BlockedReason blockedReason, InboundKind kind, - bytes calldata memo, - uint32 blockedIndex - ) external returns (uint64 receiptId); + bytes32 memo + ) external returns (uint64 blockedNonce); } ``` @@ -456,22 +449,22 @@ interface IBlockedInboundEscrow { `requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. -The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the receipts they want to consume, typically using logs or offchain indexing. +The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked inbounds they want to consume, typically using logs or offchain indexing. ### 4.3 Claim Authorization -Each blocked receipt is governed by the `recoveryContract` recorded in that receipt at block time. +Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. `claimBlockedReceipts(...)`: -- consumes only the explicitly listed receipts +- consumes only the explicitly listed receipt buckets - releases only to `to` - MUST require `msg.sender == receiver` when `recoveryContract == address(0)` - MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` -- MUST require every listed receipt to match the supplied `token`, `receiver`, and `recoveryContract` -- MAY partially consume a receipt, but MUST reject `amount == 0` or `amount > blockedReceipts[receiptId].amount` +- MUST interpret each listed receipt as the tuple `(token, receiver, receipt.originator, recoveryContract, receipt.blockedNonce)` +- MAY partially consume a receipt bucket, but MUST reject `amount == 0` or `amount > blockedReceiptAmount[receiptKey]` -The listed receipt IDs are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. +The listed originators and blocked nonces are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. If a receiver wants delegate whitelists, originator self-claim, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative standard pattern. @@ -513,28 +506,30 @@ event TransferBlocked( address indexed token, address indexed from, address indexed receiver, - uint64 receiptId, + uint64 blockedNonce, address requestedRecipient, uint256 amount, BlockedReason blockedReason, - address recoveryContract + address recoveryContract, + bytes32 memo ); event MintBlocked( address indexed token, address indexed operator, address indexed receiver, - uint64 receiptId, + uint64 blockedNonce, address requestedRecipient, uint256 amount, BlockedReason blockedReason, - address recoveryContract + address recoveryContract, + bytes32 memo ); event BlockedReceiptClaimed( address indexed token, address indexed receiver, - uint64 indexed receiptId, + uint64 indexed blockedNonce, address originator, address requestedRecipient, address recoveryContract, @@ -564,7 +559,7 @@ The standard pattern is a reusable receiver-owned implementation, not a global s - the recovery contract is the only direct caller of `claimBlockedReceipts(...)` for receipts keyed to that contract - `claimToReceiver(...)` is the default unwind path - reroutes to `to != receiver` are optional and SHOULD be separately permissioned -- originator self-claim, if supported, SHOULD only allow an originator to claim receipts whose `originator` equals that caller, and only to itself +- originator self-claim, if supported, SHOULD only allow an originator to claim receipts whose supplied `originator` equals that caller, and only to itself If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained. @@ -584,11 +579,12 @@ For a transfer-like path: - run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 and TIP-1015 checks - if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` - set `requestedRecipient = to` -- call `classifyAddressInbound(token, from, effectiveReceiver)` and capture `recoveryContract` +- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `blockedReason` - if the inbound is authorized: - follow the normal transfer path using `effectiveReceiver` - if `to` is virtual, use TIP-1022 forwarding event semantics @@ -597,15 +593,16 @@ For a transfer-like path: - credit `effectiveReceiver` - return success - if the inbound is blocked by the receiver: + - read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo, blockedIndex)` and capture `receiptId` + - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `blockedNonce` - emit `Transfer(from, ESCROW_ADDRESS, amount)` - - emit `TransferBlocked(token, from, effectiveReceiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` + - emit `TransferBlocked(token, from, effectiveReceiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success -Memo-bearing transfer variants MUST preserve the original memo in the blocked receipt. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. +Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. ### 5.2 Mint-like Paths @@ -613,11 +610,12 @@ For a mint-like path: - run the existing issuer-role, mint-recipient, and supply-cap checks - if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` - set `requestedRecipient = to` -- call `classifyAddressInbound(token, originator, effectiveReceiver)` and capture `recoveryContract`, where `originator` is the mint caller +- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `blockedReason`, where `originator` is the mint caller - if the inbound is authorized: - follow the normal mint path using `effectiveReceiver` - if `to` is virtual, use TIP-1022 forwarding event semantics @@ -626,15 +624,16 @@ For a mint-like path: - credit `effectiveReceiver` - return success - if the inbound is blocked by the receiver: + - read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo, blockedIndex)` and capture `receiptId` + - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `blockedNonce` - emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - - emit `MintBlocked(token, originator, effectiveReceiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` + - emit `MintBlocked(token, originator, effectiveReceiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success -`mintWithMemo` MUST preserve the original memo in the blocked receipt. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. +`mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. ### 5.3 Reward Delegation and Claims @@ -643,13 +642,13 @@ Reward flows are never escrowed, but they MUST respect recipient consent. For `setRewardRecipient(holder, recipient)`: - `recipient == address(0)` remains the opt-out path and bypasses receive-policy checks -- otherwise the token MUST call `classifyAddressInbound(token, holder, recipient)` +- otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` - if that check is unauthorized, the call MUST revert For `claimRewards(...)`: - the token MUST determine the actual payout recipient under its existing reward rules -- it MUST revalidate that recipient with `classifyAddressInbound(token, address(token), recipient)` before crediting rewards +- it MUST revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` before crediting rewards - if that check is unauthorized, the call MUST revert rather than escrow These rules prevent a holder from routing rewards to an unwilling recipient and prevent reward claims from bypassing address-level receive policies. @@ -665,8 +664,8 @@ Blocked inbounds MUST use truthful raw TIP-20 events: In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: -- `TransferBlocked(token, from, receiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` -- `MintBlocked(token, operator, receiver, receiptId, requestedRecipient, amount, blockedReason, recoveryContract)` +- `TransferBlocked(token, from, receiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- `MintBlocked(token, operator, receiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. @@ -707,21 +706,20 @@ This section uses the gas model from TIP-1016: | first `setAddressReceivePolicy()` with nonzero `recoveryContract` | `~500k + call overhead` | packed config slot plus recovery-contract slot | | allowed inbound to address with no receive config | current path + one cold config read | no escrow writes | | allowed inbound to configured address | current path + config read + token-set or policy membership reads | no escrow writes | -| blocked inbound after escrow balance slot is preinitialized | current path + receipt writes | exact cost depends on memo length and receipt implementation | +| blocked inbound after escrow balance slot is preinitialized | `~300k` | one new keyed receipt amount slot plus normal transfer path | | blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | -| claim over `N` receipts | one transfer from escrow + `N` receipt updates or deletes + auth reads | partial claims keep the receipt; full claims delete it | +| claim over `N` receipts | one transfer from escrow + `N` receipt-slot updates or deletes + auth reads | partial claims keep the slot; full claims delete it | ### 6.2 Storage Choices -The design intentionally stores one receipt per blocked inbound. That is more expensive than an aggregate bucket, but it preserves: +The design intentionally stores one fine-grained receipt bucket per blocked inbound. That is more expensive than an aggregate bucket, but it preserves: - the literal `requestedRecipient` needed for TIP-1022 attribution - the original `originator` -- per-transfer memo data -- block timestamp and in-transaction ordering +- per-transfer memo data in emitted events - the distinction between transfer-blocked and mint-blocked funds -Those fields are not reconstructible from a receiver-wide or originator-wide aggregate without losing important semantics. +The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata lives in the events rather than in a multi-slot onchain struct. This is the same storage pattern as ordinary bucketing, but with a much finer key. ### 6.3 Escrow Slot Strategy @@ -745,7 +743,7 @@ If an implementation uses such a reserve: - **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked receipt originator, not `ESCROW_ADDRESS`. - **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute initiated through an access key must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. - **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, or key-spend policy for that path becomes a userland concern of the recovery contract. -- **Recovery-contract changes are not retroactive.** Each blocked receipt is governed by the `recoveryContract` recorded when the inbound was blocked. +- **Recovery-contract changes are not retroactive.** Each blocked receipt is governed by the `recoveryContract` captured when the inbound was blocked. - **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked receipts keyed to that contract may become difficult or impossible to claim. - **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. - **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. @@ -767,11 +765,11 @@ The following invariants MUST always hold: 4. Token-level policy failure on the original transfer or mint path MUST still revert. -5. Every blocked inbound MUST create exactly one blocked receipt. +5. Every blocked inbound MUST create exactly one blocked receipt bucket. -6. Every blocked receipt MUST store the literal requested recipient as `requestedRecipient`, even when the canonical `receiver` differs because of TIP-1022 resolution. +6. Every blocked inbound MUST emit the literal requested recipient as `requestedRecipient`, even when the canonical `receiver` differs because of TIP-1022 resolution. -7. Only `receiver` may claim a receipt whose `recoveryContract == address(0)`. Only the receipt's `recoveryContract` may claim a receipt whose `recoveryContract != address(0)`. +7. Only `receiver` may claim a receipt bucket whose `recoveryContract == address(0)`. Only the receipt's `recoveryContract` may claim a receipt bucket whose `recoveryContract != address(0)`. 8. A claim to `receiver` MUST bypass the receiver's address-level receive controls and token-level recipient authorization. @@ -785,14 +783,14 @@ The following invariants MUST always hold: 13. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive TIP-20 logic, including any escrow-release and `burnBlocked`-like paths. -14. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked receipts. Existing receipts remain governed by the recovery contract stored in those receipts. +14. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked receipts. Existing receipt buckets remain governed by the recovery contract captured in their key. 15. Escrow-related raw TIP-20 events MUST be truthful: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, and the governing `recoveryContract`. That event's `blockedReason` MUST NOT be `NONE`. +16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. 17. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. @@ -800,7 +798,7 @@ The following invariants MUST always hold: 19. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. -20. `classifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. +20. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. ## Appendix A. Solidity Reference Recovery Contract (Non-Normative) @@ -811,28 +809,21 @@ pragma solidity ^0.8.24; interface IBlockedInboundEscrowReference { struct ClaimReceipt { - uint64 receiptId; - uint256 amount; - } - - struct BlockedReceipt { - address token; - address receiver; - address requestedRecipient; address originator; - address recoveryContract; + uint64 blockedNonce; uint256 amount; - uint64 blockedAt; - uint32 blockedIndex; - uint8 blockedReason; - uint8 kind; - bytes memo; } - function blockedReceipt(uint64 receiptId) + function blockedReceiptBalance( + address token, + address receiver, + address originator, + address recoveryContract, + uint64 blockedNonce + ) external view - returns (BlockedReceipt memory receipt); + returns (uint256 amount); function claimBlockedReceipts( address token, @@ -847,7 +838,6 @@ contract BasicBlockedReceiptRecovery { error Unauthorized(); error OriginatorSelfClaimDisabled(); error UseClaimToReceiver(); - error NotReceiptOriginator(); address public immutable receiver; IBlockedInboundEscrowReference public immutable escrow; @@ -894,18 +884,14 @@ contract BasicBlockedReceiptRecovery { escrow.claimBlockedReceipts(token, receiver, address(this), receipts, to); } - function claimOwnReceipt(address token, uint64 receiptId, uint256 amount) external { + function claimOwnReceipt(address token, uint64 blockedNonce, uint256 amount) external { if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); - IBlockedInboundEscrowReference.BlockedReceipt memory receipt = escrow.blockedReceipt( - receiptId - ); - if (receipt.originator != msg.sender) revert NotReceiptOriginator(); - IBlockedInboundEscrowReference.ClaimReceipt[] memory receipts = new IBlockedInboundEscrowReference.ClaimReceipt[](1); receipts[0] = IBlockedInboundEscrowReference.ClaimReceipt({ - receiptId: receiptId, + originator: msg.sender, + blockedNonce: blockedNonce, amount: amount }); From f496d95f81bb7b73ae9c95fa3738e4cab326ce70 Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:16:50 -0400 Subject: [PATCH 09/59] docs(tip-1028): tighten receipt claims Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 122 ++++++++++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 43 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 23a2d348f9..2679262e05 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -18,7 +18,7 @@ This TIP extends TIP-403 in three ways: 2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. 3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt-shaped bucket. -Each blocked inbound gets its own fine-grained escrow bucket keyed by the receiver, the real originator, the governing recovery contract, and a unique blocked nonce. The bucket stores only the remaining claimable amount onchain. Richer per-transfer metadata such as the literal requested recipient, the block reason, whether the inbound was a transfer or mint, and any memo remains available through the emitted events. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. +Each blocked inbound gets its own fine-grained escrow bucket. The escrow precompile stores only the remaining claimable amount onchain. The rest of the receipt identity — the literal requested recipient, block reason, transfer-vs-mint kind, memo, blocked timestamp, and blocked nonce — is authenticated by the receipt witness and emitted in the blocked event. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. Claim-to-receiver is an unwind, not a new inbound. Reroutes are new spends and must satisfy the same recipient-side and spending-limit rules that would apply to an ordinary outbound transfer. @@ -32,7 +32,7 @@ A revert-based receiver policy creates a liveness problem. Once a relationship e This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. -The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, and offchain recovery flows need the original sender identity. A fine-grained per-transfer receipt bucket preserves that attribution while still using the same storage pattern as ordinary bucketing: one keyed amount per blocked inbound. +The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, recovery contracts may want time-based or memo-based rules, and offchain recovery flows need the original sender identity. A fine-grained per-transfer receipt bucket preserves that attribution while still using the same storage pattern as ordinary bucketing: one keyed amount per blocked inbound. This TIP also avoids creating a new TIP-20 variant. Address-level receive policies remain an extension to TIP-403 and TIP-20 rather than a separate token standard. @@ -366,7 +366,18 @@ The persistent escrow key for a blocked inbound is: ```text receiptKey = keccak256( - abi.encode(token, receiver, originator, recoveryContract, blockedNonce) + abi.encode( + token, + receiver, + originator, + requestedRecipient, + recoveryContract, + blockedReason, + kind, + memo, + blockedAt, + blockedNonce + ) ) ``` @@ -374,17 +385,17 @@ where: - `receiver` is the canonical TIP-20 holder that owns the blocked receipt - `originator` is `from` for transfers and mint caller for mints +- `requestedRecipient` is the literal `to` address and therefore preserves TIP-1022 attribution - `recoveryContract` is the receiver's snapshotted recovery contract, or `address(0)` +- `blockedReason` records whether the receiver blocked the inbound because of its token set, receive policy, or both +- `kind` distinguishes transfer-blocked from mint-blocked receipts +- `memo` preserves the original memo for memo-bearing paths and is `bytes32(0)` otherwise +- `blockedAt` is the block timestamp captured when the receipt is recorded - `blockedNonce` is a monotonically increasing global disambiguator assigned at receipt creation time -`blockedReceiptAmount[receiptKey]` stores the remaining unclaimed amount for that receipt. - -Other immutable receipt metadata is not required for onchain claimability and therefore need not be stored field-by-field in persistent state. That metadata MUST instead be preserved in the blocked-inbound events: +`blockedReceiptAmount[receiptKey]` stores the entire currently claimable amount for that receipt. -- `requestedRecipient`, which preserves the literal `to` address and therefore TIP-1022 attribution -- `blockedReason` -- `kind` -- `memo` for memo-bearing paths +The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the exact same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. @@ -408,16 +419,19 @@ interface IBlockedInboundEscrow { struct ClaimReceipt { address originator; + address requestedRecipient; + uint64 blockedAt; uint64 blockedNonce; - uint256 amount; + BlockedReason blockedReason; + InboundKind kind; + bytes32 memo; } function blockedReceiptBalance( address token, address receiver, - address originator, address recoveryContract, - uint64 blockedNonce + ClaimReceipt calldata receipt ) external view @@ -441,7 +455,7 @@ interface IBlockedInboundEscrow { BlockedReason blockedReason, InboundKind kind, bytes32 memo - ) external returns (uint64 blockedNonce); + ) external returns (uint64 blockedNonce, uint64 blockedAt); } ``` @@ -449,7 +463,7 @@ interface IBlockedInboundEscrow { `requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. -The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked inbounds they want to consume, typically using logs or offchain indexing. +The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipts they want to consume, typically using logs or offchain indexing. ### 4.3 Claim Authorization @@ -461,10 +475,11 @@ Each blocked receipt is governed by the `recoveryContract` captured for that rec - releases only to `to` - MUST require `msg.sender == receiver` when `recoveryContract == address(0)` - MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` -- MUST interpret each listed receipt as the tuple `(token, receiver, receipt.originator, recoveryContract, receipt.blockedNonce)` -- MAY partially consume a receipt bucket, but MUST reject `amount == 0` or `amount > blockedReceiptAmount[receiptKey]` +- MUST interpret each listed receipt as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)` +- MUST require `blockedReceiptAmount[receiptKey] > 0` for each listed receipt +- MUST consume the entire stored amount for each listed receipt or revert; partial claims are not allowed -The listed originators and blocked nonces are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. +The listed receipt witnesses are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. If a receiver wants delegate whitelists, originator self-claim, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative standard pattern. @@ -507,6 +522,7 @@ event TransferBlocked( address indexed from, address indexed receiver, uint64 blockedNonce, + uint64 blockedAt, address requestedRecipient, uint256 amount, BlockedReason blockedReason, @@ -519,6 +535,7 @@ event MintBlocked( address indexed operator, address indexed receiver, uint64 blockedNonce, + uint64 blockedAt, address requestedRecipient, uint256 amount, BlockedReason blockedReason, @@ -530,6 +547,7 @@ event BlockedReceiptClaimed( address indexed token, address indexed receiver, uint64 indexed blockedNonce, + uint64 blockedAt, address originator, address requestedRecipient, address recoveryContract, @@ -597,9 +615,9 @@ For a transfer-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - debit `from` - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `blockedNonce` + - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)` - emit `Transfer(from, ESCROW_ADDRESS, amount)` - - emit `TransferBlocked(token, from, effectiveReceiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` + - emit `TransferBlocked(token, from, effectiveReceiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. @@ -628,9 +646,9 @@ For a mint-like path: - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - increase total supply - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `blockedNonce` + - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)` - emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - - emit `MintBlocked(token, originator, effectiveReceiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` + - emit `MintBlocked(token, originator, effectiveReceiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success `mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. @@ -662,10 +680,10 @@ Blocked inbounds MUST use truthful raw TIP-20 events: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` -In addition, every blocked inbound MUST emit exactly one attribution event from `recordBlockedInbound(...)`: +In addition, every blocked inbound MUST emit exactly one attribution event: -- `TransferBlocked(token, from, receiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -- `MintBlocked(token, operator, receiver, blockedNonce, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- `TransferBlocked(token, from, receiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- `MintBlocked(token, operator, receiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. @@ -708,7 +726,7 @@ This section uses the gas model from TIP-1016: | allowed inbound to configured address | current path + config read + token-set or policy membership reads | no escrow writes | | blocked inbound after escrow balance slot is preinitialized | `~300k` | one new keyed receipt amount slot plus normal transfer path | | blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | -| claim over `N` receipts | one transfer from escrow + `N` receipt-slot updates or deletes + auth reads | partial claims keep the slot; full claims delete it | +| claim over `N` receipts | one transfer from escrow + `N` receipt-slot deletes + auth reads | claims are whole-receipt only | ### 6.2 Storage Choices @@ -716,10 +734,11 @@ The design intentionally stores one fine-grained receipt bucket per blocked inbo - the literal `requestedRecipient` needed for TIP-1022 attribution - the original `originator` -- per-transfer memo data in emitted events +- the block timestamp `blockedAt` for programmable recovery rules - the distinction between transfer-blocked and mint-blocked funds +- memo and block-reason data for programmable recovery rules -The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata lives in the events rather than in a multi-slot onchain struct. This is the same storage pattern as ordinary bucketing, but with a much finer key. +The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. This is the same storage pattern as ordinary bucketing, but with a much finer key. ### 6.3 Escrow Slot Strategy @@ -790,15 +809,17 @@ The following invariants MUST always hold: - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. +16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. + +17. Claims MUST consume whole blocked receipts. Once a receipt is claimed, its keyed amount MUST be deleted rather than partially decremented. -17. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +18. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. -18. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. +19. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. -19. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. +20. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. -20. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. +21. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. ## Appendix A. Solidity Reference Recovery Contract (Non-Normative) @@ -808,18 +829,33 @@ The following contract is illustrative only. It is not part of the protocol, and pragma solidity ^0.8.24; interface IBlockedInboundEscrowReference { + enum BlockedReason { + NONE, + TOKEN_SET, + RECEIVE_POLICY, + TOKEN_SET_AND_RECEIVE_POLICY + } + + enum InboundKind { + TRANSFER, + MINT + } + struct ClaimReceipt { address originator; + address requestedRecipient; + uint64 blockedAt; uint64 blockedNonce; - uint256 amount; + BlockedReason blockedReason; + InboundKind kind; + bytes32 memo; } function blockedReceiptBalance( address token, address receiver, - address originator, address recoveryContract, - uint64 blockedNonce + ClaimReceipt calldata receipt ) external view @@ -884,16 +920,16 @@ contract BasicBlockedReceiptRecovery { escrow.claimBlockedReceipts(token, receiver, address(this), receipts, to); } - function claimOwnReceipt(address token, uint64 blockedNonce, uint256 amount) external { + function claimOwnReceipt( + address token, + IBlockedInboundEscrowReference.ClaimReceipt calldata receipt + ) external { if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); + if (receipt.originator != msg.sender) revert Unauthorized(); IBlockedInboundEscrowReference.ClaimReceipt[] memory receipts = new IBlockedInboundEscrowReference.ClaimReceipt[](1); - receipts[0] = IBlockedInboundEscrowReference.ClaimReceipt({ - originator: msg.sender, - blockedNonce: blockedNonce, - amount: amount - }); + receipts[0] = receipt; escrow.claimBlockedReceipts(token, receiver, address(this), receipts, msg.sender); } @@ -905,6 +941,6 @@ This reference design intentionally does four things: - it makes `receiver` the only configuration authority - it separates claims back to `receiver` from reroutes to third parties - it allows delegated claims without forcing that delegation logic into the protocol -- it makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own receipts +- it makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own authenticated receipt witness Receivers that need stronger policy MAY replace this with a multisig, smart wallet, timelock, or custom contract. If they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. From 22958f271152aa3190275f4e35d66e2ffb1a9578 Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:27:38 -0400 Subject: [PATCH 10/59] docs(tip-1028): tighten escrow wording Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 2679262e05..2d76a7da1d 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -18,7 +18,7 @@ This TIP extends TIP-403 in three ways: 2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. 3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt-shaped bucket. -Each blocked inbound gets its own fine-grained escrow bucket. The escrow precompile stores only the remaining claimable amount onchain. The rest of the receipt identity — the literal requested recipient, block reason, transfer-vs-mint kind, memo, blocked timestamp, and blocked nonce — is authenticated by the receipt witness and emitted in the blocked event. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. +Each blocked inbound gets its own fine-grained escrow bucket. The escrow precompile stores only one keyed amount for each open blocked receipt onchain. The rest of the receipt identity — the literal requested recipient, block reason, transfer-vs-mint kind, memo, blocked timestamp, and blocked nonce — is authenticated by the receipt witness and emitted in the blocked event. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. Claim-to-receiver is an unwind, not a new inbound. Reroutes are new spends and must satisfy the same recipient-side and spending-limit rules that would apply to an ordinary outbound transfer. @@ -347,7 +347,7 @@ error InvalidRecoveryContract(); ## 4. Escrow Precompile -Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only remaining claimable amounts for blocked receipts. +Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only one keyed amount for each open blocked receipt. ```solidity ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000 @@ -393,7 +393,7 @@ where: - `blockedAt` is the block timestamp captured when the receipt is recorded - `blockedNonce` is a monotonically increasing global disambiguator assigned at receipt creation time -`blockedReceiptAmount[receiptKey]` stores the entire currently claimable amount for that receipt. +`blockedReceiptAmount[receiptKey]` stores the full amount for that open receipt. The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the exact same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. From b12f0fc88444ac971909bd015ad5b43b89f714e3 Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:38:48 -0400 Subject: [PATCH 11/59] docs(tip-1028): simplify claim interface Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 52 ++++++++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 2d76a7da1d..12b13b4bea 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -214,7 +214,7 @@ Any address MAY configure three fields: 1. `receivePolicyId` - which originators may credit the address 2. `tokenSetId` - which TIP-20 token addresses may credit the address -3. `recoveryContract` - an optional address authorized to claim blocked receipts on behalf of the receiver; `address(0)` means the receiver claims directly +3. `recoveryContract` - an optional address authorized to claim blocked receipts on behalf of the receiver, one receipt at a time; `address(0)` means the receiver claims directly If an address has no configured receive controls, address-level authorization defaults to allow. @@ -437,11 +437,11 @@ interface IBlockedInboundEscrow { view returns (uint256 amount); - function claimBlockedReceipts( + function claimBlockedReceipt( address token, address receiver, address recoveryContract, - ClaimReceipt[] calldata receipts, + ClaimReceipt calldata receipt, address to ) external; @@ -463,23 +463,23 @@ interface IBlockedInboundEscrow { `requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. -The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipts they want to consume, typically using logs or offchain indexing. +The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. ### 4.3 Claim Authorization Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. -`claimBlockedReceipts(...)`: +`claimBlockedReceipt(...)`: -- consumes only the explicitly listed receipt buckets +- consumes only the explicitly supplied receipt bucket - releases only to `to` - MUST require `msg.sender == receiver` when `recoveryContract == address(0)` - MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` -- MUST interpret each listed receipt as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)` -- MUST require `blockedReceiptAmount[receiptKey] > 0` for each listed receipt -- MUST consume the entire stored amount for each listed receipt or revert; partial claims are not allowed +- MUST interpret `receipt` as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)` +- MUST require `blockedReceiptAmount[receiptKey] > 0` +- MUST consume the entire stored amount for that receipt or revert; partial claims are not allowed -The listed receipt witnesses are selectors, not authorities. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. +The supplied receipt witness is a selector, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. If a receiver wants delegate whitelists, originator self-claim, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative standard pattern. @@ -508,8 +508,8 @@ If `to != receiver`, the claim is a rerouted release. It MUST: - reject `to == ESCROW_ADDRESS` - reject TIP-1022 virtual addresses as `to` - enforce token-level transfer-recipient authorization for `to` -- enforce `to`'s address-level receive controls against each consumed receipt's `originator` -- revert if any consumed receipt fails the destination's address-level checks +- enforce `to`'s address-level receive controls against the receipt's `originator` +- revert if that receipt fails the destination's address-level checks - if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key policy is a userland concern. @@ -563,7 +563,7 @@ error ClaimDestinationUnauthorized(); error InvalidReceiptClaim(); ``` -For batched claims, `BlockedReceiptClaimed` MUST be emitted once per consumed receipt. +A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. ### 4.6 Standard Recovery Contract Pattern (Non-Normative) @@ -574,7 +574,7 @@ The standard pattern is a reusable receiver-owned implementation, not a global s - the receiver deploys its own instance, proxy, or smart-wallet module - the receiver sets `recoveryContract` to that instance in `setAddressReceivePolicy(...)` - the recovery contract stores one canonical `receiver` -- the recovery contract is the only direct caller of `claimBlockedReceipts(...)` for receipts keyed to that contract +- the recovery contract is the only direct caller of `claimBlockedReceipt(...)` for receipts keyed to that contract - `claimToReceiver(...)` is the default unwind path - reroutes to `to != receiver` are optional and SHOULD be separately permissioned - originator self-claim, if supported, SHOULD only allow an originator to claim receipts whose supplied `originator` equals that caller, and only to itself @@ -726,7 +726,7 @@ This section uses the gas model from TIP-1016: | allowed inbound to configured address | current path + config read + token-set or policy membership reads | no escrow writes | | blocked inbound after escrow balance slot is preinitialized | `~300k` | one new keyed receipt amount slot plus normal transfer path | | blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | -| claim over `N` receipts | one transfer from escrow + `N` receipt-slot deletes + auth reads | claims are whole-receipt only | +| claim one receipt | one transfer from escrow + one receipt-slot delete + auth reads | batching belongs above the protocol layer | ### 6.2 Storage Choices @@ -761,7 +761,7 @@ If an implementation uses such a reserve: - **Rerouted claims are new transfers.** A claim to `to != receiver` must satisfy token-level recipient authorization for `to` and that destination's address-level receive controls. - **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked receipt originator, not `ESCROW_ADDRESS`. - **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute initiated through an access key must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. -- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, or key-spend policy for that path becomes a userland concern of the recovery contract. +- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, key-spend policy, or batching policy for that path becomes a userland concern of the recovery contract. - **Recovery-contract changes are not retroactive.** Each blocked receipt is governed by the `recoveryContract` captured when the inbound was blocked. - **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked receipts keyed to that contract may become difficult or impossible to claim. - **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. @@ -794,7 +794,7 @@ The following invariants MUST always hold: 9. A rerouted claim to `to != receiver` MUST enforce token-level recipient authorization for `to` and MUST revert with `ClaimDestinationUnauthorized()` if it fails. -10. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive controls against each consumed receipt's `originator`. +10. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive controls against the consumed receipt's `originator`. 11. If `recoveryContract == address(0)`, `to != receiver`, and the claim is initiated through an access key, the claim MUST meter the total claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 spend by the receiver. @@ -861,11 +861,11 @@ interface IBlockedInboundEscrowReference { view returns (uint256 amount); - function claimBlockedReceipts( + function claimBlockedReceipt( address token, address receiver, address recoveryContract, - ClaimReceipt[] calldata receipts, + ClaimReceipt calldata receipt, address to ) external; } @@ -904,20 +904,20 @@ contract BasicBlockedReceiptRecovery { function claimToReceiver( address token, - IBlockedInboundEscrowReference.ClaimReceipt[] calldata receipts + IBlockedInboundEscrowReference.ClaimReceipt calldata receipt ) external { if (msg.sender != receiver && !claimOperators[msg.sender]) revert Unauthorized(); - escrow.claimBlockedReceipts(token, receiver, address(this), receipts, receiver); + escrow.claimBlockedReceipt(token, receiver, address(this), receipt, receiver); } function claimTo( address token, - IBlockedInboundEscrowReference.ClaimReceipt[] calldata receipts, + IBlockedInboundEscrowReference.ClaimReceipt calldata receipt, address to ) external { if (msg.sender != receiver && !rerouteOperators[msg.sender]) revert Unauthorized(); if (to == receiver) revert UseClaimToReceiver(); - escrow.claimBlockedReceipts(token, receiver, address(this), receipts, to); + escrow.claimBlockedReceipt(token, receiver, address(this), receipt, to); } function claimOwnReceipt( @@ -927,11 +927,7 @@ contract BasicBlockedReceiptRecovery { if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); if (receipt.originator != msg.sender) revert Unauthorized(); - IBlockedInboundEscrowReference.ClaimReceipt[] - memory receipts = new IBlockedInboundEscrowReference.ClaimReceipt[](1); - receipts[0] = receipt; - - escrow.claimBlockedReceipts(token, receiver, address(this), receipts, msg.sender); + escrow.claimBlockedReceipt(token, receiver, address(this), receipt, msg.sender); } } ``` From c23f5db33ba2e61a1db1a3ae62d584e6df07be0b Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:40:10 -0400 Subject: [PATCH 12/59] docs(tip-1028): tighten ordering and prewarm notes Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 12b13b4bea..1602d6323a 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -89,11 +89,12 @@ For reward accounting, `ESCROW_ADDRESS` is a reward-exempt always-opted-out synt High-level flow: -1. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 and TIP-1015 checks. -2. Run the receiver's token-set and receive-policy checks. -3. If the inbound is authorized, credit the receiver normally. -4. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, create one blocked receipt, and emit a blocked-inbound event naming both `receiver` and `requestedRecipient`. -5. Later, the receiver or the receipt's recovery contract may claim the funds. +1. If `to` is a TIP-1022 virtual address, resolve it first to `effectiveReceiver`; otherwise `effectiveReceiver = to`. +2. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 and TIP-1015 checks using `effectiveReceiver` wherever the current path is recipient-sensitive. +3. Run the receiver's token-set and receive-policy checks against `effectiveReceiver`. +4. If the inbound is authorized, credit the receiver normally. +5. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, create one blocked receipt, and emit a blocked-inbound event naming both `receiver` and `requestedRecipient`. +6. Later, the receiver or the receipt's recovery contract may claim the funds. ## 2. Token Sets @@ -235,6 +236,7 @@ It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evalua - MAY be `address(0)` - if nonzero, designates the sole direct claimer for future blocked receipts for this receiver - MUST NOT equal `ESCROW_ADDRESS` +- MUST NOT be a TIP-1022 virtual address ### 3.2 Blocked Reason and Recovery Contract @@ -595,13 +597,13 @@ error EscrowAddressReserved(); For a transfer-like path: -- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 and TIP-1015 checks - if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` - set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` - set `requestedRecipient = to` +- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 and TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive - call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `blockedReason` - if the inbound is authorized: - follow the normal transfer path using `effectiveReceiver` @@ -626,13 +628,13 @@ Memo-bearing transfer variants MUST preserve the original memo in the blocked ev For a mint-like path: -- run the existing issuer-role, mint-recipient, and supply-cap checks - if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` - set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - otherwise `to` - set `requestedRecipient = to` +- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive - call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `blockedReason`, where `originator` is the mint caller - if the inbound is authorized: - follow the normal mint path using `effectiveReceiver` @@ -744,11 +746,12 @@ The persistent state is still just one keyed amount per blocked inbound. The ric The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly `250,000` gas to the first blocked live transfer. -TIP-20 implementations SHOULD move that cost to deployment. One acceptable pattern is to create a non-user-claimable implementation-private escrow reserve at deployment so the `ESCROW_ADDRESS` balance slot is already live before any blocked inbound occurs. Implementations MAY use any equivalent deployment-time mechanism instead. +TIP-20 implementations SHOULD move that cost to deployment by initializing the `balances[ESCROW_ADDRESS]` slot when the token is created, before any blocked inbound occurs. One acceptable pattern is to do this with a non-user-claimable implementation-private escrow reserve. Implementations MAY use any equivalent deployment-time mechanism instead. If an implementation uses such a reserve: - it MUST be created at token deployment time +- it MUST exist only to initialize and keep live the `balances[ESCROW_ADDRESS]` slot - it MUST NOT correspond to any blocked receipt - it MUST NOT be claimable by users or recovery contracts - release and burn logic MUST preserve it as implementation-private state From 634925176618ea2393d9eb4fd640f2cb3c3530aa Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:03:04 -0400 Subject: [PATCH 13/59] docs(tip-1028): dedupe spec prose Co-Authored-By: malleshpai <5857042+malleshpai@users.noreply.github.com> --- tips/tip-1028.md | 219 +++++++---------------------------------------- 1 file changed, 33 insertions(+), 186 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 1602d6323a..5aa3af8eea 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -12,29 +12,15 @@ protocolVersion: TBD ## Abstract -This TIP extends TIP-403 in three ways: +This TIP extends TIP-403 with token sets for TIP-20 token addresses, address-level receive controls, and non-reverting escrow for blocked TIP-20 inbounds. A blocked inbound credits `ESCROW_ADDRESS` and creates one fine-grained receipt bucket. -1. **Token sets** for TIP-20 token addresses. -2. **Address-level receive controls** so any address can restrict which counterparties and which TIP-20 tokens may credit it. -3. **Escrowed blocked inbounds** so a receiver-side block does not revert. Instead, the raw TIP-20 balance is credited to `ESCROW_ADDRESS` and the blocked inbound is recorded as an individual receipt-shaped bucket. - -Each blocked inbound gets its own fine-grained escrow bucket. The escrow precompile stores only one keyed amount for each open blocked receipt onchain. The rest of the receipt identity — the literal requested recipient, block reason, transfer-vs-mint kind, memo, blocked timestamp, and blocked nonce — is authenticated by the receipt witness and emitted in the blocked event. This preserves TIP-1022 attribution and memo-bearing semantics without requiring the escrow precompile to store a large receipt struct in persistent state. - -Claims may either unwind the blocked inbound back to the receiver or reroute the funds to a different destination. Claim-to-receiver is an unwind, not a new inbound. Reroutes are new spends and must satisfy the same recipient-side and spending-limit rules that would apply to an ordinary outbound transfer. - -These controls apply only to TIP-20 precompile flows. Ordinary ERC-20 contracts deployed on Tempo remain outside this TIP and may still transfer to any address under their own contract logic. +The escrow precompile stores only one keyed amount for each open blocked receipt. The rest of the receipt identity — requested recipient, block reason, transfer-vs-mint kind, memo, timestamp, and nonce — is authenticated by the receipt witness and emitted in the blocked event. Claims may unwind to the receiver or reroute elsewhere; claim-to-receiver is an unwind, while reroutes are new spends. This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts remain out of scope. ## Motivation -TIP-403 lets token issuers decide who may use a token. Some receivers also need their own inbound controls. - -A revert-based receiver policy creates a liveness problem. Once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. - -This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert. Receiver-side failures are escrowed instead. +TIP-403 lets token issuers decide who may use a token, but some receivers also need inbound controls. A revert-based receiver policy creates a liveness problem: once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. -The escrow representation must preserve more than an aggregate amount. TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, recovery contracts may want time-based or memo-based rules, and offchain recovery flows need the original sender identity. A fine-grained per-transfer receipt bucket preserves that attribution while still using the same storage pattern as ordinary bucketing: one keyed amount per blocked inbound. - -This TIP also avoids creating a new TIP-20 variant. Address-level receive policies remain an extension to TIP-403 and TIP-20 rather than a separate token standard. +This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert; receiver-side failures are escrowed instead. The escrow representation must preserve more than an aggregate amount because TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, recovery contracts may want time-based or memo-based rules, and offchain recovery flows need the original sender identity. # Specification @@ -68,22 +54,11 @@ If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur - the blocked receipt's `requestedRecipient` MUST be the literal `to` address so offchain systems can recover the TIP-1022 `userTag`; and - if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics. -TIP-1028 does **not** alter: - -- `approve` -- `permit` -- `burn` -- fee refunds via `transfer_fee_post_tx` -- non-TIP-20 tokens deployed as ordinary contracts -- future recipient-bearing system-credit paths that do not identify a concrete originator +TIP-1028 does **not** alter `approve`, `permit`, `burn`, fee refunds via `transfer_fee_post_tx`, non-TIP-20 tokens deployed as ordinary contracts, or future recipient-bearing system-credit paths that do not identify a concrete originator. DEX internal balances are not TIP-20 wallet balances and are not subject to address-level receive checks until withdrawn back onto the TIP-20 ledger. -The reward subsystem is not receiver-side escrowable, but it is not exempt from recipient consent: - -- `distributeReward` remains token-internal accounting; -- `setRewardRecipient` MUST reject a nonzero recipient whose address-level receive controls would block the holder as originator; and -- `claimRewards` MUST revalidate the actual payout recipient's address-level receive controls against the token contract as originator and MUST revert on failure rather than escrow. +The reward subsystem is not receiver-side escrowable, but it is not exempt from recipient consent. `distributeReward` remains token-internal accounting; `setRewardRecipient` MUST reject a nonzero recipient whose address-level receive controls would block the holder as originator; and `claimRewards` MUST revalidate the actual payout recipient's address-level receive controls against the token contract as originator and MUST revert on failure rather than escrow. For reward accounting, `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. @@ -103,7 +78,7 @@ Token sets are a dedicated TIP-403 primitive for TIP-20 token addresses. They an - address policy: "is this originator authorized?" - token set: "is this token authorized?" -Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists, and they do not reuse the compound-policy surface. They do, however, mirror the ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. +Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists and do not reuse the compound-policy surface, but they mirror the ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. ### 2.1 Storage and Constraints @@ -119,17 +94,9 @@ mapping(uint64 => TokenSetData) internal _tokenSetData; mapping(uint64 => mapping(address => bool)) internal tokenSetMembers; ``` -Built-in meanings: - -- `0` = always reject -- `1` = always allow - -Token sets MUST satisfy: +Built-in meanings: `0` = always reject; `1` = always allow. -- `setType` is `WHITELIST` or `BLACKLIST` -- `COMPOUND` token sets are forbidden -- token-set type is immutable after creation -- membership is mutable by the token-set admin +Token sets MUST satisfy: `setType` is `WHITELIST` or `BLACKLIST`; `COMPOUND` token sets are forbidden; token-set type is immutable after creation; and membership is mutable by the token-set admin. ### 2.2 Interface @@ -171,30 +138,15 @@ interface ITIP403TokenSets { } ``` -`createTokenSetWithTokens(...)` is the token-set analogue of `createPolicyWithAccounts(...)`. The batch mutation functions are the token-set analogue of TIP-403 batch list updates for ordinary allowlists and denylists. +`createTokenSetWithTokens(...)` is the token-set analogue of `createPolicyWithAccounts(...)`, and the batch mutation functions are the token-set analogue of TIP-403 batch list updates for ordinary allowlists and denylists. ### 2.3 Authorization Logic -`isTokenAuthorized(tokenSetId, token)` MUST behave as follows: - -- if `tokenSetId == 0`, return reject -- if `tokenSetId == 1`, return allow -- otherwise: - - read the token set's immutable `setType` - - for a `WHITELIST`, return the stored membership bit for `token` - - for a `BLACKLIST`, return the negation of the stored membership bit for `token` +`isTokenAuthorized(tokenSetId, token)` MUST return reject for `tokenSetId == 0`, allow for `tokenSetId == 1`, and otherwise read the token set's immutable `setType`, returning the stored membership bit for `token` in a `WHITELIST` and the negation of that bit in a `BLACKLIST`. ### 2.4 Batch Update Semantics -For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`: - -- `tokens.length` and the corresponding boolean array length MUST match -- the caller authorization and policy-type checks are identical to the single-entry mutation functions -- the update MUST apply entries in order -- the call MUST be atomic -- the implementation MUST emit the ordinary per-token update event once for each touched token, rather than a separate batch-only event - -If TIP-403 standardizes a different canonical batch-list ABI before TIP-1028 is finalized, token sets SHOULD adopt that same ABI shape mutatis mutandis while preserving the semantics above. +For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`, `tokens.length` and the corresponding boolean array length MUST match; caller authorization and policy-type checks are identical to the single-entry mutation functions; updates MUST apply in order; the call MUST be atomic; and the implementation MUST emit the ordinary per-token update event once for each touched token, rather than a separate batch-only event. If TIP-403 standardizes a different canonical batch-list ABI before TIP-1028 is finalized, token sets SHOULD adopt that same ABI shape mutatis mutandis while preserving the semantics above. ### 2.5 Events and Errors @@ -211,32 +163,15 @@ error TokenSetBatchLengthMismatch(); ## 3. Address-Level Receive Controls -Any address MAY configure three fields: - -1. `receivePolicyId` - which originators may credit the address -2. `tokenSetId` - which TIP-20 token addresses may credit the address -3. `recoveryContract` - an optional address authorized to claim blocked receipts on behalf of the receiver, one receipt at a time; `address(0)` means the receiver claims directly - -If an address has no configured receive controls, address-level authorization defaults to allow. +Any address MAY configure three fields: `receivePolicyId` for which originators may credit the address, `tokenSetId` for which TIP-20 token addresses may credit the address, and `recoveryContract` for an optional claimer authorized to recover blocked receipts one receipt at a time; `address(0)` means the receiver claims directly. If an address has no configured receive controls, address-level authorization defaults to allow. TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. ### 3.1 Constraints -`receivePolicyId` MUST reference: +`receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. -- a simple `WHITELIST` policy, -- a simple `BLACKLIST` policy, or -- built-in policy `0` or `1` - -It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis - whether a given inbound originator may credit the receiver. TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles, so they do not map cleanly onto this receiver-side originator check. - -`recoveryContract`: - -- MAY be `address(0)` -- if nonzero, designates the sole direct claimer for future blocked receipts for this receiver -- MUST NOT equal `ESCROW_ADDRESS` -- MUST NOT be a TIP-1022 virtual address +`recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for future blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. ### 3.2 Blocked Reason and Recovery Contract @@ -249,9 +184,7 @@ enum BlockedReason { } ``` -`BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized. - -Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and its storage key and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. +`BlockedReason` classifies why an inbound was escrowed; `NONE` is used only when the inbound is authorized. Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and its storage key and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. ### 3.3 Packed Storage @@ -302,34 +235,16 @@ interface IAddressReceivePolicies { } ``` -Changing `recoveryContract` affects only future blocked receipts. Existing receipts remain governed by the `recoveryContract` captured when they were created. - Implementations SHOULD read `addressRecoveryContract[to]` only after `verifyAddressInbound(...)` returns `authorized = false`. ### 3.5 Authorization Logic -`verifyAddressInbound(token, originator, to)` MUST behave as follows: - -- read the packed config for `to` -- if `hasAddressPolicy == 0`, return: - - `authorized = true` - - `blockedReason = NONE` -- otherwise: - - decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType` - - evaluate whether `token` is allowed by the token set - - evaluate whether `originator` is allowed by the receive policy -- if both checks pass, return: - - `authorized = true` - - `blockedReason = NONE` -- if both checks fail, return: - - `authorized = false` - - `blockedReason = TOKEN_SET_AND_RECEIVE_POLICY` -- if only the token-set check fails, return: - - `authorized = false` - - `blockedReason = TOKEN_SET` -- if only the receive-policy check fails, return: - - `authorized = false` - - `blockedReason = RECEIVE_POLICY` +`verifyAddressInbound(token, originator, to)` MUST read the packed config for `to`. If `hasAddressPolicy == 0`, it MUST return `(true, NONE)`. Otherwise it MUST decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType`, evaluate whether `token` is allowed by the token set and whether `originator` is allowed by the receive policy, and return: + +- `(true, NONE)` if both checks pass +- `(false, TOKEN_SET_AND_RECEIVE_POLICY)` if both checks fail +- `(false, TOKEN_SET)` if only the token-set check fails +- `(false, RECEIVE_POLICY)` if only the receive-policy check fails An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. @@ -397,18 +312,11 @@ where: `blockedReceiptAmount[receiptKey]` stores the full amount for that open receipt. -The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the exact same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. +The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. -This is intentionally the same storage pattern as ordinary bucketing, but with a much finer key: one keyed amount per blocked inbound rather than one keyed amount per originator-wide aggregate. - -The precompile deliberately does **not** store: - -- receiver-wide aggregate balances -- signer lists or multisig state -- any global singleton recovery-policy state -- a field-by-field receipt struct in persistent storage +This is the same storage pattern as ordinary bucketing, but with a much finer key: one keyed amount per blocked inbound rather than one keyed amount per originator-wide aggregate. The precompile does **not** store receiver-wide aggregate balances, signer lists or multisig state, any global singleton recovery-policy state, or a field-by-field receipt struct in persistent storage. ### 4.2 Interface @@ -463,23 +371,13 @@ interface IBlockedInboundEscrow { `recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. -`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. - -The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. +`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. ### 4.3 Claim Authorization Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. -`claimBlockedReceipt(...)`: - -- consumes only the explicitly supplied receipt bucket -- releases only to `to` -- MUST require `msg.sender == receiver` when `recoveryContract == address(0)` -- MUST require `msg.sender == recoveryContract` when `recoveryContract != address(0)` -- MUST interpret `receipt` as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)` -- MUST require `blockedReceiptAmount[receiptKey] > 0` -- MUST consume the entire stored amount for that receipt or revert; partial claims are not allowed +`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST require `msg.sender == receiver` when `recoveryContract == address(0)` and `msg.sender == recoveryContract` when `recoveryContract != address(0)`. It MUST interpret `receipt` as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, require `blockedReceiptAmount[receiptKey] > 0`, and consume the entire stored amount for that receipt or revert. Partial claims are not allowed. The supplied receipt witness is a selector, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. @@ -569,19 +467,7 @@ A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the c ### 4.6 Standard Recovery Contract Pattern (Non-Normative) -The protocol does not mandate any particular recovery-contract design. - -The standard pattern is a reusable receiver-owned implementation, not a global singleton: - -- the receiver deploys its own instance, proxy, or smart-wallet module -- the receiver sets `recoveryContract` to that instance in `setAddressReceivePolicy(...)` -- the recovery contract stores one canonical `receiver` -- the recovery contract is the only direct caller of `claimBlockedReceipt(...)` for receipts keyed to that contract -- `claimToReceiver(...)` is the default unwind path -- reroutes to `to != receiver` are optional and SHOULD be separately permissioned -- originator self-claim, if supported, SHOULD only allow an originator to claim receipts whose supplied `originator` equals that caller, and only to itself - -If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained. +The protocol does not mandate any particular recovery-contract design. The standard pattern is a reusable receiver-owned implementation, not a global singleton: the receiver deploys its own instance, proxy, or smart-wallet module; sets `recoveryContract` to that instance in `setAddressReceivePolicy(...)`; stores one canonical `receiver`; uses `claimToReceiver(...)` as the default unwind path; optionally permits reroutes to `to != receiver`; and, if originator self-claim is supported, restricts it to receipts whose supplied `originator` equals the caller and only to the caller itself. If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained. Appendix A gives a non-normative Solidity reference design for this pattern. @@ -657,21 +543,7 @@ For a mint-like path: ### 5.3 Reward Delegation and Claims -Reward flows are never escrowed, but they MUST respect recipient consent. - -For `setRewardRecipient(holder, recipient)`: - -- `recipient == address(0)` remains the opt-out path and bypasses receive-policy checks -- otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` -- if that check is unauthorized, the call MUST revert - -For `claimRewards(...)`: - -- the token MUST determine the actual payout recipient under its existing reward rules -- it MUST revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` before crediting rewards -- if that check is unauthorized, the call MUST revert rather than escrow - -These rules prevent a holder from routing rewards to an unwilling recipient and prevent reward claims from bypassing address-level receive policies. +Reward flows are never escrowed, but they MUST respect recipient consent. For `setRewardRecipient(holder, recipient)`, `recipient == address(0)` remains the opt-out path and bypasses receive-policy checks; otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` and revert if unauthorized. For `claimRewards(...)`, the token MUST determine the actual payout recipient under its existing reward rules, revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` before crediting rewards, and revert rather than escrow if unauthorized. ### 5.4 Reward and Event Semantics @@ -703,12 +575,7 @@ For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtua ### 5.6 Integration Consequence -After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager or TIPFeeAMM wallet payouts may succeed in either of two states: - -1. the intended receiver was credited; or -2. the inbound was escrowed. - -Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` or `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. +After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager or TIPFeeAMM wallet payouts may succeed either because the intended receiver was credited or because the inbound was escrowed. Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` or `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. ## 6. Gas and Storage Analysis @@ -732,15 +599,7 @@ This section uses the gas model from TIP-1016: ### 6.2 Storage Choices -The design intentionally stores one fine-grained receipt bucket per blocked inbound. That is more expensive than an aggregate bucket, but it preserves: - -- the literal `requestedRecipient` needed for TIP-1022 attribution -- the original `originator` -- the block timestamp `blockedAt` for programmable recovery rules -- the distinction between transfer-blocked and mint-blocked funds -- memo and block-reason data for programmable recovery rules - -The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. This is the same storage pattern as ordinary bucketing, but with a much finer key. +The design intentionally stores one fine-grained receipt bucket per blocked inbound. That is more expensive than an aggregate bucket, but it preserves the literal `requestedRecipient` needed for TIP-1022 attribution, the original `originator`, the block timestamp `blockedAt`, the distinction between transfer-blocked and mint-blocked funds, and memo and block-reason data for programmable recovery rules. The persistent state is still just one keyed amount per blocked inbound, while the richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. ### 6.3 Escrow Slot Strategy @@ -760,13 +619,8 @@ If an implementation uses such a reserve: - **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. - **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-receipt events and claim from escrow SHOULD NOT opt in. -- **Claim-to-receiver is an unwind.** A claim back to `receiver` is not a new inbound and therefore bypasses the receiver's token-level and address-level receive checks. -- **Rerouted claims are new transfers.** A claim to `to != receiver` must satisfy token-level recipient authorization for `to` and that destination's address-level receive controls. -- **Reroutes preserve real originators.** Destination address-level checks for rerouted claims must use the real blocked receipt originator, not `ESCROW_ADDRESS`. -- **Direct receiver reroutes are spends.** If `recoveryContract == address(0)`, a reroute initiated through an access key must be metered against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend. -- **Recovery-contract authority is explicit.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, key-spend policy, or batching policy for that path becomes a userland concern of the recovery contract. -- **Recovery-contract changes are not retroactive.** Each blocked receipt is governed by the `recoveryContract` captured when the inbound was blocked. -- **Recovery-contract rotation can strand funds.** If a receiver changes `recoveryContract` and the old contract later becomes unusable, older blocked receipts keyed to that contract may become difficult or impossible to claim. +- **Claim-to-receiver is an unwind, while reroutes are new transfers.** Claim-to-receiver bypasses the receiver's token-level and address-level receive checks. A reroute to `to != receiver` must satisfy token-level recipient authorization for `to`, that destination's address-level receive controls, and, for direct receiver reroutes through an access key, ordinary AccountKeychain spending-limit metering. +- **Recovery-contract authority is explicit and not retroactive.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, key-spend policy, or batching policy for that path becomes a userland concern of the recovery contract. Older receipts remain keyed to the recovery contract captured when they were blocked, so rotation can strand funds if the old contract later becomes unusable. - **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. - **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. @@ -935,11 +789,4 @@ contract BasicBlockedReceiptRecovery { } ``` -This reference design intentionally does four things: - -- it makes `receiver` the only configuration authority -- it separates claims back to `receiver` from reroutes to third parties -- it allows delegated claims without forcing that delegation logic into the protocol -- it makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own authenticated receipt witness - -Receivers that need stronger policy MAY replace this with a multisig, smart wallet, timelock, or custom contract. If they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. +This reference design makes `receiver` the only configuration authority, separates claims back to `receiver` from reroutes to third parties, allows delegated claims without forcing that logic into the protocol, and makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own authenticated receipt witness. Receivers that need stronger policy MAY replace it with a multisig, smart wallet, timelock, or custom contract; if they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. From 56f69e9d6aafea466a07c7cd799df5df075462fd Mon Sep 17 00:00:00 2001 From: malleshpai <5857042+malleshpai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:32:59 -0400 Subject: [PATCH 14/59] docs(tip-1028): version blocked receipt buckets Co-authored-by: malleshpai <5857042+malleshpai@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab7c-efa4-7608-9058-7380358fbdb7 Co-authored-by: Amp --- tips/tip-1028.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 5aa3af8eea..ec5a8db790 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -14,7 +14,7 @@ protocolVersion: TBD This TIP extends TIP-403 with token sets for TIP-20 token addresses, address-level receive controls, and non-reverting escrow for blocked TIP-20 inbounds. A blocked inbound credits `ESCROW_ADDRESS` and creates one fine-grained receipt bucket. -The escrow precompile stores only one keyed amount for each open blocked receipt. The rest of the receipt identity — requested recipient, block reason, transfer-vs-mint kind, memo, timestamp, and nonce — is authenticated by the receipt witness and emitted in the blocked event. Claims may unwind to the receiver or reroute elsewhere; claim-to-receiver is an unwind, while reroutes are new spends. This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts remain out of scope. +The escrow precompile stores only one keyed amount for each open blocked receipt. The rest of the receipt identity — receipt version, requested recipient, block reason, transfer-vs-mint kind, memo, timestamp, and nonce — is authenticated by the receipt witness and emitted in the blocked event. Claims may unwind to the receiver or reroute elsewhere; claim-to-receiver is an unwind, while reroutes are new spends. This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts remain out of scope. ## Motivation @@ -275,6 +275,7 @@ ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000 Each blocked inbound creates exactly one fine-grained receipt bucket. ```solidity +uint8 public constant BLOCKED_RECEIPT_VERSION = 1; uint64 public blockedReceiptNonce = 1; mapping(bytes32 => uint256) internal blockedReceiptAmount; ``` @@ -284,6 +285,7 @@ The persistent escrow key for a blocked inbound is: ```text receiptKey = keccak256( abi.encode( + receiptVersion, token, receiver, originator, @@ -300,6 +302,7 @@ receiptKey = keccak256( where: +- `receiptVersion` is a one-byte bucketing version tag and MUST currently be `1` - `receiver` is the canonical TIP-20 holder that owns the blocked receipt - `originator` is `from` for transfers and mint caller for mints - `requestedRecipient` is the literal `to` address and therefore preserves TIP-1022 attribution @@ -310,6 +313,8 @@ where: - `blockedAt` is the block timestamp captured when the receipt is recorded - `blockedNonce` is a monotonically increasing global disambiguator assigned at receipt creation time +Future bucketing or receipt-key formats MUST use a different `receiptVersion` value. + `blockedReceiptAmount[receiptKey]` stores the full amount for that open receipt. The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. @@ -328,6 +333,7 @@ interface IBlockedInboundEscrow { } struct ClaimReceipt { + uint8 receiptVersion; address originator; address requestedRecipient; uint64 blockedAt; @@ -371,13 +377,13 @@ interface IBlockedInboundEscrow { `recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. -`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. +`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. `receipt.receiptVersion` MUST be `1` for receipts created under this TIP. The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. ### 4.3 Claim Authorization Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. -`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST require `msg.sender == receiver` when `recoveryContract == address(0)` and `msg.sender == recoveryContract` when `recoveryContract != address(0)`. It MUST interpret `receipt` as the tuple `(token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, require `blockedReceiptAmount[receiptKey] > 0`, and consume the entire stored amount for that receipt or revert. Partial claims are not allowed. +`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST require `msg.sender == receiver` when `recoveryContract == address(0)` and `msg.sender == recoveryContract` when `recoveryContract != address(0)`. It MUST interpret `receipt` as the tuple `(receipt.receiptVersion, token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, require `blockedReceiptAmount[receiptKey] > 0`, and consume the entire stored amount for that receipt or revert. Partial claims are not allowed. The supplied receipt witness is a selector, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. @@ -421,6 +427,7 @@ event TransferBlocked( address indexed token, address indexed from, address indexed receiver, + uint8 receiptVersion, uint64 blockedNonce, uint64 blockedAt, address requestedRecipient, @@ -434,6 +441,7 @@ event MintBlocked( address indexed token, address indexed operator, address indexed receiver, + uint8 receiptVersion, uint64 blockedNonce, uint64 blockedAt, address requestedRecipient, @@ -446,6 +454,7 @@ event MintBlocked( event BlockedReceiptClaimed( address indexed token, address indexed receiver, + uint8 receiptVersion, uint64 indexed blockedNonce, uint64 blockedAt, address originator, @@ -505,7 +514,7 @@ For a transfer-like path: - credit `ESCROW_ADDRESS` - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)` - emit `Transfer(from, ESCROW_ADDRESS, amount)` - - emit `TransferBlocked(token, from, effectiveReceiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` + - emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. @@ -536,7 +545,7 @@ For a mint-like path: - credit `ESCROW_ADDRESS` - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)` - emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - - emit `MintBlocked(token, originator, effectiveReceiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` + - emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - return success `mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. @@ -556,8 +565,8 @@ Blocked inbounds MUST use truthful raw TIP-20 events: In addition, every blocked inbound MUST emit exactly one attribution event: -- `TransferBlocked(token, from, receiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -- `MintBlocked(token, operator, receiver, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- `TransferBlocked(token, from, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- `MintBlocked(token, operator, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` `blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. @@ -666,7 +675,7 @@ The following invariants MUST always hold: - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. +16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's version, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. 17. Claims MUST consume whole blocked receipts. Once a receipt is claimed, its keyed amount MUST be deleted rather than partially decremented. @@ -699,6 +708,7 @@ interface IBlockedInboundEscrowReference { } struct ClaimReceipt { + uint8 receiptVersion; address originator; address requestedRecipient; uint64 blockedAt; From 5e774862d1ff240c0be35d9a4efac848f62cd360 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 30 Apr 2026 19:42:31 +0200 Subject: [PATCH 15/59] docs: rewrite for clarity Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019ddf7e-fb21-76a8-b851-e98e593519cd --- tips/tip-1028.md | 565 +++++++++++++++++++++++++++++++---------------- 1 file changed, 378 insertions(+), 187 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index ec5a8db790..fd4a1d2024 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -12,21 +12,127 @@ protocolVersion: TBD ## Abstract -This TIP extends TIP-403 with token sets for TIP-20 token addresses, address-level receive controls, and non-reverting escrow for blocked TIP-20 inbounds. A blocked inbound credits `ESCROW_ADDRESS` and creates one fine-grained receipt bucket. +This TIP introduces receiver-controlled inbound policies for TIP-20 tokens. Today, only token issuers (via TIP-403 / TIP-1015) decide who may move a token; receivers cannot independently filter what arrives in their balance. This TIP adds: -The escrow precompile stores only one keyed amount for each open blocked receipt. The rest of the receipt identity — receipt version, requested recipient, block reason, transfer-vs-mint kind, memo, timestamp, and nonce — is authenticated by the receipt witness and emitted in the blocked event. Claims may unwind to the receiver or reroute elsewhere; claim-to-receiver is an unwind, while reroutes are new spends. This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts remain out of scope. +1. **Token sets** — a new TIP-403 primitive that lets any address declare which TIP-20 token addresses may credit it. +2. **Address-level receive policies** — a per-address configuration on the TIP-403 registry that filters inbound TIP-20 credits by originator and by token, with an optional dedicated recovery contract. +3. **An escrow precompile** — a new precompile that records a fine-grained receipt for each blocked inbound TIP-20 transfer, and lets the receiver (or its designated recovery contract) claim or reroute the funds later. + +A blocked inbound never reverts: the funds are credited to a reserved `ESCROW_ADDRESS` inside the TIP-20 token, one receipt bucket is recorded by the escrow precompile, and a blocked-inbound event is emitted that authenticates the receipt witness. Pre-existing token-level (issuer-side) failures continue to revert exactly as today. + +This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts deployed as userland code remain out of scope. ## Motivation -TIP-403 lets token issuers decide who may use a token, but some receivers also need inbound controls. A revert-based receiver policy creates a liveness problem: once a relationship exists, the receiver can later change policy and cause future transfers or mints to revert. +TIP-403 is an issuer-side authorization layer: the token issuer decides who may send, who may receive, and who may mint to whom. It cannot answer the receiver's question, *"do I want to be exposed to this counterparty or this token at all?"* + +Plain receiver-side reverts are not a viable substitute. Once an issuer–receiver–counterparty relationship exists, a receiver that later changes a revert-based policy can break in-flight flows and recurring inbounds across the entire system. Reverting receivers also create an asymmetric DoS surface against issuers, integrations, and protocol-owned distribution paths (DEX payouts, fee distributions, AMM outputs). + +The design choice in TIP-1028 is therefore: + +- **Token-level / issuer-side failures keep reverting.** Issuers retain full control of the token's authorization model; nothing in their semantics changes. +- **Receiver-side failures escrow instead of reverting.** Senders, mints, DEX payouts, and fee distributions still succeed at the protocol layer; the asset is held in `ESCROW_ADDRESS` until the receiver (or its recovery contract) claims it. -This TIP keeps issuer-side semantics unchanged and changes only receiver-side failure handling. Token-level failures still revert; receiver-side failures are escrowed instead. The escrow representation must preserve more than an aggregate amount because TIP-1022 virtual addresses carry attribution in the literal `to` address, memo-bearing transfers carry memo data, recovery contracts may want time-based or memo-based rules, and offchain recovery flows need the original sender identity. +Escrowed funds must carry enough metadata to be programmatically recoverable. An aggregate "amount blocked per (token, receiver)" bucket cannot express TIP-1022 attribution, memo routing, originator-based recovery rules, or transfer-vs-mint provenance. Therefore, the escrow precompile stores one keyed amount per blocked inbound, with ALL the receipt identity (version, requested recipient, block reason, kind, memo, timestamp, nonce) authenticated by the receipt witness and emitted in the blocked event — keeping persistent state to a single slot per receipt while preserving full attribution offchain. + +## Assumptions + +- **TIP-403 registry exists and is extensible.** Token sets and address-level receive policies are added as new state and new entrypoints on the existing TIP-403 precompile registry, alongside its current address-policy surface. This TIP does not modify the existing TIP-403 address-policy ABI. +- **TIP-1015 compound policies are unchanged.** Receiver-side address-level controls evaluate exactly one axis (originator → receiver), and never cross-validate against the issuer's `transfer_recipient` / `mint_recipient` roles. +- **TIP-1022 resolution is available at TIP-20 inbound time.** Virtual-address resolution runs before TIP-1028 receiver-side checks; failed resolution still reverts. +- **TIP-1016 gas model.** Cost analysis uses the storage-cost model from TIP-1016 (fresh slot ≈ 250k gas, hot slot update ≈ 2.9k gas, baseline TIP-20 transfer ≈ 50k gas). +- **`ESCROW_ADDRESS` is reserved at protocol level.** No TIP-20 implementation, system contract, or user may treat `ESCROW_ADDRESS` as an ordinary holder. Userland transfers and mints to it MUST revert. +- **Reward state never lives at `ESCROW_ADDRESS`.** Implementations rely on treating the escrow sink as a reward-exempt always-opted-out address; violating this assumption silently corrupts the opted-in supply for reward distribution. +- **Backwards compatibility.** Addresses with no configured receive controls behave exactly as today. The only new ambient cost on existing flows is one cold storage read of the receiver's packed receive config slot. # Specification -## 1. Scope and Model +## 1. System Architecture + +This TIP is intentionally additive: it does not alter TIP-403's existing address-policy ABI, it does not change issuer-side TIP-20 semantics, and it does not introduce new surfaces in TIP-20 callers' interfaces. The new functionality is split across three protocol components, summarized below. + +### 1.1 Component Map + +| Component | Status | Owns | +|-----------|--------|------| +| **TIP-403 registry** (existing) | extended | Token sets (new primitive), address-level receive config (new mapping), recovery-contract mapping (new mapping), the new `verifyAddressInbound(...)` view | +| **Escrow Precompile** (new, address `ESCROW_ADDRESS`) | introduced by this TIP | Per-receipt amount mapping keyed by a witness-derived hash, `recordBlockedInbound(...)` ingest entrypoint, `claimBlockedReceipt(...)` claim entrypoint, blocked / claim attribution events; also acts as the on-token holder of all blocked balances — `balances[ESCROW_ADDRESS]` inside each TIP-20 token is held by this precompile | +| **TIP-20 token precompile** (existing) | extended | Inbound dispatch: TIP-1022 resolution → issuer-side checks → call `verifyAddressInbound(...)` → either credit `effectiveReceiver` or credit `ESCROW_ADDRESS` and call `recordBlockedInbound(...)`; new escrow-release internal path for `claimBlockedReceipt`; raw-event truthfulness for blocked credits; `setRewardRecipient` / `claimRewards` recipient revalidation | + +The escrow precompile and the TIP-403 registry are independent: the escrow precompile authenticates claims using the receipt witness and the receipt's snapshotted recovery contract, and does **not** read the receiver's current TIP-403 receive config when claiming. The TIP-403 registry, conversely, has no knowledge of escrowed amounts: it only answers `verifyAddressInbound(...)` and exposes the current `recoveryContract` of an address. + +### 1.2 End-to-End Flow Overview + +The TIP-20 inbound path is the single integration point. Pseudocode: + +```text +on TIP-20 inbound (transfer-like or mint-like) with literal `to`: + if to == ESCROW_ADDRESS: + revert EscrowAddressReserved + if to is a TIP-1022 virtual address: + effectiveReceiver = resolveRecipient(to) # may revert + else: + effectiveReceiver = to + requestedRecipient = to + memo = supplied memo, or bytes32(0) for non-memo variants + + # Issuer-side / token-level checks unchanged. + run TIP-20 pause / balance / allowance / TIP-403 / TIP-1015 checks + (using effectiveReceiver wherever recipient-sensitive) + # ^ failure here MUST revert. + + # Receiver-side check (new in TIP-1028). + (authorized, blockedReason) = + TIP403.verifyAddressInbound(token, originator, effectiveReceiver) + + if authorized: + # Existing happy path, with effectiveReceiver as the credited holder. + update rewards, debit/mint, credit effectiveReceiver, + emit ordinary TIP-20 / TIP-1022 events, return success. + + else: + recoveryContract = TIP403.addressRecoveryContract(effectiveReceiver) + update rewards as if recipient were ESCROW_ADDRESS + (reward-exempt, always-opted-out) + debit / mint amount, credit ESCROW_ADDRESS + (blockedNonce, blockedAt) = Escrow.recordBlockedInbound( + token, originator, effectiveReceiver, requestedRecipient, + recoveryContract, amount, blockedReason, kind, memo + ) + emit truthful raw TIP-20 events naming ESCROW_ADDRESS + emit TransferBlocked(...) or MintBlocked(...) + return success. +``` + +Claim path: + +```text +on Escrow.claimBlockedReceipt(token, receiver, recoveryContract, receipt, to): + require msg.sender == receiver if recoveryContract == 0 + else msg.sender == recoveryContract + receiptKey = keccak256(witness fields) + require blockedReceiptAmount[receiptKey] > 0 + amount = blockedReceiptAmount[receiptKey]; delete it + + if to == receiver: + # Unwind: bypass receiver-side controls (token-level recipient + address-level + key metering). + TIP20.escrowReleaseUnwind(token, receiver, amount) + else: + # Reroute: new spend. + require to != ESCROW_ADDRESS + require to is not a TIP-1022 virtual address + require token-level recipient authorization for `to` + require TIP403.verifyAddressInbound(token, receipt.originator, to).authorized + if recoveryContract == 0 and call entered via access key: + meter amount against receiver's AccountKeychain spending limit + TIP20.escrowReleaseTransfer(token, to, amount) + + emit BlockedReceiptClaimed(...) +``` + +### 1.3 Scope -TIP-1028 applies to the following TIP-20 recipient-bearing paths: +The address-level inbound authorization layer applies to the following TIP-20 recipient-bearing paths: - `transfer` - `transferFrom` @@ -46,39 +152,34 @@ For all such paths, TIP-1028 adds a receiver-side authorization layer: If a token-level TIP-403 or TIP-1015 policy rejects the operation, the operation MUST revert exactly as it does today. If the receiver's address-level controls reject the inbound, the operation MUST be escrowed instead. -If `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur before TIP-1028 receiver-side authorization. In that case: - -- TIP-1028 applies to the resolved master address, not the literal virtual address; -- if virtual-address resolution fails, the operation MUST revert rather than escrow; -- the blocked receipt's `receiver` MUST be the resolved master address; -- the blocked receipt's `requestedRecipient` MUST be the literal `to` address so offchain systems can recover the TIP-1022 `userTag`; and -- if the inbound is authorized, the success path MUST then follow TIP-1022 forwarding semantics. - -TIP-1028 does **not** alter `approve`, `permit`, `burn`, fee refunds via `transfer_fee_post_tx`, non-TIP-20 tokens deployed as ordinary contracts, or future recipient-bearing system-credit paths that do not identify a concrete originator. - -DEX internal balances are not TIP-20 wallet balances and are not subject to address-level receive checks until withdrawn back onto the TIP-20 ledger. +TIP-1028 does **NOT** alter: -The reward subsystem is not receiver-side escrowable, but it is not exempt from recipient consent. `distributeReward` remains token-internal accounting; `setRewardRecipient` MUST reject a nonzero recipient whose address-level receive controls would block the holder as originator; and `claimRewards` MUST revalidate the actual payout recipient's address-level receive controls against the token contract as originator and MUST revert on failure rather than escrow. +- `approve` / `permit` / `burn` +- fee refunds via `transfer_fee_post_tx` +- non-TIP-20 tokens deployed as ordinary contracts +- future recipient-bearing system-credit paths that do not identify a concrete originator +- DEX *internal* balances. Gating only kicks in when funds are withdrawn back onto the TIP-20 ledger +- the reward subsystem's escrow-ability. Rewards are never escrowed; see [Section 6.3](#63-reward-subsystem) -For reward accounting, `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +### 1.4 TIP-1022 Interaction -High-level flow: +If the literal `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur **before** TIP-1028 receiver-side authorization. Specifically: -1. If `to` is a TIP-1022 virtual address, resolve it first to `effectiveReceiver`; otherwise `effectiveReceiver = to`. -2. Run the existing TIP-20 sender-side or issuer-side checks and the token's TIP-403 and TIP-1015 checks using `effectiveReceiver` wherever the current path is recipient-sensitive. -3. Run the receiver's token-set and receive-policy checks against `effectiveReceiver`. -4. If the inbound is authorized, credit the receiver normally. -5. If the inbound is blocked by the receiver, credit `ESCROW_ADDRESS`, create one blocked receipt, and emit a blocked-inbound event naming both `receiver` and `requestedRecipient`. -6. Later, the receiver or the receipt's recovery contract may claim the funds. +- TIP-1028 authorization applies to the resolved master address, never to the literal virtual address. +- If virtual-address resolution itself fails, the operation MUST revert rather than escrow. +- The blocked receipt's `receiver` MUST be the resolved master address. +- The blocked receipt's `requestedRecipient` MUST be the literal `to`, so offchain systems can recover the TIP-1022 `userTag`. +- If the inbound is authorized, the success path MUST follow TIP-1022 forwarding event semantics. +- TIP-1022 virtual addresses MUST NOT configure their own receive policy (see [Section 3.1](#31-constraints)). The master address's policy governs all virtual addresses derived from it. -## 2. Token Sets +## 2. Token Sets (TIP-403 Extension) -Token sets are a dedicated TIP-403 primitive for TIP-20 token addresses. They answer a different question from address policies: +Token sets are a new TIP-403 primitive *for token addresses*. They answer a different question from address policies: -- address policy: "is this originator authorized?" -- token set: "is this token authorized?" +- **address policy**: filters by *counterparty* — *"is this counterparty allowed to interact with this token?"* +- **token set**: filters by *token* — *"is this token allowed to interact with this address?"* -Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists and do not reuse the compound-policy surface, but they mirror the ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. +Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists and do not reuse the compound-policy surface, but they mirror ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. ### 2.1 Storage and Constraints @@ -96,7 +197,12 @@ mapping(uint64 => mapping(address => bool)) internal tokenSetMembers; Built-in meanings: `0` = always reject; `1` = always allow. -Token sets MUST satisfy: `setType` is `WHITELIST` or `BLACKLIST`; `COMPOUND` token sets are forbidden; token-set type is immutable after creation; and membership is mutable by the token-set admin. +Token sets MUST satisfy: + +- `setType` is `WHITELIST` or `BLACKLIST`. +- `COMPOUND` token sets are forbidden. +- Token-set type is immutable after creation. +- Membership is mutable by the token-set admin. ### 2.2 Interface @@ -142,11 +248,21 @@ interface ITIP403TokenSets { ### 2.3 Authorization Logic -`isTokenAuthorized(tokenSetId, token)` MUST return reject for `tokenSetId == 0`, allow for `tokenSetId == 1`, and otherwise read the token set's immutable `setType`, returning the stored membership bit for `token` in a `WHITELIST` and the negation of that bit in a `BLACKLIST`. +`isTokenAuthorized(tokenSetId, token)` MUST: + +- return `false` for `tokenSetId == 0`, +- return `true` for `tokenSetId == 1`, +- otherwise read the token set's immutable `setType` and return the stored membership bit for `token` in a `WHITELIST`, or its negation in a `BLACKLIST`. ### 2.4 Batch Update Semantics -For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`, `tokens.length` and the corresponding boolean array length MUST match; caller authorization and policy-type checks are identical to the single-entry mutation functions; updates MUST apply in order; the call MUST be atomic; and the implementation MUST emit the ordinary per-token update event once for each touched token, rather than a separate batch-only event. If TIP-403 standardizes a different canonical batch-list ABI before TIP-1028 is finalized, token sets SHOULD adopt that same ABI shape mutatis mutandis while preserving the semantics above. +For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`: + +- `tokens.length` and the corresponding boolean array length MUST match. +- Caller authorization and policy-type checks are identical to the single-entry mutation functions. +- Updates MUST apply in order. +- The call MUST be atomic. +- The implementation MUST emit the ordinary per-token update event once for each touched token, not a separate batch-only event. ### 2.5 Events and Errors @@ -161,19 +277,27 @@ error InvalidTokenSetType(); error TokenSetBatchLengthMismatch(); ``` -## 3. Address-Level Receive Controls +## 3. Address-Level Receive Controls (TIP-403 Extension) -Any address MAY configure three fields: `receivePolicyId` for which originators may credit the address, `tokenSetId` for which TIP-20 token addresses may credit the address, and `recoveryContract` for an optional claimer authorized to recover blocked receipts one receipt at a time; `address(0)` means the receiver claims directly. If an address has no configured receive controls, address-level authorization defaults to allow. +Address-level receive controls are a new per-address configuration on the TIP-403 registry. They expose three fields: -TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. +- `receivePolicyId` — which originators may credit the address. +- `tokenSetId` — which TIP-20 token addresses may credit the address. +- `recoveryContract` — an optional dedicated claimer for blocked receipts; `address(0)` means the receiver claims directly. + +If an address has no configured receive controls, address-level authorization defaults to allow (i.e., `verifyAddressInbound(...)` returns `(true, NONE)`). ### 3.1 Constraints -`receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. +- `receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. + + Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. The two are semantically incompatible. -`recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for future blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. +- `recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for **future** blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. -### 3.2 Blocked Reason and Recovery Contract +- TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. + +### 3.2 Blocked Reasons ```solidity enum BlockedReason { @@ -184,9 +308,15 @@ enum BlockedReason { } ``` -`BlockedReason` classifies why an inbound was escrowed; `NONE` is used only when the inbound is authorized. Each blocked inbound snapshots the receiver's current `recoveryContract`. That snapshot becomes part of the blocked receipt and its storage key and governs later claims for that receipt. Changing `recoveryContract` affects only future receipts. +`BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized; blocked events MUST NOT set `blockedReason == NONE`. + +### 3.3 Recovery Contract Semantics + +Each blocked inbound snapshots the receiver's *current* `recoveryContract` at block time. That snapshot becomes part of the blocked receipt and its storage key, and governs later claims for that receipt. **Changing `recoveryContract` affects only future receipts.** Existing receipt buckets remain governed by the recovery contract captured in their key. -### 3.3 Packed Storage +This makes recovery-contract authority explicit and non-retroactive. Receivers that rotate `recoveryContract` SHOULD keep the previous contract callable until receipts keyed to it are drained. + +### 3.4 Packed Storage ```solidity mapping(address => uint256) public addressReceiveConfig; @@ -206,7 +336,7 @@ When `hasAddressPolicy == 0`, the address is always authorized at the address le `recoveryContract` is stored separately because a 160-bit address does not fit in the packed config slot. -### 3.4 Interface +### 3.5 Interface ```solidity interface IAddressReceivePolicies { @@ -237,18 +367,21 @@ interface IAddressReceivePolicies { Implementations SHOULD read `addressRecoveryContract[to]` only after `verifyAddressInbound(...)` returns `authorized = false`. -### 3.5 Authorization Logic +### 3.6 Authorization Logic -`verifyAddressInbound(token, originator, to)` MUST read the packed config for `to`. If `hasAddressPolicy == 0`, it MUST return `(true, NONE)`. Otherwise it MUST decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType`, evaluate whether `token` is allowed by the token set and whether `originator` is allowed by the receive policy, and return: +`verifyAddressInbound(token, originator, to)` MUST: -- `(true, NONE)` if both checks pass -- `(false, TOKEN_SET_AND_RECEIVE_POLICY)` if both checks fail -- `(false, TOKEN_SET)` if only the token-set check fails -- `(false, RECEIVE_POLICY)` if only the receive-policy check fails +1. Read the packed config for `to`. +2. If `hasAddressPolicy == 0`, return `(true, NONE)`. +3. Otherwise, decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType`. Evaluate whether `token` is allowed by the token set, and whether `originator` is allowed by the receive policy. Return: + - `(true, NONE)` if both checks pass. + - `(false, TOKEN_SET_AND_RECEIVE_POLICY)` if both fail. + - `(false, TOKEN_SET)` if only the token-set check fails. + - `(false, RECEIVE_POLICY)` if only the receive-policy check fails. An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. -### 3.6 Events and Errors +### 3.7 Events and Errors ```solidity event AddressReceivePolicyUpdated( @@ -264,15 +397,26 @@ error InvalidRecoveryContract(); ## 4. Escrow Precompile -Blocked inbounds are recorded in a dedicated escrow precompile. The raw TIP-20 balance is held at `ESCROW_ADDRESS` inside each TIP-20 token; the precompile stores only one keyed amount for each open blocked receipt. +The escrow precompile is a new system precompile dedicated to blocked-inbound bookkeeping. It lives at `ESCROW_ADDRESS`: the blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot — i.e., held by this precompile — while the precompile's own storage records **only one keyed amount per open blocked receipt**. The rest of the receipt identity is authenticated by the witness fields supplied at claim time and is emitted in the blocked-inbound event when the receipt is created. + +### 4.1 Precompile Address ```solidity -ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000 +address constant ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000; ``` -### 4.1 Storage +`ESCROW_ADDRESS` is the address of this precompile. Calls to it execute precompile code, and `balances[ESCROW_ADDRESS]` inside each TIP-20 token represents tokens held by this precompile. It is a system precompile, not a userland account, and the TIP-20 layer never enumerates blocked receipts at this address — receipt accounting lives in the precompile's own storage ([Section 4.2](#42-storage)). -Each blocked inbound creates exactly one fine-grained receipt bucket. +Reservation rules: + +- Userland TIP-20 calls with `to == ESCROW_ADDRESS` (transfer-like or mint-like) MUST revert with `EscrowAddressReserved()`. +- `setAddressReceivePolicy(...)` MUST reject `ESCROW_ADDRESS` and any `recoveryContract == ESCROW_ADDRESS`. +- Reroute claims with `to == ESCROW_ADDRESS` MUST revert. +- Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. + +For reward accounting purposes (see [Section 6.3](#63-reward-subsystem)), `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. + +### 4.2 Storage ```solidity uint8 public constant BLOCKED_RECEIPT_VERSION = 1; @@ -302,18 +446,17 @@ receiptKey = keccak256( where: -- `receiptVersion` is a one-byte bucketing version tag and MUST currently be `1` -- `receiver` is the canonical TIP-20 holder that owns the blocked receipt -- `originator` is `from` for transfers and mint caller for mints -- `requestedRecipient` is the literal `to` address and therefore preserves TIP-1022 attribution -- `recoveryContract` is the receiver's snapshotted recovery contract, or `address(0)` -- `blockedReason` records whether the receiver blocked the inbound because of its token set, receive policy, or both -- `kind` distinguishes transfer-blocked from mint-blocked receipts -- `memo` preserves the original memo for memo-bearing paths and is `bytes32(0)` otherwise -- `blockedAt` is the block timestamp captured when the receipt is recorded -- `blockedNonce` is a monotonically increasing global disambiguator assigned at receipt creation time - -Future bucketing or receipt-key formats MUST use a different `receiptVersion` value. +- `receiptVersion` — one-byte bucketing tag; MUST be `1` for receipts created under this TIP. Future bucketing or receipt-key formats MUST use a different value. +- `token` — the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS`. +- `receiver` — the canonical TIP-20 holder that owns the receipt (resolved master address for TIP-1022 inbounds). +- `originator` — `from` for transfers, mint caller for mints. +- `requestedRecipient` — the literal `to` supplied at the TIP-20 entrypoint; preserves TIP-1022 attribution. For non-virtual inbounds, `requestedRecipient == receiver`. +- `recoveryContract` — the receiver's snapshotted recovery contract, or `address(0)`. +- `blockedReason` — receiver's token set, receive policy, or both. +- `kind` — `TRANSFER` or `MINT` (see [Section 4.3](#43-interface)). +- `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. @@ -321,9 +464,9 @@ The escrow precompile does not need to store the rest of the receipt field-by-fi For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. -This is the same storage pattern as ordinary bucketing, but with a much finer key: one keyed amount per blocked inbound rather than one keyed amount per originator-wide aggregate. The precompile does **not** store receiver-wide aggregate balances, signer lists or multisig state, any global singleton recovery-policy state, or a field-by-field receipt struct in persistent storage. +It also does not enumerate receiver-owned receipts onchain. Claimers MUST supply the receipt witness for the receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. -### 4.2 Interface +### 4.3 Interface ```solidity interface IBlockedInboundEscrow { @@ -375,52 +518,76 @@ interface IBlockedInboundEscrow { } ``` -`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. +`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. Userland callers MUST NOT be able to fabricate receipts. -`requestedRecipient` is the literal `to` supplied to the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` is the literal virtual address. `receipt.receiptVersion` MUST be `1` for receipts created under this TIP. The precompile does not enumerate receiver-owned receipts onchain. Claimers MUST supply the blocked receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. - -### 4.3 Claim Authorization +### 4.4 Claim Authorization Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. -`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST require `msg.sender == receiver` when `recoveryContract == address(0)` and `msg.sender == recoveryContract` when `recoveryContract != address(0)`. It MUST interpret `receipt` as the tuple `(receipt.receiptVersion, token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, require `blockedReceiptAmount[receiptKey] > 0`, and consume the entire stored amount for that receipt or revert. Partial claims are not allowed. +`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST: + +- require `msg.sender == receiver` when `recoveryContract == address(0)`, +- require `msg.sender == recoveryContract` when `recoveryContract != address(0)`, +- interpret `receipt` as the witness tuple `(receipt.receiptVersion, token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, +- recompute `receiptKey`, +- require `blockedReceiptAmount[receiptKey] > 0`, +- consume the entire stored amount for that receipt and delete the slot, or revert with `InvalidReceiptClaim()`. + +**Partial claims are not allowed.** Claims MUST consume whole receipts. + +The supplied receipt witness is a *selector*, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. Receivers who want delegate whitelists, originator self-claim, multisig approval, timelocks, batching, or any richer recovery policy SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See [Section 4.6](#46-standard-recovery-contract-pattern-non-normative) and Appendix A for a non-normative reference pattern. + +### 4.5 Release Semantics -The supplied receipt witness is a selector, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. +All claims release only to the caller-specified `to`. The escrow precompile MUST call an internal TIP-20 escrow-release path that: -If a receiver wants delegate whitelists, originator self-claim, multisig approval, timelocks, or any other richer recovery policy, it SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See Section 4.6 and Appendix A for a non-normative standard pattern. +1. debits `balances[ESCROW_ADDRESS]`, +2. credits the beneficiary, +3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)`, +4. bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, +5. treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. -### 4.4 Release Semantics +The two release modes differ only in what they enforce on the destination side. -All claims release only to the specified `to`. +#### 4.5.1 Claim to Receiver (Unwind) -The escrow precompile MUST call an internal TIP-20 escrow-release path that: +If `to == receiver`, the claim is an **unwind** of a previously authorized inbound. It MUST: -1. debits `balances[ESCROW_ADDRESS]` -2. credits the beneficiary -3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -4. bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS` -5. treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source +- bypass the receiver's address-level receive controls, +- bypass token-level recipient authorization for the receiver, +- bypass AccountKeychain spending-limit metering. -If `to == receiver`, the claim is an unwind of a previously authorized inbound to that receiver. It MUST: +This is intentional for TIP-1015 compound policies. A blocked mint, for example, has already satisfied the token's original `mint_recipient` authorization on the inbound path. Releasing that escrow back to the same receiver is an unwind of a previously authorized mint-like inbound, not a new transfer, and MUST NOT be rechecked against transfer-recipient authorization. -- bypass the receiver's address-level receive controls -- bypass token-level recipient authorization for the receiver -- bypass AccountKeychain spending-limit metering +#### 4.5.2 Reroute (`to != receiver`) -This is intentional for TIP-1015 compound policies. A blocked mint has already satisfied the token's original `mint_recipient` authorization on the inbound path. Releasing that escrow back to the same `receiver` is an unwind of that previously authorized mint-like inbound, not a new transfer, so it MUST NOT be rechecked against transfer-recipient authorization. +If `to != receiver`, the claim is a **reroute**, treated as a new spend by the receiver. It MUST: -If `to != receiver`, the claim is a rerouted release. It MUST: +- reject `to == ESCROW_ADDRESS`, +- reject TIP-1022 virtual addresses as `to`, +- enforce token-level transfer-recipient authorization for `to`, +- enforce `to`'s address-level receive controls against the consumed receipt's `originator`, +- revert with `ClaimDestinationUnauthorized()` if the destination's checks fail, +- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver. -- reject `to == ESCROW_ADDRESS` -- reject TIP-1022 virtual addresses as `to` -- enforce token-level transfer-recipient authorization for `to` -- enforce `to`'s address-level receive controls against the receipt's `originator` -- revert if that receipt fails the destination's address-level checks -- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver +If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key-policy enforcement is a userland concern of that contract. -If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key policy is a userland concern. +### 4.6 Standard Recovery Contract Pattern (Non-Normative) + +The protocol does not mandate any particular recovery-contract design. The standard pattern is a reusable receiver-owned implementation, not a global singleton: + +- the receiver deploys its own instance, proxy, or smart-wallet module; +- sets `recoveryContract` to that instance via `setAddressReceivePolicy(...)`; +- stores one canonical `receiver`; +- uses `claimToReceiver(...)` as the default unwind path; +- optionally permits reroutes to `to != receiver`; +- if originator self-claim is supported, restricts it to receipts whose supplied `originator` equals the caller and only releases to the caller itself. -### 4.5 Events and Errors +If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained (see [Section 3.3](#33-recovery-contract-semantics)). + +Appendix A gives a non-normative Solidity reference design for this pattern. + +### 4.7 Events and Errors ```solidity event TransferBlocked( @@ -474,14 +641,10 @@ error InvalidReceiptClaim(); A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. -### 4.6 Standard Recovery Contract Pattern (Non-Normative) - -The protocol does not mandate any particular recovery-contract design. The standard pattern is a reusable receiver-owned implementation, not a global singleton: the receiver deploys its own instance, proxy, or smart-wallet module; sets `recoveryContract` to that instance in `setAddressReceivePolicy(...)`; stores one canonical `receiver`; uses `claimToReceiver(...)` as the default unwind path; optionally permits reroutes to `to != receiver`; and, if originator self-claim is supported, restricts it to receipts whose supplied `originator` equals the caller and only to the caller itself. If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained. - -Appendix A gives a non-normative Solidity reference design for this pattern. - ## 5. TIP-20 Inbound Path Changes +This section specifies the exact integration each TIP-20 path takes. All new logic lives in the TIP-20 token precompile; the token consults TIP-403 ([Section 3](#3-address-level-receive-controls-tip-403-extension)) and the escrow precompile ([Section 4](#4-blocked-inbound-escrow-precompile)) but owns the inbound dispatch. + Userland TIP-20 transfers or mints directly to `ESCROW_ADDRESS` MUST revert: ```solidity @@ -490,111 +653,129 @@ error EscrowAddressReserved(); ### 5.1 Transfer-like Paths -For a transfer-like path: +For `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, and `systemTransferFrom`: -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()`; +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants; - compute `effectiveReceiver`: - - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - - otherwise `to` -- set `requestedRecipient = to` -- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 and TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive -- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `blockedReason` -- if the inbound is authorized: - - follow the normal transfer path using `effectiveReceiver` - - if `to` is virtual, use TIP-1022 forwarding event semantics - - update rewards exactly as on a normal transfer - - debit `from` - - credit `effectiveReceiver` - - return success -- if the inbound is blocked by the receiver: - - read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` - - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - - debit `from` - - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)` - - emit `Transfer(from, ESCROW_ADDRESS, amount)` - - emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - - return success + - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address (revert if resolution fails), + - otherwise `to`; +- set `requestedRecipient = to`; +- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 / TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive (failure here MUST revert); +- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `(authorized, blockedReason)`; + +If the inbound is authorized: + +- follow the normal transfer path using `effectiveReceiver`; +- if `to` is virtual, use TIP-1022 forwarding event semantics; +- update rewards exactly as on a normal transfer; +- debit `from`, credit `effectiveReceiver`, return success. + +If the inbound is blocked by the receiver: + +- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract`; +- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS`; +- debit `from`, credit `ESCROW_ADDRESS`; +- call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)`; +- emit `Transfer(from, ESCROW_ADDRESS, amount)`; +- emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)`; +- return success. Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. ### 5.2 Mint-like Paths -For a mint-like path: +For `mint` and `mintWithMemo`: -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants -- compute `effectiveReceiver`: - - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address - - otherwise `to` -- set `requestedRecipient = to` -- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive -- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `blockedReason`, where `originator` is the mint caller -- if the inbound is authorized: - - follow the normal mint path using `effectiveReceiver` - - if `to` is virtual, use TIP-1022 forwarding event semantics - - update rewards exactly as on a normal mint - - increase total supply - - credit `effectiveReceiver` - - return success -- if the inbound is blocked by the receiver: - - read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` - - update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` - - increase total supply - - credit `ESCROW_ADDRESS` - - call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)` - - emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - - emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - - return success +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()`; +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants; +- compute `effectiveReceiver` as in [Section 5.1](#51-transfer-like-paths); +- set `requestedRecipient = to`; +- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive; +- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `(authorized, blockedReason)`, where `originator` is the mint caller. -`mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. +If the inbound is authorized: -### 5.3 Reward Delegation and Claims +- follow the normal mint path using `effectiveReceiver`; +- if `to` is virtual, use TIP-1022 forwarding event semantics; +- update rewards exactly as on a normal mint; +- increase total supply, credit `effectiveReceiver`, return success. -Reward flows are never escrowed, but they MUST respect recipient consent. For `setRewardRecipient(holder, recipient)`, `recipient == address(0)` remains the opt-out path and bypasses receive-policy checks; otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` and revert if unauthorized. For `claimRewards(...)`, the token MUST determine the actual payout recipient under its existing reward rules, revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` before crediting rewards, and revert rather than escrow if unauthorized. +If the inbound is blocked by the receiver: -### 5.4 Reward and Event Semantics +- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract`; +- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS`; +- increase total supply, credit `ESCROW_ADDRESS`; +- call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)`; +- emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)`; +- emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)`; +- return success. -Blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. +`mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. + +### 5.3 Raw Event Truthfulness Blocked inbounds MUST use truthful raw TIP-20 events: - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` +- claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -In addition, every blocked inbound MUST emit exactly one attribution event: +In addition, every blocked inbound MUST emit exactly one attribution event (`TransferBlocked` or `MintBlocked`) whose `blockedReason != NONE`. For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the receipt: - `TransferBlocked(token, from, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - `MintBlocked(token, operator, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -`blockedReason` MUST distinguish whether the inbound was blocked by the receiver's token set, the receiver's receive policy, or both. It MUST NOT be `NONE` in a blocked event. +## 6. Adjacent Subsystems -For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the receipt. +The general rule is: every TIP-20 outbound onto the canonical balance ledger goes through the inbound dispatch in [Section 5](#5-tip-20-inbound-path-changes) and is therefore subject to receiver-side checks. This section calls out each adjacent subsystem and its specific handling. -### 5.5 Tempo-Specific Protocol Interactions +### 6.1 DEX, FeeManager, and TIPFeeAMM Wallet Payouts -- **Stablecoin DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. +- **DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. - **DEX wallet payouts remain ordinary TIP-20 transfers.** DEX `withdraw` calls and swap outputs that transfer from the DEX address to a wallet remain subject to address-level receive controls and may therefore be escrowed. - **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee distributions, AMM burns, and TIPFeeAMM outputs such as `rebalanceSwap` payouts remain subject to address-level receive controls and may therefore be escrowed. -- **These protocol entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect `TransferBlocked` or `MintBlocked` or wrap these calls with additional logic. -- **Fee refunds are exempt.** `transfer_fee_post_tx` is a refund of the current transaction's unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive controls and MUST NOT be escrowed. -- **Blocked memo-bearing inbounds keep their raw memo event.** The raw memo-bearing event still names `ESCROW_ADDRESS`; receivers that care about memo-based routing MUST correlate it with the blocked-receipt event in the same transaction. -- **`ESCROW_ADDRESS` is a protected system address.** Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. +- **Higher-level entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect `TransferBlocked` / `MintBlocked` or wrap these calls with additional logic. This applies to higher-level Tempo precompile events too; they are not rewritten to distinguish direct credit from escrow. + +### 6.2 Fee Refunds + +`transfer_fee_post_tx` is a refund of the current transaction's unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive controls and MUST NOT be escrowed. + +### 6.3 Reward Subsystem -### 5.6 Integration Consequence +Reward flows are **never escrowed**, but they are **not exempt from recipient consent**. -After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager or TIPFeeAMM wallet payouts may succeed either because the intended receiver was credited or because the inbound was escrowed. Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` or `MintBlocked` or use wrapper logic. This includes higher-level Tempo precompile events, which are not rewritten to distinguish direct credit from escrow. +- `distributeReward` remains token-internal accounting. +- `setRewardRecipient(holder, recipient)`: + - `recipient == address(0)` is the opt-out path and bypasses receive-policy checks. + - Otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` and revert if unauthorized. +- `claimRewards(...)`: + - The token determines the actual payout recipient under its existing reward rules. + - It MUST revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` and revert (rather than escrow) if unauthorized. -## 6. Gas and Storage Analysis +For reward-state purposes, blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. Implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. + +### 6.4 Memo-Bearing Paths + +Blocked memo-bearing inbounds keep their raw memo event. The raw memo-bearing event still names `ESCROW_ADDRESS`; receivers that care about memo-based routing MUST correlate it with the blocked-receipt event in the same transaction. + +### 6.5 Out-of-Scope Surfaces + +`approve`, `permit`, `burn`, non-TIP-20 tokens deployed as ordinary contracts, and future recipient-bearing system-credit paths that do not identify a concrete originator are unchanged by this TIP. + +### 6.6 Integration Consequence + +After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager / TIPFeeAMM wallet payouts may succeed either because the intended receiver was credited or because the inbound was escrowed. Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` / `MintBlocked` events or use wrapper logic. + +## 7. Gas and Storage Analysis This section uses the gas model from TIP-1016: -- fresh storage slot: **250,000 gas** +- fresh storage slot: **~250,000 gas** - existing nonzero slot update: **~2,900 gas** - typical TIP-20 transfer to an existing address: **~50,000 gas** -### 6.1 Main Cases +### 7.1 Main Cases | Case | Rough cost | Notes | |------|------------|-------| @@ -606,34 +787,44 @@ This section uses the gas model from TIP-1016: | blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | | claim one receipt | one transfer from escrow + one receipt-slot delete + auth reads | batching belongs above the protocol layer | -### 6.2 Storage Choices +### 7.2 Storage Choices + +The design intentionally stores **one fine-grained receipt bucket per blocked inbound**. That is more expensive than an aggregate bucket, but it preserves: + +- the literal `requestedRecipient` needed for TIP-1022 attribution, +- the original `originator`, +- the block timestamp `blockedAt`, +- the distinction between transfer-blocked and mint-blocked funds, +- memo and block-reason data for programmable recovery rules. -The design intentionally stores one fine-grained receipt bucket per blocked inbound. That is more expensive than an aggregate bucket, but it preserves the literal `requestedRecipient` needed for TIP-1022 attribution, the original `originator`, the block timestamp `blockedAt`, the distinction between transfer-blocked and mint-blocked funds, and memo and block-reason data for programmable recovery rules. The persistent state is still just one keyed amount per blocked inbound, while the richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. +The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. -### 6.3 Escrow Slot Strategy +### 7.3 Escrow Slot Strategy The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly `250,000` gas to the first blocked live transfer. -TIP-20 implementations SHOULD move that cost to deployment by initializing the `balances[ESCROW_ADDRESS]` slot when the token is created, before any blocked inbound occurs. One acceptable pattern is to do this with a non-user-claimable implementation-private escrow reserve. Implementations MAY use any equivalent deployment-time mechanism instead. +TIP-20 implementations SHOULD move that cost to deployment by initializing the `balances[ESCROW_ADDRESS]` slot when the token is created, before any blocked inbound occurs. One acceptable pattern is to do this with a non-user-claimable implementation-private escrow reserve; implementations MAY use any equivalent deployment-time mechanism instead. If an implementation uses such a reserve: -- it MUST be created at token deployment time -- it MUST exist only to initialize and keep live the `balances[ESCROW_ADDRESS]` slot -- it MUST NOT correspond to any blocked receipt -- it MUST NOT be claimable by users or recovery contracts -- release and burn logic MUST preserve it as implementation-private state +- it MUST be created at token deployment time; +- it MUST exist only to initialize and keep live the `balances[ESCROW_ADDRESS]` slot; +- it MUST NOT correspond to any blocked receipt; +- it MUST NOT be claimable by users or recovery contracts; +- release and burn logic MUST preserve it as implementation-private state. -## 7. Security and Integration Considerations +## 8. Security and Integration Considerations -- **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. -- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer`, `transferFrom`, or mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-receipt events and claim from escrow SHOULD NOT opt in. -- **Claim-to-receiver is an unwind, while reroutes are new transfers.** Claim-to-receiver bypasses the receiver's token-level and address-level receive checks. A reroute to `to != receiver` must satisfy token-level recipient authorization for `to`, that destination's address-level receive controls, and, for direct receiver reroutes through an access key, ordinary AccountKeychain spending-limit metering. -- **Recovery-contract authority is explicit and not retroactive.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock policy, key-spend policy, or batching policy for that path becomes a userland concern of the recovery contract. Older receipts remain keyed to the recovery contract captured when they were blocked, so rotation can strand funds if the old contract later becomes unusable. +- **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. This is the most important behavioral change for integrators. +- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer` / `transferFrom` / mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-receipt events and claim from escrow SHOULD NOT opt in. +- **Claim-to-receiver is an unwind; reroutes are new transfers.** Claim-to-receiver bypasses the receiver's token-level and address-level receive checks. Reroutes to `to != receiver` must satisfy token-level recipient authorization for `to`, that destination's address-level receive controls, and (for direct receiver reroutes through an access key) ordinary AccountKeychain spending-limit metering. +- **Recovery-contract authority is explicit and not retroactive.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock, key-spend policy, or batching is a userland concern of the recovery contract. Older receipts remain keyed to the recovery contract captured when they were blocked, so rotation can strand funds if the old contract later becomes unusable. - **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. - **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. +- **Receipt witnesses are public.** Blocked-inbound events emit the full witness; anyone can construct a `ClaimReceipt`. The witness selects a receipt but does not authorize a claim — only `receiver` (when `recoveryContract == 0`) or `recoveryContract` may call `claimBlockedReceipt(...)`. +- **`recordBlockedInbound(...)` is privileged.** Only TIP-20 precompiles or protocol-internal system code may call it. Otherwise an attacker could mint synthetic receipts by replaying or fabricating witnesses without backing escrow balance. -## 8. Invariants +## 9. Invariants The following invariants MUST always hold: @@ -689,7 +880,7 @@ The following invariants MUST always hold: ## Appendix A. Solidity Reference Recovery Contract (Non-Normative) -The following contract is illustrative only. It is not part of the protocol, and receivers may use any recovery contract or smart wallet that obeys the rules in Sections 4.3 and 4.4. +The following contract is illustrative only. It is not part of the protocol, and receivers may use any recovery contract or smart wallet that obeys the rules in [Section 4.4](#44-claim-authorization) and [Section 4.5](#45-release-semantics). ```solidity pragma solidity ^0.8.24; From d6e04f03e8c395e75ac471de33c6726a29aafeb3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 30 Apr 2026 20:47:27 +0200 Subject: [PATCH 16/59] feat: sequence diagrams --- tips/tip-1028.md | 122 +++++++++++++++++++++++------------------------ 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index fd4a1d2024..911ce4ffd5 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -63,71 +63,71 @@ The escrow precompile and the TIP-403 registry are independent: the escrow preco ### 1.2 End-to-End Flow Overview -The TIP-20 inbound path is the single integration point. Pseudocode: - -```text -on TIP-20 inbound (transfer-like or mint-like) with literal `to`: - if to == ESCROW_ADDRESS: - revert EscrowAddressReserved - if to is a TIP-1022 virtual address: - effectiveReceiver = resolveRecipient(to) # may revert - else: - effectiveReceiver = to - requestedRecipient = to - memo = supplied memo, or bytes32(0) for non-memo variants - - # Issuer-side / token-level checks unchanged. - run TIP-20 pause / balance / allowance / TIP-403 / TIP-1015 checks - (using effectiveReceiver wherever recipient-sensitive) - # ^ failure here MUST revert. - - # Receiver-side check (new in TIP-1028). - (authorized, blockedReason) = - TIP403.verifyAddressInbound(token, originator, effectiveReceiver) - - if authorized: - # Existing happy path, with effectiveReceiver as the credited holder. - update rewards, debit/mint, credit effectiveReceiver, - emit ordinary TIP-20 / TIP-1022 events, return success. - - else: - recoveryContract = TIP403.addressRecoveryContract(effectiveReceiver) - update rewards as if recipient were ESCROW_ADDRESS - (reward-exempt, always-opted-out) - debit / mint amount, credit ESCROW_ADDRESS - (blockedNonce, blockedAt) = Escrow.recordBlockedInbound( - token, originator, effectiveReceiver, requestedRecipient, - recoveryContract, amount, blockedReason, kind, memo - ) - emit truthful raw TIP-20 events naming ESCROW_ADDRESS - emit TransferBlocked(...) or MintBlocked(...) - return success. +The TIP-20 inbound path is the single integration point. + +```mermaid +sequenceDiagram + autonumber + participant Caller + participant Token as TIP-20 Token + participant TIP403 as TIP-403 Registry + participant Escrow + participant Recipient + + Caller->>Token: transfer(to, amount) + + Note over Token: TIP-20 checks:
pause, recipient, amount
(ok, else revert) + + Token->>TIP403: TIP-403 + TIP-1015 token-level policies + TIP403-->>Token: ok, else revert + + Token->>TIP403: TIP-403 (inbound) address-level policies + TIP403-->>Token: (authorized, blockedReason) + + alt authorized (NONE) + Note over Token: TIP20 transfer:
debits sender,
credits recipient,
accrues rewards + Token->>Recipient: emit Transfer(caller, recipient, amount) + Token-->>Caller: success + else blocked (TOKEN_SET / RECEIVE_POLICY / both) + Note over Token: TIP20 transfer:
debits sender,
credits ESCROW,
reward-exempt + Token->>Escrow: emit Transfer(caller, ESCROW, amount) + Note over Escrow: store blocked-receipt
digest(receiver,
requestedRecipient,
originator,
recoveryContract,
reason, kind, memo
blockedAt, blockedNonce) + Escrow-->>Token: (blockedNonce, blockedAt) + Token-->>Caller: success
(funds escrowed) + end ``` Claim path: -```text -on Escrow.claimBlockedReceipt(token, receiver, recoveryContract, receipt, to): - require msg.sender == receiver if recoveryContract == 0 - else msg.sender == recoveryContract - receiptKey = keccak256(witness fields) - require blockedReceiptAmount[receiptKey] > 0 - amount = blockedReceiptAmount[receiptKey]; delete it - - if to == receiver: - # Unwind: bypass receiver-side controls (token-level recipient + address-level + key metering). - TIP20.escrowReleaseUnwind(token, receiver, amount) - else: - # Reroute: new spend. - require to != ESCROW_ADDRESS - require to is not a TIP-1022 virtual address - require token-level recipient authorization for `to` - require TIP403.verifyAddressInbound(token, receipt.originator, to).authorized - if recoveryContract == 0 and call entered via access key: - meter amount against receiver's AccountKeychain spending limit - TIP20.escrowReleaseTransfer(token, to, amount) - - emit BlockedReceiptClaimed(...) +```mermaid +sequenceDiagram + autonumber + participant Claimer + participant Escrow + participant TIP403 as TIP-403 Registry + participant Token as TIP-20 Token + participant Recipient + + Claimer->>Escrow: claimBlockedReceipt(token, receiver,
recoveryContract, receipt, to) + + Note over Escrow: caller check:
msg.sender == receiver if recoveryContract == 0,
else msg.sender == recoveryContract
(ok, else revert) + + Note over Escrow: receiptKey = keccak256(witness)
load and delete blockedReceiptAmount[receiptKey]
(ok, else revert) + + alt recipient == receiver (unwind) + Note over Token: escrow release:
debits ESCROW,
credits recipient,
reward-exempt + Token->>Recipient: emit Transfer(ESCROW, recipient, amount) + else recipient != receiver (reroute) + Escrow->>TIP403: TIP-403 + TIP-1015
token-level policies
for recipient + TIP403-->>Escrow: ok, else revert + Escrow->>TIP403: TIP-403 (inbound)
address-level policies
for receipt.originator + TIP403-->>Escrow: ok, else revert + Note over Escrow: if recoveryContract == 0 and via access key:
meter amount against receiver's spending limit + Note over Token: escrow release:
debits ESCROW,
credits recipient,
reward-exempt + Token->>Recipient: emit Transfer(ESCROW, recipient, amount) + end + + Escrow-->>Claimer: success
(emit BlockedReceiptClaimed) ``` ### 1.3 Scope From 2efb033a521abe7ba8229e588bf7978777d7b5c5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 30 Apr 2026 20:55:00 +0200 Subject: [PATCH 17/59] style --- tips/tip-1028.md | 110 +++++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 911ce4ffd5..d4c2dadb86 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -289,13 +289,11 @@ If an address has no configured receive controls, address-level authorization de ### 3.1 Constraints -- `receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. +`receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. - Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. The two are semantically incompatible. +`recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for **future** blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. -- `recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for **future** blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. - -- TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. +TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. ### 3.2 Blocked Reasons @@ -447,16 +445,16 @@ receiptKey = keccak256( where: - `receiptVersion` — one-byte bucketing tag; MUST be `1` for receipts created under this TIP. Future bucketing or receipt-key formats MUST use a different value. -- `token` — the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS`. -- `receiver` — the canonical TIP-20 holder that owns the receipt (resolved master address for TIP-1022 inbounds). -- `originator` — `from` for transfers, mint caller for mints. -- `requestedRecipient` — the literal `to` supplied at the TIP-20 entrypoint; preserves TIP-1022 attribution. For non-virtual inbounds, `requestedRecipient == receiver`. -- `recoveryContract` — the receiver's snapshotted recovery contract, or `address(0)`. -- `blockedReason` — receiver's token set, receive policy, or both. -- `kind` — `TRANSFER` or `MINT` (see [Section 4.3](#43-interface)). -- `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. +- `token` — the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS` +- `receiver` — the canonical TIP-20 holder that owns the receipt (resolved master address for TIP-1022 inbounds) +- `originator` — `from` for transfers, mint caller for mints +- `requestedRecipient` — the literal `to` supplied at the TIP-20 entrypoint; preserves TIP-1022 attribution. For non-virtual inbounds, `requestedRecipient == receiver` +- `recoveryContract` — the receiver's snapshotted recovery contract, or `address(0)` +- `blockedReason` — receiver's token set, receive policy, or both +- `kind` — `TRANSFER` or `MINT` (see [Section 4.3](#43-interface)) +- `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. @@ -518,7 +516,7 @@ interface IBlockedInboundEscrow { } ``` -`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. Userland callers MUST NOT be able to fabricate receipts. +`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. User callers MUST NOT be able to fabricate receipts. ### 4.4 Claim Authorization @@ -563,12 +561,12 @@ This is intentional for TIP-1015 compound policies. A blocked mint, for example, If `to != receiver`, the claim is a **reroute**, treated as a new spend by the receiver. It MUST: -- reject `to == ESCROW_ADDRESS`, -- reject TIP-1022 virtual addresses as `to`, -- enforce token-level transfer-recipient authorization for `to`, -- enforce `to`'s address-level receive controls against the consumed receipt's `originator`, -- revert with `ClaimDestinationUnauthorized()` if the destination's checks fail, -- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver. +- reject `to == ESCROW_ADDRESS` +- reject TIP-1022 virtual addresses as `to` +- enforce token-level transfer-recipient authorization for `to` +- enforce `to`'s address-level receive controls against the consumed receipt's `originator` +- revert with `ClaimDestinationUnauthorized()` if the destination's checks fail +- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key-policy enforcement is a userland concern of that contract. @@ -655,31 +653,31 @@ error EscrowAddressReserved(); For `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, and `systemTransferFrom`: -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()`; -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants; +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants - compute `effectiveReceiver`: - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address (revert if resolution fails), - - otherwise `to`; -- set `requestedRecipient = to`; -- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 / TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive (failure here MUST revert); -- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `(authorized, blockedReason)`; + - otherwise `to` +- set `requestedRecipient = to` +- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 / TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive (failure here MUST revert) +- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `(authorized, blockedReason)` If the inbound is authorized: -- follow the normal transfer path using `effectiveReceiver`; -- if `to` is virtual, use TIP-1022 forwarding event semantics; -- update rewards exactly as on a normal transfer; -- debit `from`, credit `effectiveReceiver`, return success. +- follow the normal transfer path using `effectiveReceiver` +- if `to` is virtual, use TIP-1022 forwarding event semantics +- update rewards exactly as on a normal transfer +- debit `from`, credit `effectiveReceiver`, return success If the inbound is blocked by the receiver: -- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract`; -- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS`; -- debit `from`, credit `ESCROW_ADDRESS`; -- call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)`; -- emit `Transfer(from, ESCROW_ADDRESS, amount)`; -- emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)`; -- return success. +- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` +- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` +- debit `from`, credit `ESCROW_ADDRESS` +- call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)` +- emit `Transfer(from, ESCROW_ADDRESS, amount)` +- emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- return success Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. @@ -687,29 +685,29 @@ Memo-bearing transfer variants MUST preserve the original memo in the blocked ev For `mint` and `mintWithMemo`: -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()`; -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants; -- compute `effectiveReceiver` as in [Section 5.1](#51-transfer-like-paths); -- set `requestedRecipient = to`; -- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive; -- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `(authorized, blockedReason)`, where `originator` is the mint caller. +- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` +- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants +- compute `effectiveReceiver` as in [Section 5.1](#51-transfer-like-paths) +- set `requestedRecipient = to` +- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive +- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `(authorized, blockedReason)`, where `originator` is the mint caller If the inbound is authorized: -- follow the normal mint path using `effectiveReceiver`; -- if `to` is virtual, use TIP-1022 forwarding event semantics; -- update rewards exactly as on a normal mint; -- increase total supply, credit `effectiveReceiver`, return success. +- follow the normal mint path using `effectiveReceiver` +- if `to` is virtual, use TIP-1022 forwarding event semantics +- update rewards exactly as on a normal mint +- increase total supply, credit `effectiveReceiver`, return success If the inbound is blocked by the receiver: -- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract`; -- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS`; -- increase total supply, credit `ESCROW_ADDRESS`; -- call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)`; -- emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)`; -- emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)`; -- return success. +- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` +- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` +- increase total supply, credit `ESCROW_ADDRESS` +- call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)` +- emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` +- emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` +- return success `mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. From 029b4c0249431086be02a26ca397dc5310183f20 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 30 Apr 2026 20:20:54 -0400 Subject: [PATCH 18/59] docs: cleanup headings, remove unnecessary sections --- tips/tip-1028.md | 83 ++++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index d4c2dadb86..af8b254478 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -12,58 +12,54 @@ protocolVersion: TBD ## Abstract -This TIP introduces receiver-controlled inbound policies for TIP-20 tokens. Today, only token issuers (via TIP-403 / TIP-1015) decide who may move a token; receivers cannot independently filter what arrives in their balance. This TIP adds: +TIP-1028 extends TIP-403 with address-level receive policies and token sets, allowing a receiver to control which originators and which TIP-20 tokens may credit it. When a receive policy blocks an inbound transfer or mint, the operation still succeeds at the protocol level, but the funds are credited to `ESCROW_ADDRESS` instead of the receiver and a corresponding escrow receipt is recorded. -1. **Token sets** — a new TIP-403 primitive that lets any address declare which TIP-20 token addresses may credit it. -2. **Address-level receive policies** — a per-address configuration on the TIP-403 registry that filters inbound TIP-20 credits by originator and by token, with an optional dedicated recovery contract. -3. **An escrow precompile** — a new precompile that records a fine-grained receipt for each blocked inbound TIP-20 transfer, and lets the receiver (or its designated recovery contract) claim or reroute the funds later. +The receiver or a designated recovery contract may later claim these escrowed funds. Claims back to the receiver act as an unwind of the original inbound, while claims to other addresses are treated as new transfers. -A blocked inbound never reverts: the funds are credited to a reserved `ESCROW_ADDRESS` inside the TIP-20 token, one receipt bucket is recorded by the escrow precompile, and a blocked-inbound event is emitted that authenticates the receipt witness. Pre-existing token-level (issuer-side) failures continue to revert exactly as today. - -This TIP applies only to TIP-20 precompile flows; ordinary ERC-20 contracts deployed as userland code remain out of scope. +Token-level authorization remains unchanged and continues to revert on failure. This behavior applies only to TIP-20 precompile flows; ordinary contracts and other precompiles are unaffected. ## Motivation -TIP-403 is an issuer-side authorization layer: the token issuer decides who may send, who may receive, and who may mint to whom. It cannot answer the receiver's question, *"do I want to be exposed to this counterparty or this token at all?"* - -Plain receiver-side reverts are not a viable substitute. Once an issuer–receiver–counterparty relationship exists, a receiver that later changes a revert-based policy can break in-flight flows and recurring inbounds across the entire system. Reverting receivers also create an asymmetric DoS surface against issuers, integrations, and protocol-owned distribution paths (DEX payouts, fee distributions, AMM outputs). - -The design choice in TIP-1028 is therefore: +TIP-403 allows token issuers to control who may use a token, but it does not give receivers control over which inbounds they accept. Some applications require receivers to restrict incoming transfers or mints based on the originator or token. -- **Token-level / issuer-side failures keep reverting.** Issuers retain full control of the token's authorization model; nothing in their semantics changes. -- **Receiver-side failures escrow instead of reverting.** Senders, mints, DEX payouts, and fee distributions still succeed at the protocol layer; the asset is held in `ESCROW_ADDRESS` until the receiver (or its recovery contract) claims it. +A revert-based receiver policy introduces a liveness problem: once a sender relationship exists, a receiver can later change its policy and cause future transfers or mints to revert, breaking integrations and making delivery unreliable. -Escrowed funds must carry enough metadata to be programmatically recoverable. An aggregate "amount blocked per (token, receiver)" bucket cannot express TIP-1022 attribution, memo routing, originator-based recovery rules, or transfer-vs-mint provenance. Therefore, the escrow precompile stores one keyed amount per blocked inbound, with ALL the receipt identity (version, requested recipient, block reason, kind, memo, timestamp, nonce) authenticated by the receipt witness and emitted in the blocked event — keeping persistent state to a single slot per receipt while preserving full attribution offchain. +TIP-1028 introduces receiver-side controls while preserving liveness. Instead of reverting when a receiver blocks an inbound, the transfer or mint succeeds at the protocol level and the funds are escrowed. -## Assumptions - -- **TIP-403 registry exists and is extensible.** Token sets and address-level receive policies are added as new state and new entrypoints on the existing TIP-403 precompile registry, alongside its current address-policy surface. This TIP does not modify the existing TIP-403 address-policy ABI. -- **TIP-1015 compound policies are unchanged.** Receiver-side address-level controls evaluate exactly one axis (originator → receiver), and never cross-validate against the issuer's `transfer_recipient` / `mint_recipient` roles. -- **TIP-1022 resolution is available at TIP-20 inbound time.** Virtual-address resolution runs before TIP-1028 receiver-side checks; failed resolution still reverts. -- **TIP-1016 gas model.** Cost analysis uses the storage-cost model from TIP-1016 (fresh slot ≈ 250k gas, hot slot update ≈ 2.9k gas, baseline TIP-20 transfer ≈ 50k gas). -- **`ESCROW_ADDRESS` is reserved at protocol level.** No TIP-20 implementation, system contract, or user may treat `ESCROW_ADDRESS` as an ordinary holder. Userland transfers and mints to it MUST revert. -- **Reward state never lives at `ESCROW_ADDRESS`.** Implementations rely on treating the escrow sink as a reward-exempt always-opted-out address; violating this assumption silently corrupts the opted-in supply for reward distribution. -- **Backwards compatibility.** Addresses with no configured receive controls behave exactly as today. The only new ambient cost on existing flows is one cold storage read of the receiver's packed receive config slot. +Blocked inbounds remain recoverable and attributable, allowing receivers (or their recovery contracts) to claim funds while preserving context needed for offchain handling. # Specification ## 1. System Architecture -This TIP is intentionally additive: it does not alter TIP-403's existing address-policy ABI, it does not change issuer-side TIP-20 semantics, and it does not introduce new surfaces in TIP-20 callers' interfaces. The new functionality is split across three protocol components, summarized below. +This TIP is additive and does not alter TIP-403's existing address policy ABI, change issuer-side TIP-20 semantics, or introduce new surfaces in TIP-20 callers' interfaces. The new functionality is split across three protocol components, summarized below. + +This TIP introduces three concrete changes: -### 1.1 Component Map +1. **TIP-403 is extended** with new state and entrypoints: + - Token sets for filtering inbound tokens + - Per-address receive configuration (`receivePolicyId`, `tokenSetId`, `recoveryContract`) + - A new view `verifyAddressInbound(...)` used during TIP-20 flows -| Component | Status | Owns | -|-----------|--------|------| -| **TIP-403 registry** (existing) | extended | Token sets (new primitive), address-level receive config (new mapping), recovery-contract mapping (new mapping), the new `verifyAddressInbound(...)` view | -| **Escrow Precompile** (new, address `ESCROW_ADDRESS`) | introduced by this TIP | Per-receipt amount mapping keyed by a witness-derived hash, `recordBlockedInbound(...)` ingest entrypoint, `claimBlockedReceipt(...)` claim entrypoint, blocked / claim attribution events; also acts as the on-token holder of all blocked balances — `balances[ESCROW_ADDRESS]` inside each TIP-20 token is held by this precompile | -| **TIP-20 token precompile** (existing) | extended | Inbound dispatch: TIP-1022 resolution → issuer-side checks → call `verifyAddressInbound(...)` → either credit `effectiveReceiver` or credit `ESCROW_ADDRESS` and call `recordBlockedInbound(...)`; new escrow-release internal path for `claimBlockedReceipt`; raw-event truthfulness for blocked credits; `setRewardRecipient` / `claimRewards` recipient revalidation | +2. **A new escrow precompile is introduced**: + - Holds blocked funds at `ESCROW_ADDRESS` + - Records one receipt per blocked inbound + - Exposes `recordBlockedInbound(...)` and `claimBlockedReceipt(...)` -The escrow precompile and the TIP-403 registry are independent: the escrow precompile authenticates claims using the receipt witness and the receipt's snapshotted recovery contract, and does **not** read the receiver's current TIP-403 receive config when claiming. The TIP-403 registry, conversely, has no knowledge of escrowed amounts: it only answers `verifyAddressInbound(...)` and exposes the current `recoveryContract` of an address. +3. **TIP-20 transfer and mint flows add a receiver-side check**: + - Resolve TIP-1022 addresses + - Run existing issuer-side checks + - Call `verifyAddressInbound(...)` + - If authorized: credit the receiver + - If blocked: credit `ESCROW_ADDRESS` and record a receipt -### 1.2 End-to-End Flow Overview +With these components in place, TIP-20 transfers and mints execute as follows: -The TIP-20 inbound path is the single integration point. +1. Resolve the recipient (including TIP-1022 virtual address resolution if applicable). +2. Run issuer-side checks (TIP-403 / TIP-1015). +3. Call `verifyAddressInbound(...)`. +4. If authorized, credit the receiver. +5. If blocked, credit `ESCROW_ADDRESS` and record a receipt. ```mermaid sequenceDiagram @@ -97,7 +93,13 @@ sequenceDiagram end ``` -Claim path: +Escrowed funds are released through `claimBlockedReceipt(...)` with the following flow: + +1. Validate the caller against the receipt's `receiver` or snapshotted `recoveryContract`. +2. Recompute the receipt key from the supplied witness. +3. Load and consume the blocked receipt amount. +4. If releasing to the original receiver, credit the receiver directly. +5. If rerouting to another address, enforce destination checks before release. ```mermaid sequenceDiagram @@ -130,9 +132,9 @@ sequenceDiagram Escrow-->>Claimer: success
(emit BlockedReceiptClaimed) ``` -### 1.3 Scope +### 1.1 TIP-20 Affected Paths -The address-level inbound authorization layer applies to the following TIP-20 recipient-bearing paths: +This TIP adds receiver-side checks to the following TIP-20 operations: - `transfer` - `transferFrom` @@ -156,14 +158,11 @@ TIP-1028 does **NOT** alter: - `approve` / `permit` / `burn` - fee refunds via `transfer_fee_post_tx` -- non-TIP-20 tokens deployed as ordinary contracts -- future recipient-bearing system-credit paths that do not identify a concrete originator -- DEX *internal* balances. Gating only kicks in when funds are withdrawn back onto the TIP-20 ledger - the reward subsystem's escrow-ability. Rewards are never escrowed; see [Section 6.3](#63-reward-subsystem) -### 1.4 TIP-1022 Interaction +### 1.2 TIP-1022 Interaction -If the literal `to` is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur **before** TIP-1028 receiver-side authorization. Specifically: +If the `to` address is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur **before** TIP-1028 receiver-side authorization. Specifically: - TIP-1028 authorization applies to the resolved master address, never to the literal virtual address. - If virtual-address resolution itself fails, the operation MUST revert rather than escrow. From 684f4fb5744e4a233dd3eb66eea21f515c5b37ea Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 30 Apr 2026 22:02:52 -0400 Subject: [PATCH 19/59] docs: abstract --- tips/tip-1028.md | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index af8b254478..6a87901d3e 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -12,11 +12,13 @@ protocolVersion: TBD ## Abstract -TIP-1028 extends TIP-403 with address-level receive policies and token sets, allowing a receiver to control which originators and which TIP-20 tokens may credit it. When a receive policy blocks an inbound transfer or mint, the operation still succeeds at the protocol level, but the funds are credited to `ESCROW_ADDRESS` instead of the receiver and a corresponding escrow receipt is recorded. +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. -The receiver or a designated recovery contract may later claim these escrowed funds. Claims back to the receiver act as an unwind of the original inbound, while claims to other addresses are treated as new transfers. +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. -Token-level authorization remains unchanged and continues to revert on failure. This behavior applies only to TIP-20 precompile flows; ordinary contracts and other precompiles are unaffected. +The receiver or a designated recovery contract can later claim these funds. Claims back to the receiver unwind the original inbound 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 @@ -30,23 +32,23 @@ Blocked inbounds remain recoverable and attributable, allowing receivers (or the # Specification -## 1. System Architecture - -This TIP is additive and does not alter TIP-403's existing address policy ABI, change issuer-side TIP-20 semantics, or introduce new surfaces in TIP-20 callers' interfaces. The new functionality is split across three protocol components, summarized below. +TIP-1028 introduces three main changes: -This TIP introduces three concrete changes: +// NOTE: this is not clear, should be clean concise, 1. **TIP-403 is extended** with new state and entrypoints: - Token sets for filtering inbound tokens - Per-address receive configuration (`receivePolicyId`, `tokenSetId`, `recoveryContract`) - A new view `verifyAddressInbound(...)` used during TIP-20 flows +// NOTE: also bad 2. **A new escrow precompile is introduced**: - - Holds blocked funds at `ESCROW_ADDRESS` - - Records one receipt per blocked inbound - - Exposes `recordBlockedInbound(...)` and `claimBlockedReceipt(...)` -3. **TIP-20 transfer and mint flows add a receiver-side check**: +- Holds blocked funds at `ESCROW_ADDRESS` +- Records one receipt per blocked inbound +- Exposes `recordBlockedInbound(...)` and `claimBlockedReceipt(...)` + +1. **TIP-20 transfer and mint flows add a receiver-side check**: - Resolve TIP-1022 addresses - Run existing issuer-side checks - Call `verifyAddressInbound(...)` @@ -826,6 +828,7 @@ If an implementation uses such a reserve: The following invariants MUST always hold: 1. For every TIP-20 token: + ```text balances[ESCROW_ADDRESS] = implementation_private_escrow_reserve[token] @@ -859,21 +862,22 @@ The following invariants MUST always hold: 14. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked receipts. Existing receipt buckets remain governed by the recovery contract captured in their key. 15. Escrow-related raw TIP-20 events MUST be truthful: - - blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` - - blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` - - claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` -16. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's version, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. +- blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` +- blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` +- claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` + +1. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's version, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. -17. Claims MUST consume whole blocked receipts. Once a receipt is claimed, its keyed amount MUST be deleted rather than partially decremented. +2. Claims MUST consume whole blocked receipts. Once a receipt is claimed, its keyed amount MUST be deleted rather than partially decremented. -18. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +3. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. -19. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. +4. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. -20. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. +5. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. -21. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. +6. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. ## Appendix A. Solidity Reference Recovery Contract (Non-Normative) From d15660e7059462ea595c5fd8219a7e1f7e8d6206 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 30 Apr 2026 22:11:23 -0400 Subject: [PATCH 20/59] docs: motivation --- tips/tip-1028.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 6a87901d3e..a2e936a5a3 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -22,13 +22,13 @@ TIP-403 authorization checks are unchanged and continue to revert on failure. TI ## Motivation -TIP-403 allows token issuers to control who may use a token, but it does not give receivers control over which inbounds they accept. Some applications require receivers to restrict incoming transfers or mints based on the originator or token. +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. Some applications require receivers to restrict incoming funds based on the sender or the token. -A revert-based receiver policy introduces a liveness problem: once a sender relationship exists, a receiver can later change its policy and cause future transfers or mints to revert, breaking integrations and making delivery unreliable. +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 introduces receiver-side controls while preserving liveness. Instead of reverting when a receiver blocks an inbound, the transfer or mint succeeds at the protocol level and the funds are escrowed. +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. -Blocked inbounds remain recoverable and attributable, allowing receivers (or their recovery contracts) to claim funds while preserving context needed for offchain handling. +The receiver (or a recovery contract) can claim those funds later. Each escrow entry keeps enough information to identify the original transfer, so it can be handled correctly offchain. # Specification From bdfdc10358e61d44dcd3a221bdb7ae8615128b15 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 30 Apr 2026 23:33:57 -0400 Subject: [PATCH 21/59] docs: tip20 operations --- tips/tip-1028.md | 143 ++++++++++------------------------------------- 1 file changed, 30 insertions(+), 113 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index a2e936a5a3..ce61d6ab99 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -34,133 +34,43 @@ The receiver (or a recovery contract) can claim those funds later. Each escrow e TIP-1028 introduces three main changes: -// NOTE: this is not clear, should be clean concise, +- 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 authorized `receiver`. +- TIP-20 transfer and mint flows are updated to check receive policies before crediting a receiver. If allowed, the transfer proceeds as normal. If blocked, the funds are sent to the escrow precompile and a receipt is recorded. -1. **TIP-403 is extended** with new state and entrypoints: - - Token sets for filtering inbound tokens - - Per-address receive configuration (`receivePolicyId`, `tokenSetId`, `recoveryContract`) - - A new view `verifyAddressInbound(...)` used during TIP-20 flows - -// NOTE: also bad -2. **A new escrow precompile is introduced**: - -- Holds blocked funds at `ESCROW_ADDRESS` -- Records one receipt per blocked inbound -- Exposes `recordBlockedInbound(...)` and `claimBlockedReceipt(...)` - -1. **TIP-20 transfer and mint flows add a receiver-side check**: - - Resolve TIP-1022 addresses - - Run existing issuer-side checks - - Call `verifyAddressInbound(...)` - - If authorized: credit the receiver - - If blocked: credit `ESCROW_ADDRESS` and record a receipt - -With these components in place, TIP-20 transfers and mints execute as follows: - -1. Resolve the recipient (including TIP-1022 virtual address resolution if applicable). -2. Run issuer-side checks (TIP-403 / TIP-1015). -3. Call `verifyAddressInbound(...)`. -4. If authorized, credit the receiver. -5. If blocked, credit `ESCROW_ADDRESS` and record a receipt. +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 - autonumber - participant Caller - participant Token as TIP-20 Token - participant TIP403 as TIP-403 Registry + participant Receiver + participant Sender + participant TIP20 + participant TIP403 participant Escrow - participant Recipient - - Caller->>Token: transfer(to, amount) - - Note over Token: TIP-20 checks:
pause, recipient, amount
(ok, else revert) - - Token->>TIP403: TIP-403 + TIP-1015 token-level policies - TIP403-->>Token: ok, else revert - - Token->>TIP403: TIP-403 (inbound) address-level policies - TIP403-->>Token: (authorized, blockedReason) - - alt authorized (NONE) - Note over Token: TIP20 transfer:
debits sender,
credits recipient,
accrues rewards - Token->>Recipient: emit Transfer(caller, recipient, amount) - Token-->>Caller: success - else blocked (TOKEN_SET / RECEIVE_POLICY / both) - Note over Token: TIP20 transfer:
debits sender,
credits ESCROW,
reward-exempt - Token->>Escrow: emit Transfer(caller, ESCROW, amount) - Note over Escrow: store blocked-receipt
digest(receiver,
requestedRecipient,
originator,
recoveryContract,
reason, kind, memo
blockedAt, blockedNonce) - Escrow-->>Token: (blockedNonce, blockedAt) - Token-->>Caller: success
(funds escrowed) - end -``` - -Escrowed funds are released through `claimBlockedReceipt(...)` with the following flow: -1. Validate the caller against the receipt's `receiver` or snapshotted `recoveryContract`. -2. Recompute the receipt key from the supplied witness. -3. Load and consume the blocked receipt amount. -4. If releasing to the original receiver, credit the receiver directly. -5. If rerouting to another address, enforce destination checks before release. + 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) -```mermaid -sequenceDiagram - autonumber - participant Claimer - participant Escrow - participant TIP403 as TIP-403 Registry - participant Token as TIP-20 Token - participant Recipient - - Claimer->>Escrow: claimBlockedReceipt(token, receiver,
recoveryContract, receipt, to) - - Note over Escrow: caller check:
msg.sender == receiver if recoveryContract == 0,
else msg.sender == recoveryContract
(ok, else revert) - - Note over Escrow: receiptKey = keccak256(witness)
load and delete blockedReceiptAmount[receiptKey]
(ok, else revert) - - alt recipient == receiver (unwind) - Note over Token: escrow release:
debits ESCROW,
credits recipient,
reward-exempt - Token->>Recipient: emit Transfer(ESCROW, recipient, amount) - else recipient != receiver (reroute) - Escrow->>TIP403: TIP-403 + TIP-1015
token-level policies
for recipient - TIP403-->>Escrow: ok, else revert - Escrow->>TIP403: TIP-403 (inbound)
address-level policies
for receipt.originator - TIP403-->>Escrow: ok, else revert - Note over Escrow: if recoveryContract == 0 and via access key:
meter amount against receiver's spending limit - Note over Token: escrow release:
debits ESCROW,
credits recipient,
reward-exempt - Token->>Recipient: emit Transfer(ESCROW, recipient, amount) - end - - Escrow-->>Claimer: success
(emit BlockedReceiptClaimed) + Receiver->>Escrow: claimBlocked(...) + Escrow->>TIP20: release from ESCROW_ADDRESS + TIP20-->>Receiver: transfer funds ``` -### 1.1 TIP-20 Affected Paths +## TIP-20 Operations -This TIP adds receiver-side checks to the following TIP-20 operations: +TIP-1028 applies to the following TIP-20 operations: `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, `systemTransferFrom`, `mint`, `mintWithMemo`. -- `transfer` -- `transferFrom` -- `transferWithMemo` -- `transferFromWithMemo` -- `systemTransferFrom` -- `mint` -- `mintWithMemo` -- protocol withdrawals that execute as TIP-20 transfers from a concrete source balance +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 allowed token list 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 the `msg.sender` as the sender. -For all such paths, TIP-1028 adds a receiver-side authorization layer: +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. -- the receiver's token set is checked against the TIP-20 token address; and -- the receiver's receive policy is checked against the inbound **originator**: - - `from` for transfer-like paths; - - the mint caller for `mint` and `mintWithMemo`. +Token level checks controlled by the issuer (`TIP-403` / `TIP-1015`) are unchanged and continue to revert on failure. -If a token-level TIP-403 or TIP-1015 policy rejects the operation, the operation MUST revert exactly as it does today. If the receiver's address-level controls reject the inbound, the operation MUST be escrowed instead. - -TIP-1028 does **NOT** alter: - -- `approve` / `permit` / `burn` -- fee refunds via `transfer_fee_post_tx` -- the reward subsystem's escrow-ability. Rewards are never escrowed; see [Section 6.3](#63-reward-subsystem) +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 and does not interact with TIP-20 escrowed rewards or internal balances. ### 1.2 TIP-1022 Interaction @@ -992,3 +902,10 @@ contract BasicBlockedReceiptRecovery { ``` This reference design makes `receiver` the only configuration authority, separates claims back to `receiver` from reroutes to third parties, allows delegated claims without forcing that logic into the protocol, and makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own authenticated receipt witness. Receivers that need stronger policy MAY replace it with a multisig, smart wallet, timelock, or custom contract; if they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. + +1. **TIP-20 transfer and mint flows add a receiver-side check**: + - Resolve TIP-1022 addresses + - Run existing issuer-side checks + - Call `verifyAddressInbound(...)` + - If authorized: credit the receiver + - If blocked: credit `ESCROW_ADDRESS` and record a receipt From 6d8226347bc082eef559875163b05b787691b930 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 30 Apr 2026 23:55:26 -0400 Subject: [PATCH 22/59] docs: tip1022 interaction --- tips/tip-1028.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index ce61d6ab99..e3d9783049 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -72,16 +72,11 @@ Token level checks controlled by the issuer (`TIP-403` / `TIP-1015`) are unchang 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 and does not interact with TIP-20 escrowed rewards or internal balances. -### 1.2 TIP-1022 Interaction +### TIP-1022 Interaction -If the `to` address is a TIP-1022 virtual address, TIP-1022 recipient resolution MUST occur **before** TIP-1028 receiver-side authorization. Specifically: +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. -- TIP-1028 authorization applies to the resolved master address, never to the literal virtual address. -- If virtual-address resolution itself fails, the operation MUST revert rather than escrow. -- The blocked receipt's `receiver` MUST be the resolved master address. -- The blocked receipt's `requestedRecipient` MUST be the literal `to`, so offchain systems can recover the TIP-1022 `userTag`. -- If the inbound is authorized, the success path MUST follow TIP-1022 forwarding event semantics. -- TIP-1022 virtual addresses MUST NOT configure their own receive policy (see [Section 3.1](#31-constraints)). The master address's policy governs all virtual addresses derived from it. +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. ## 2. Token Sets (TIP-403 Extension) From 88018a73d360ab054b7d00a15e742a2a73029b1c Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 00:21:13 -0400 Subject: [PATCH 23/59] docs: receive policies --- tips/tip-1028.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index e3d9783049..03dc50a71c 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -78,6 +78,29 @@ If `to` is a TIP-1022 virtual address, it is resolved to its master address befo 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. Each address can configure: + +- A TIP-403 policy (`receivePolicyId`) that defines which senders are allowed. +- A token filter (`tokenFilterId`) that defines which TIP-20 tokens are allowed. +- An optional `recoveryContract` that can claim blocked funds. If not set, the receiver claims directly. + +If no receive policy is set, all transfers and mints are allowed. + +Internally, TIP-403 stores this configuration in a per-address mapping, along with a separate mapping for the `recoveryContract`. + +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 proceeds as normal. If either check fails, the transfer is sent to escrow. + ## 2. Token Sets (TIP-403 Extension) Token sets are a new TIP-403 primitive *for token addresses*. They answer a different question from address policies: From 5a6b0c8dede7100cf2ae845a3074d760b9ddce69 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 00:28:43 -0400 Subject: [PATCH 24/59] docs: receive policy state --- tips/tip-1028.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 03dc50a71c..3c16c7efbc 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -101,6 +101,30 @@ When a TIP-20 transfer or mint executes, it calls `validateReceivePolicy(token, If both checks pass, the transfer proceeds as normal. If either check fails, the transfer is sent to escrow. +### Receive Policy State + +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 | `receivePolicyId` | +| `65..72` | 8 | `receivePolicyType` | +| `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. + +`addressRecoveryContract[account]` stores the recovery contract for the address. If it is `address(0)`, the receiver claims blocked funds directly. + ## 2. Token Sets (TIP-403 Extension) Token sets are a new TIP-403 primitive *for token addresses*. They answer a different question from address policies: From b07cb9938364948a032ff0eeef1bb7c131457cfe Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 00:51:03 -0400 Subject: [PATCH 25/59] docs: token filters --- tips/tip-1028.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 3c16c7efbc..28e5ec716b 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -125,37 +125,37 @@ When `hasReceivePolicy == 0`, the address has no receive policy and all transfer `addressRecoveryContract[account]` stores the recovery contract for the address. If it is `address(0)`, the receiver claims blocked funds directly. -## 2. Token Sets (TIP-403 Extension) +## Token Filters -Token sets are a new TIP-403 primitive *for token addresses*. They answer a different question from address policies: +Token filters control which TIP-20 tokens an address accepts. A receive policy references a token filter by `tokenFilterId`. -- **address policy**: filters by *counterparty* — *"is this counterparty allowed to interact with this token?"* -- **token set**: filters by *token* — *"is this token allowed to interact with this address?"* +A token filter is a list of token addresses with either allowlist or denylist semantics: -Token sets use a separate ID space from policy IDs. They are not aliases for ordinary TIP-403 policy lists and do not reuse the compound-policy surface, but they mirror ordinary TIP-403 list ergonomics, including create-with-members and batched membership updates. +- an allowlist filter allows only the listed tokens +- a denylist filter blocks the listed tokens -### 2.1 Storage and Constraints +This is separate from TIP-403 address policies, which filter by sender. Token filters operate on the token itself. ```solidity -uint64 public tokenSetIdCounter = 2; // 0 = reject all, 1 = allow all +uint64 public tokenFilterIdCounter = 2; // 0 = reject all, 1 = allow all -struct TokenSetData { +struct TokenFilterData { PolicyType setType; // WHITELIST or BLACKLIST address admin; } -mapping(uint64 => TokenSetData) internal _tokenSetData; -mapping(uint64 => mapping(address => bool)) internal tokenSetMembers; +mapping(uint64 => TokenFilterData) internal _tokenFilterData; +mapping(uint64 => mapping(address => bool)) internal tokenFilterMembers; ``` -Built-in meanings: `0` = always reject; `1` = always allow. +Token filters are identified by an ID. `0` rejects all tokens and `1` allows all tokens. New filters are assigned incrementing IDs starting from `2`. -Token sets MUST satisfy: +Token filters must satisfy the following: - `setType` is `WHITELIST` or `BLACKLIST`. -- `COMPOUND` token sets are forbidden. -- Token-set type is immutable after creation. -- Membership is mutable by the token-set admin. +- `COMPOUND` token filters are forbidden. +- Filter type is immutable after creation. +- Membership is mutable by the filter admin. ### 2.2 Interface From 1c1f947c957e3478ba40f7ceff7183b9b48cf56c Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 08:47:22 -0400 Subject: [PATCH 26/59] docs: escrow, claiming --- tips/tip-1028.md | 831 ++++++++++++----------------------------------- 1 file changed, 210 insertions(+), 621 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 28e5ec716b..c737dfc666 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -1,7 +1,7 @@ --- id: TIP-1028 title: Address-Level Receive Policies -description: Extends TIP-403 with token sets, address-level receive controls, and receipt-based escrow for blocked TIP-20 inbounds. +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 status: Draft related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 @@ -14,7 +14,7 @@ protocolVersion: TBD 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. +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 unwind the original inbound while claims to other addresses are treated as new transfers. @@ -35,7 +35,7 @@ The receiver (or a recovery contract) can claim those funds later. Each escrow e 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 authorized `receiver`. +- 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. If allowed, the transfer proceeds as normal. If blocked, the funds are sent to the escrow precompile and 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. @@ -64,13 +64,13 @@ sequenceDiagram 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 allowed token list 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 the `msg.sender` as the sender. +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 and does not interact with TIP-20 escrowed rewards or internal balances. +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 @@ -93,15 +93,13 @@ Receive policies are stored per address in the TIP-403 registry. Each address ca If no receive policy is set, all transfers and mints are allowed. -Internally, TIP-403 stores this configuration in a per-address mapping, along with a separate mapping for the `recoveryContract`. - 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. +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 proceeds as normal. If either check fails, the transfer is sent to escrow. +If both checks pass, the transfer or mint proceeds as normal. If either check fails, the funds are sent to escrow. -### Receive Policy State +### Receive Policy Storage Layout TIP-403 stores receive policy configuration per address. @@ -121,10 +119,21 @@ mapping(address => address) public addressRecoveryContract; | `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. +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 `receivePolicyId = 1` and `tokenFilterId = 1`. The slot remains allocated. + +Constraints on `setReceivePolicy(...)`: + +- `receivePolicyId` 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 an existing token filter, or built-in `0` (reject all) or `1` (allow all). +- `recoveryContract` MAY be `address(0)`. If nonzero, it MUST NOT equal `ESCROW_ADDRESS` and MUST NOT be a TIP-1022 virtual address. +- The caller MUST NOT be a TIP-1022 virtual address. Virtual addresses are forwarding aliases and configure receive policies on their resolved master address. + ## Token Filters Token filters control which TIP-20 tokens an address accepts. A receive policy references a token filter by `tokenFilterId`. @@ -140,7 +149,7 @@ This is separate from TIP-403 address policies, which filter by sender. Token fi uint64 public tokenFilterIdCounter = 2; // 0 = reject all, 1 = allow all struct TokenFilterData { - PolicyType setType; // WHITELIST or BLACKLIST + PolicyType filterType; // WHITELIST or BLACKLIST address admin; } @@ -152,222 +161,81 @@ Token filters are identified by an ID. `0` rejects all tokens and `1` allows all Token filters must satisfy the following: -- `setType` is `WHITELIST` or `BLACKLIST`. +- `filterType` is `WHITELIST` or `BLACKLIST`. - `COMPOUND` token filters are forbidden. - Filter type is immutable after creation. - Membership is mutable by the filter admin. -### 2.2 Interface - -```solidity -interface ITIP403TokenSets { - function createTokenSet(address admin, PolicyType setType) - external - returns (uint64 newTokenSetId); - - function createTokenSetWithTokens( - address admin, - PolicyType setType, - address[] calldata tokens - ) external returns (uint64 newTokenSetId); - - function setTokenSetAdmin(uint64 tokenSetId, address admin) external; - - function modifyTokenSetWhitelist(uint64 tokenSetId, address token, bool allowed) external; - function modifyTokenSetBlacklist(uint64 tokenSetId, address token, bool restricted) external; - - function modifyTokenSetWhitelistBatch( - uint64 tokenSetId, - address[] calldata tokens, - bool[] calldata allowed - ) external; - - function modifyTokenSetBlacklistBatch( - uint64 tokenSetId, - address[] calldata tokens, - bool[] calldata restricted - ) external; - - function isTokenAuthorized(uint64 tokenSetId, address token) external view returns (bool); - function tokenSetExists(uint64 tokenSetId) external view returns (bool); - function tokenSetData(uint64 tokenSetId) - external - view - returns (PolicyType setType, address admin); -} -``` - -`createTokenSetWithTokens(...)` is the token-set analogue of `createPolicyWithAccounts(...)`, and the batch mutation functions are the token-set analogue of TIP-403 batch list updates for ordinary allowlists and denylists. - -### 2.3 Authorization Logic - -`isTokenAuthorized(tokenSetId, token)` MUST: - -- return `false` for `tokenSetId == 0`, -- return `true` for `tokenSetId == 1`, -- otherwise read the token set's immutable `setType` and return the stored membership bit for `token` in a `WHITELIST`, or its negation in a `BLACKLIST`. - -### 2.4 Batch Update Semantics - -For `modifyTokenSetWhitelistBatch(...)` and `modifyTokenSetBlacklistBatch(...)`: - -- `tokens.length` and the corresponding boolean array length MUST match. -- Caller authorization and policy-type checks are identical to the single-entry mutation functions. -- Updates MUST apply in order. -- The call MUST be atomic. -- The implementation MUST emit the ordinary per-token update event once for each touched token, not a separate batch-only event. - -### 2.5 Events and Errors - -```solidity -event TokenSetCreated(uint64 indexed tokenSetId, address indexed creator, PolicyType setType); -event TokenSetAdminUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed admin); -event TokenSetWhitelistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool allowed); -event TokenSetBlacklistUpdated(uint64 indexed tokenSetId, address indexed updater, address indexed token, bool restricted); - -error TokenSetNotFound(); -error InvalidTokenSetType(); -error TokenSetBatchLengthMismatch(); -``` - -## 3. Address-Level Receive Controls (TIP-403 Extension) +`isTokenAllowed(tokenFilterId, token)` returns `false` for filter `0`, `true` for filter `1`, and otherwise reads the stored membership bit and applies it according to the filter's `filterType`. -Address-level receive controls are a new per-address configuration on the TIP-403 registry. They expose three fields: +## Sender Policies -- `receivePolicyId` — which originators may credit the address. -- `tokenSetId` — which TIP-20 token addresses may credit the address. -- `recoveryContract` — an optional dedicated claimer for blocked receipts; `address(0)` means the receiver claims directly. +A receive policy points at an existing TIP-403 policy through `receivePolicyId`. 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). -If an address has no configured receive controls, address-level authorization defaults to allow (i.e., `verifyAddressInbound(...)` returns `(true, NONE)`). +`COMPOUND` policies are not valid for `receivePolicyId`. 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. -### 3.1 Constraints +## Receive Policy Evaluation -`receivePolicyId` MUST reference a simple `WHITELIST` policy, a simple `BLACKLIST` policy, or built-in policy `0` or `1`. It MUST NOT reference a `COMPOUND` policy. Address-level receive controls evaluate only one axis — whether a given inbound originator may credit the receiver — while TIP-1015 `COMPOUND` policies split authorization across sender, transfer-recipient, and mint-recipient roles. +`validateReceivePolicy(token, sender, receiver)` evaluates the receiver's configuration and returns whether the inbound is allowed. -`recoveryContract` MAY be `address(0)`. If nonzero, it designates the sole direct claimer for **future** blocked receipts for this receiver and MUST NOT equal `ESCROW_ADDRESS` or be a TIP-1022 virtual address. +It MUST: -TIP-1022 virtual addresses are forwarding aliases, not canonical TIP-20 holders. `setAddressReceivePolicy()` MUST reject TIP-1022 virtual addresses and require configuration on the resolved master address instead. +1. Read `addressReceiveConfig[receiver]`. +2. If `hasReceivePolicy == 0`, return `(true, NONE)`. +3. Otherwise, decode `receivePolicyId`, `receivePolicyType`, `tokenFilterId`, and `tokenFilterType`. +4. Evaluate `token` against the token filter. +5. Evaluate `sender` against the receive policy. +6. Return: + - `(true, NONE)` if both checks pass. + - `(false, TOKEN_FILTER)` if only the token filter check fails. + - `(false, RECEIVE_POLICY)` if only the receive policy check fails. + - `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` if both fail. -### 3.2 Blocked Reasons +`BlockedReason` classifies why an inbound was blocked: ```solidity enum BlockedReason { NONE, - TOKEN_SET, + TOKEN_FILTER, RECEIVE_POLICY, - TOKEN_SET_AND_RECEIVE_POLICY + TOKEN_FILTER_AND_RECEIVE_POLICY } ``` -`BlockedReason` classifies why an inbound was escrowed. `NONE` is used only when the inbound is authorized; blocked events MUST NOT set `blockedReason == NONE`. - -### 3.3 Recovery Contract Semantics - -Each blocked inbound snapshots the receiver's *current* `recoveryContract` at block time. That snapshot becomes part of the blocked receipt and its storage key, and governs later claims for that receipt. **Changing `recoveryContract` affects only future receipts.** Existing receipt buckets remain governed by the recovery contract captured in their key. - -This makes recovery-contract authority explicit and non-retroactive. Receivers that rotate `recoveryContract` SHOULD keep the previous contract callable until receipts keyed to it are drained. - -### 3.4 Packed Storage - -```solidity -mapping(address => uint256) public addressReceiveConfig; -mapping(address => address) public addressRecoveryContract; -``` - -| Bits | Size | Field | -|------|------|-------| -| `0` | 1 | `hasAddressPolicy` | -| `1..64` | 64 | `receivePolicyId` | -| `65..72` | 8 | cached `receivePolicyType` | -| `73..136` | 64 | `tokenSetId` | -| `137..144` | 8 | cached `tokenSetType` | -| `145..255` | 111 | reserved, MUST be zero | +`NONE` is used only when the inbound is allowed. Blocked events MUST NOT use `NONE`. -When `hasAddressPolicy == 0`, the address is always authorized at the address level. The cached type fields are valid because policy type and token-set type are immutable after creation. +## Escrow Precompile -`recoveryContract` is stored separately because a 160-bit address does not fit in the packed config slot. +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. -### 3.5 Interface +### Escrow Address ```solidity -interface IAddressReceivePolicies { - function setAddressReceivePolicy( - uint64 receivePolicyId, - uint64 tokenSetId, - address recoveryContract - ) external; - - function addressReceivePolicy(address account) - external - view - returns ( - bool hasAddressPolicy, - uint64 receivePolicyId, - PolicyType receivePolicyType, - uint64 tokenSetId, - PolicyType tokenSetType, - address recoveryContract - ); - - function verifyAddressInbound(address token, address originator, address to) - external - view - returns (bool authorized, BlockedReason blockedReason); -} +address constant ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000; ``` -Implementations SHOULD read `addressRecoveryContract[to]` only after `verifyAddressInbound(...)` returns `authorized = false`. - -### 3.6 Authorization Logic - -`verifyAddressInbound(token, originator, to)` MUST: - -1. Read the packed config for `to`. -2. If `hasAddressPolicy == 0`, return `(true, NONE)`. -3. Otherwise, decode `receivePolicyId`, `receivePolicyType`, `tokenSetId`, and `tokenSetType`. Evaluate whether `token` is allowed by the token set, and whether `originator` is allowed by the receive policy. Return: - - `(true, NONE)` if both checks pass. - - `(false, TOKEN_SET_AND_RECEIVE_POLICY)` if both fail. - - `(false, TOKEN_SET)` if only the token-set check fails. - - `(false, RECEIVE_POLICY)` if only the receive-policy check fails. +`ESCROW_ADDRESS` is the address of the escrow precompile. The blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot. -An address that wants to functionally disable filtering SHOULD set `receivePolicyId = 1` and `tokenSetId = 1`. The slot remains allocated. +### Restrictions on `ESCROW_ADDRESS` -### 3.7 Events and Errors +`ESCROW_ADDRESS` is a system precompile, not an account. It is only credited when the TIP-20 inbound dispatch escrows a blocked transfer or mint, and only debited when `claimBlocked(...)` releases a receipt. The following restrictions apply: -```solidity -event AddressReceivePolicyUpdated( - address indexed account, - uint64 receivePolicyId, - uint64 tokenSetId, - address recoveryContract -); - -error InvalidReceivePolicyType(); -error InvalidRecoveryContract(); -``` - -## 4. Escrow Precompile - -The escrow precompile is a new system precompile dedicated to blocked-inbound bookkeeping. It lives at `ESCROW_ADDRESS`: the blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot — i.e., held by this precompile — while the precompile's own storage records **only one keyed amount per open blocked receipt**. The rest of the receipt identity is authenticated by the witness fields supplied at claim time and is emitted in the blocked-inbound event when the receipt is created. - -### 4.1 Precompile Address +- 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` and any `recoveryContract == ESCROW_ADDRESS`. +- Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. -```solidity -address constant ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000; -``` +Funds reach `balances[ESCROW_ADDRESS]` only when the TIP-20 inbound dispatch decides to escrow a blocked transfer or mint, and leave it only through `claimBlocked(...)`. -`ESCROW_ADDRESS` is the address of this precompile. Calls to it execute precompile code, and `balances[ESCROW_ADDRESS]` inside each TIP-20 token represents tokens held by this precompile. It is a system precompile, not a userland account, and the TIP-20 layer never enumerates blocked receipts at this address — receipt accounting lives in the precompile's own storage ([Section 4.2](#42-storage)). +The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly 250,000 gas to the first blocked transfer or mint. TIP-20 implementations SHOULD move that cost to deployment by initializing the slot when the token is created. One acceptable pattern is an implementation-private escrow reserve created at deployment that is not claimable by users or recovery contracts and is preserved by release and burn logic. -Reservation rules: +### Escrow Model -- Userland TIP-20 calls with `to == ESCROW_ADDRESS` (transfer-like or mint-like) MUST revert with `EscrowAddressReserved()`. -- `setAddressReceivePolicy(...)` MUST reject `ESCROW_ADDRESS` and any `recoveryContract == ESCROW_ADDRESS`. -- Reroute claims with `to == ESCROW_ADDRESS` MUST revert. -- Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. +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. The receipt keeps enough identity to recover the original sender and recipient, attribute the blocked transfer or mint, and authorize a later claim. -For reward accounting purposes (see [Section 6.3](#63-reward-subsystem)), `ESCROW_ADDRESS` is a reward-exempt always-opted-out synthetic sink/source: blocked transfers, blocked mints, and claim releases MUST preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. +The receipt is identified by a `receiptKey` derived from the receipt fields. The escrow precompile only stores the keyed amount. The other receipt fields are emitted in the blocked event when the receipt is created, and the claimer supplies them again at claim time as a witness. -### 4.2 Storage +### Escrow State ```solidity uint8 public constant BLOCKED_RECEIPT_VERSION = 1; @@ -375,7 +243,7 @@ uint64 public blockedReceiptNonce = 1; mapping(bytes32 => uint256) internal blockedReceiptAmount; ``` -The persistent escrow key for a blocked inbound is: +The persistent escrow key for a blocked transfer or mint is: ```text receiptKey = keccak256( @@ -397,148 +265,88 @@ receiptKey = keccak256( where: -- `receiptVersion` — one-byte bucketing tag; MUST be `1` for receipts created under this TIP. Future bucketing or receipt-key formats MUST use a different value. -- `token` — the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS` -- `receiver` — the canonical TIP-20 holder that owns the receipt (resolved master address for TIP-1022 inbounds) -- `originator` — `from` for transfers, mint caller for mints -- `requestedRecipient` — the literal `to` supplied at the TIP-20 entrypoint; preserves TIP-1022 attribution. For non-virtual inbounds, `requestedRecipient == receiver` -- `recoveryContract` — the receiver's snapshotted recovery contract, or `address(0)` -- `blockedReason` — receiver's token set, receive policy, or both -- `kind` — `TRANSFER` or `MINT` (see [Section 4.3](#43-interface)) -- `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 +- `receiptVersion`: one-byte bucketing tag. MUST be `1` for receipts created under this TIP. Future receipt-key formats MUST use a different value. +- `token`: the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS`. +- `receiver`: the canonical TIP-20 holder that owns the receipt. For TIP-1022 inbounds this is the resolved master address. +- `originator`: `from` for transfers, `msg.sender` for mints. +- `requestedRecipient`: the literal `to` supplied at the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. +- `recoveryContract`: the receiver's recovery contract at the time the receipt was created, or `address(0)`. +- `blockedReason`: `TOKEN_FILTER`, `RECEIVE_POLICY`, or `TOKEN_FILTER_AND_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. -The escrow precompile does not need to store the rest of the receipt field-by-field in persistent state. Instead, the same witness fields MUST be used to recompute `receiptKey` at claim time and MUST be surfaced in the blocked-inbound event emitted when the receipt is created. +Storing one fine-grained receipt per blocked transfer or mint is more expensive than aggregating, but it preserves the literal `requestedRecipient` 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. Persistent state stays minimal: one keyed amount per receipt. The richer fields live in the witness and the blocked event. -For TIP-1022 virtual-address inbounds, `receiver` is the resolved master address while `requestedRecipient` preserves the literal virtual address. +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. -It also does not enumerate receiver-owned receipts onchain. Claimers MUST supply the receipt witness for the receipt they want to consume, typically using logs or offchain indexing. The protocol claim interface is intentionally single-receipt; recovery contracts or callers that want batching MAY loop or use multicall outside the protocol surface. +### Storing Blocked Transfers and Mints -### 4.3 Interface +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. The TIP-20 path then emits the raw `Transfer` (and `Mint`, for mint paths) event naming `ESCROW_ADDRESS` as the recipient, followed by the matching `TransferBlocked` or `MintBlocked` attribution event with `blockedReason != NONE`. Memo-bearing variants preserve the original memo in the attribution event. -```solidity -interface IBlockedInboundEscrow { - enum InboundKind { - TRANSFER, - MINT - } +`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. - struct ClaimReceipt { - uint8 receiptVersion; - address originator; - address requestedRecipient; - uint64 blockedAt; - uint64 blockedNonce; - BlockedReason blockedReason; - InboundKind kind; - bytes32 memo; - } +### Claiming - function blockedReceiptBalance( - address token, - address receiver, - address recoveryContract, - ClaimReceipt calldata receipt - ) - external - view - returns (uint256 amount); - - function claimBlockedReceipt( - address token, - address receiver, - address recoveryContract, - ClaimReceipt calldata receipt, - address to - ) external; - - function recordBlockedInbound( - address token, - address originator, - address receiver, - address requestedRecipient, - address recoveryContract, - uint256 amount, - BlockedReason blockedReason, - InboundKind kind, - bytes32 memo - ) external returns (uint64 blockedNonce, uint64 blockedAt); -} -``` - -`recordBlockedInbound(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. User callers MUST NOT be able to fabricate receipts. - -### 4.4 Claim Authorization - -Each blocked receipt is governed by the `recoveryContract` captured for that receipt at block time. - -`claimBlockedReceipt(...)` consumes only the explicitly supplied receipt bucket and releases only to `to`. It MUST: - -- require `msg.sender == receiver` when `recoveryContract == address(0)`, -- require `msg.sender == recoveryContract` when `recoveryContract != address(0)`, -- interpret `receipt` as the witness tuple `(receipt.receiptVersion, token, receiver, receipt.originator, receipt.requestedRecipient, recoveryContract, receipt.blockedReason, receipt.kind, receipt.memo, receipt.blockedAt, receipt.blockedNonce)`, -- recompute `receiptKey`, -- require `blockedReceiptAmount[receiptKey] > 0`, -- consume the entire stored amount for that receipt and delete the slot, or revert with `InvalidReceiptClaim()`. - -**Partial claims are not allowed.** Claims MUST consume whole receipts. - -The supplied receipt witness is a *selector*, not an authority. Claim rights flow only from `receiver` or the snapshotted `recoveryContract`. Receivers who want delegate whitelists, originator self-claim, multisig approval, timelocks, batching, or any richer recovery policy SHOULD set `recoveryContract` to a userland contract or smart wallet that enforces that policy. See [Section 4.6](#46-standard-recovery-contract-pattern-non-normative) and Appendix A for a non-normative reference pattern. +A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, recomputes `receiptKey`, requires `blockedReceiptAmount[receiptKey] > 0`, deletes the slot, and releases the stored amount. Partial claims are not allowed. Claims MUST consume whole receipts. -### 4.5 Release Semantics +The release path inside the TIP-20 token debits `balances[ESCROW_ADDRESS]`, credits the destination, emits `Transfer(ESCROW_ADDRESS, to, amount)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. -All claims release only to the caller-specified `to`. The escrow precompile MUST call an internal TIP-20 escrow-release path that: +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(...)`. -1. debits `balances[ESCROW_ADDRESS]`, -2. credits the beneficiary, -3. emits `Transfer(ESCROW_ADDRESS, beneficiary, amount)`, -4. bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, -5. treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. +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 two release modes differ only in what they enforce on the destination side. +The destination `to` decides whether the claim is an **unwind** back to the receiver or a **reroute** to a new address. The two cases differ only in what they enforce on the destination side. -#### 4.5.1 Claim to Receiver (Unwind) +When `to == receiver`, the claim is an unwind of 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 transfer-recipient authorization. -If `to == receiver`, the claim is an **unwind** of a previously authorized inbound. It MUST: +When `to != receiver`, the claim is a reroute and is treated as a new spend by the receiver. The reroute MUST reject `to == ESCROW_ADDRESS` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. -- bypass the receiver's address-level receive controls, -- bypass token-level recipient authorization for the receiver, -- bypass AccountKeychain spending-limit metering. +The sequence diagram below shows the high level flow for a claim. -This is intentional for TIP-1015 compound policies. A blocked mint, for example, has already satisfied the token's original `mint_recipient` authorization on the inbound path. Releasing that escrow back to the same receiver is an unwind of a previously authorized mint-like inbound, not a new transfer, and MUST NOT be rechecked against transfer-recipient authorization. - -#### 4.5.2 Reroute (`to != receiver`) - -If `to != receiver`, the claim is a **reroute**, treated as a new spend by the receiver. It MUST: - -- reject `to == ESCROW_ADDRESS` -- reject TIP-1022 virtual addresses as `to` -- enforce token-level transfer-recipient authorization for `to` -- enforce `to`'s address-level receive controls against the consumed receipt's `originator` -- revert with `ClaimDestinationUnauthorized()` if the destination's checks fail -- if `recoveryContract == address(0)` and the reroute is initiated through an access key, meter the total claimed amount against the receiver's AccountKeychain spending limit exactly as an ordinary TIP-20 spend by the receiver +```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 +``` -If a receiver installs a custom `recoveryContract`, any equivalent delegation, timelock, multisig, or key-policy enforcement is a userland concern of that contract. +## Invariants -### 4.6 Standard Recovery Contract Pattern (Non-Normative) +## Events and Errors -The protocol does not mandate any particular recovery-contract design. The standard pattern is a reusable receiver-owned implementation, not a global singleton: +### Receive Policy Events -- the receiver deploys its own instance, proxy, or smart-wallet module; -- sets `recoveryContract` to that instance via `setAddressReceivePolicy(...)`; -- stores one canonical `receiver`; -- uses `claimToReceiver(...)` as the default unwind path; -- optionally permits reroutes to `to != receiver`; -- if originator self-claim is supported, restricts it to receipts whose supplied `originator` equals the caller and only releases to the caller itself. +```solidity +event ReceivePolicyUpdated( + address indexed account, + uint64 receivePolicyId, + uint64 tokenFilterId, + address recoveryContract +); +``` -If the receiver rotates to a new recovery contract, the old contract SHOULD remain callable until receipts keyed to it are drained (see [Section 3.3](#33-recovery-contract-semantics)). +### Token Filter Events -Appendix A gives a non-normative Solidity reference design for this pattern. +```solidity +event TokenFilterCreated(uint64 indexed tokenFilterId, address indexed creator, PolicyType filterType); +event TokenFilterAdminUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed admin); +event TokenFilterWhitelistUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed token, bool allowed); +event TokenFilterBlacklistUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed token, bool restricted); +``` -### 4.7 Events and Errors +### Escrow Events ```solidity event TransferBlocked( @@ -582,270 +390,110 @@ event BlockedReceiptClaimed( address to, uint256 amount ); - -error UnauthorizedClaimer(); -error InsufficientEscrowBalance(); -error EscrowOnlyTIP20(); -error ClaimDestinationUnauthorized(); -error InvalidReceiptClaim(); ``` -A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. - -## 5. TIP-20 Inbound Path Changes - -This section specifies the exact integration each TIP-20 path takes. All new logic lives in the TIP-20 token precompile; the token consults TIP-403 ([Section 3](#3-address-level-receive-controls-tip-403-extension)) and the escrow precompile ([Section 4](#4-blocked-inbound-escrow-precompile)) but owns the inbound dispatch. - -Userland TIP-20 transfers or mints directly to `ESCROW_ADDRESS` MUST revert: +### Errors ```solidity +error InvalidReceivePolicyType(); +error InvalidRecoveryContract(); +error TokenFilterNotFound(); +error InvalidTokenFilterType(); +error TokenFilterBatchLengthMismatch(); error EscrowAddressReserved(); +error UnauthorizedClaimer(); +error InvalidReceiptClaim(); +error ClaimDestinationUnauthorized(); +error InsufficientEscrowBalance(); ``` -### 5.1 Transfer-like Paths - -For `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, and `systemTransferFrom`: - -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants -- compute `effectiveReceiver`: - - `resolveRecipient(to)` if `to` is a TIP-1022 virtual address (revert if resolution fails), - - otherwise `to` -- set `requestedRecipient = to` -- run the existing TIP-20 pause, balance, allowance, and token-level TIP-403 / TIP-1015 checks, using `effectiveReceiver` wherever the current path is recipient-sensitive (failure here MUST revert) -- call `verifyAddressInbound(token, from, effectiveReceiver)` and capture `(authorized, blockedReason)` - -If the inbound is authorized: - -- follow the normal transfer path using `effectiveReceiver` -- if `to` is virtual, use TIP-1022 forwarding event semantics -- update rewards exactly as on a normal transfer -- debit `from`, credit `effectiveReceiver`, return success - -If the inbound is blocked by the receiver: - -- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` -- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` -- debit `from`, credit `ESCROW_ADDRESS` -- call `recordBlockedInbound(token, from, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, TRANSFER, memo)` and capture `(blockedNonce, blockedAt)` -- emit `Transfer(from, ESCROW_ADDRESS, amount)` -- emit `TransferBlocked(token, from, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -- return success - -Memo-bearing transfer variants MUST preserve the original memo in the blocked event. Their raw memo-bearing TIP-20 event MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. - -### 5.2 Mint-like Paths - -For `mint` and `mintWithMemo`: - -- if `to == ESCROW_ADDRESS`, revert with `EscrowAddressReserved()` -- set `memo = bytes32(0)` for non-memo variants, or the supplied memo for memo-bearing variants -- compute `effectiveReceiver` as in [Section 5.1](#51-transfer-like-paths) -- set `requestedRecipient = to` -- run the existing issuer-role, mint-recipient, and supply-cap checks, using `effectiveReceiver` wherever the current path is recipient-sensitive -- call `verifyAddressInbound(token, originator, effectiveReceiver)` and capture `(authorized, blockedReason)`, where `originator` is the mint caller - -If the inbound is authorized: - -- follow the normal mint path using `effectiveReceiver` -- if `to` is virtual, use TIP-1022 forwarding event semantics -- update rewards exactly as on a normal mint -- increase total supply, credit `effectiveReceiver`, return success - -If the inbound is blocked by the receiver: - -- read the current `addressRecoveryContract[effectiveReceiver]` and capture `recoveryContract` -- update rewards as if the raw recipient were a reward-exempt always-opted-out `ESCROW_ADDRESS` -- increase total supply, credit `ESCROW_ADDRESS` -- call `recordBlockedInbound(token, originator, effectiveReceiver, requestedRecipient, recoveryContract, amount, blockedReason, MINT, memo)` and capture `(blockedNonce, blockedAt)` -- emit `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` -- emit `MintBlocked(token, originator, effectiveReceiver, BLOCKED_RECEIPT_VERSION, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -- return success - -`mintWithMemo` MUST preserve the original memo in the blocked event. Its raw mint-related events MUST still name `ESCROW_ADDRESS` as the raw recipient when blocked. - -### 5.3 Raw Event Truthfulness - -Blocked inbounds MUST use truthful raw TIP-20 events: - -- blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` -- blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` -- claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` - -In addition, every blocked inbound MUST emit exactly one attribution event (`TransferBlocked` or `MintBlocked`) whose `blockedReason != NONE`. For blocked TIP-1022 deposits, `requestedRecipient` preserves the literal virtual address while `receiver` names the resolved master address that owns the receipt: - -- `TransferBlocked(token, from, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` -- `MintBlocked(token, operator, receiver, receiptVersion, blockedNonce, blockedAt, requestedRecipient, amount, blockedReason, recoveryContract, memo)` - -## 6. Adjacent Subsystems - -The general rule is: every TIP-20 outbound onto the canonical balance ledger goes through the inbound dispatch in [Section 5](#5-tip-20-inbound-path-changes) and is therefore subject to receiver-side checks. This section calls out each adjacent subsystem and its specific handling. - -### 6.1 DEX, FeeManager, and TIPFeeAMM Wallet Payouts - -- **DEX internal balances are out of scope.** Internal DEX balances are not TIP-20 wallet balances and are not gated until withdrawn back onto the TIP-20 ledger. -- **DEX wallet payouts remain ordinary TIP-20 transfers.** DEX `withdraw` calls and swap outputs that transfer from the DEX address to a wallet remain subject to address-level receive controls and may therefore be escrowed. -- **FeeManager and TIPFeeAMM payouts remain ordinary TIP-20 transfers.** Validator fee distributions, AMM burns, and TIPFeeAMM outputs such as `rebalanceSwap` payouts remain subject to address-level receive controls and may therefore be escrowed. -- **Higher-level entrypoints become processed-vs-credited operations.** A DEX, FeeManager, or AMM call may complete successfully even if the final TIP-20 outbound was escrowed rather than credited to the intended wallet. Integrations that require guaranteed wallet credit MUST inspect `TransferBlocked` / `MintBlocked` or wrap these calls with additional logic. This applies to higher-level Tempo precompile events too; they are not rewritten to distinguish direct credit from escrow. - -### 6.2 Fee Refunds - -`transfer_fee_post_tx` is a refund of the current transaction's unused fee deposit, not a new third-party inbound. It MUST bypass address-level receive controls and MUST NOT be escrowed. - -### 6.3 Reward Subsystem - -Reward flows are **never escrowed**, but they are **not exempt from recipient consent**. - -- `distributeReward` remains token-internal accounting. -- `setRewardRecipient(holder, recipient)`: - - `recipient == address(0)` is the opt-out path and bypasses receive-policy checks. - - Otherwise the token MUST call `verifyAddressInbound(token, holder, recipient)` and revert if unauthorized. -- `claimRewards(...)`: - - The token determines the actual payout recipient under its existing reward rules. - - It MUST revalidate that recipient with `verifyAddressInbound(token, address(token), recipient)` and revert (rather than escrow) if unauthorized. - -For reward-state purposes, blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source. Implementations MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. - -### 6.4 Memo-Bearing Paths - -Blocked memo-bearing inbounds keep their raw memo event. The raw memo-bearing event still names `ESCROW_ADDRESS`; receivers that care about memo-based routing MUST correlate it with the blocked-receipt event in the same transaction. - -### 6.5 Out-of-Scope Surfaces - -`approve`, `permit`, `burn`, non-TIP-20 tokens deployed as ordinary contracts, and future recipient-bearing system-credit paths that do not identify a concrete originator are unchanged by this TIP. - -### 6.6 Integration Consequence - -After TIP-1028, `transfer`, `transferFrom`, `mint`, `mintWithMemo`, DEX wallet payouts, and FeeManager / TIPFeeAMM wallet payouts may succeed either because the intended receiver was credited or because the inbound was escrowed. Contracts and offchain systems that must distinguish those outcomes MUST inspect `TransferBlocked` / `MintBlocked` events or use wrapper logic. - -## 7. Gas and Storage Analysis - -This section uses the gas model from TIP-1016: - -- fresh storage slot: **~250,000 gas** -- existing nonzero slot update: **~2,900 gas** -- typical TIP-20 transfer to an existing address: **~50,000 gas** - -### 7.1 Main Cases - -| Case | Rough cost | Notes | -|------|------------|-------| -| first `setAddressReceivePolicy()` with `recoveryContract == address(0)` | `~250k + call overhead` | one new packed config slot | -| first `setAddressReceivePolicy()` with nonzero `recoveryContract` | `~500k + call overhead` | packed config slot plus recovery-contract slot | -| allowed inbound to address with no receive config | current path + one cold config read | no escrow writes | -| allowed inbound to configured address | current path + config read + token-set or policy membership reads | no escrow writes | -| blocked inbound after escrow balance slot is preinitialized | `~300k` | one new keyed receipt amount slot plus normal transfer path | -| blocked inbound without escrow-slot preinitialization | previous row + `~250k` | first live zero-to-nonzero write to `balances[ESCROW_ADDRESS]` | -| claim one receipt | one transfer from escrow + one receipt-slot delete + auth reads | batching belongs above the protocol layer | - -### 7.2 Storage Choices - -The design intentionally stores **one fine-grained receipt bucket per blocked inbound**. That is more expensive than an aggregate bucket, but it preserves: - -- the literal `requestedRecipient` needed for TIP-1022 attribution, -- the original `originator`, -- the block timestamp `blockedAt`, -- the distinction between transfer-blocked and mint-blocked funds, -- memo and block-reason data for programmable recovery rules. - -The persistent state is still just one keyed amount per blocked inbound. The richer receipt metadata is authenticated through the receipt witness and surfaced in the blocked events rather than stored as a multi-slot onchain struct. - -### 7.3 Escrow Slot Strategy - -The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly `250,000` gas to the first blocked live transfer. - -TIP-20 implementations SHOULD move that cost to deployment by initializing the `balances[ESCROW_ADDRESS]` slot when the token is created, before any blocked inbound occurs. One acceptable pattern is to do this with a non-user-claimable implementation-private escrow reserve; implementations MAY use any equivalent deployment-time mechanism instead. - -If an implementation uses such a reserve: - -- it MUST be created at token deployment time; -- it MUST exist only to initialize and keep live the `balances[ESCROW_ADDRESS]` slot; -- it MUST NOT correspond to any blocked receipt; -- it MUST NOT be claimable by users or recovery contracts; -- release and burn logic MUST preserve it as implementation-private state. - -## 8. Security and Integration Considerations - -- **Success no longer implies receiver credit.** A successful transfer or mint means the inbound was processed, not necessarily that the intended receiver's balance increased. This is the most important behavioral change for integrators. -- **Ordinary contracts should usually not opt in.** A contract address that enables receive controls can cause callers to observe a successful `transfer` / `transferFrom` / mint-like payout even though the asset was escrowed instead of credited to the contract. Contracts that are not explicitly built to inspect blocked-receipt events and claim from escrow SHOULD NOT opt in. -- **Claim-to-receiver is an unwind; reroutes are new transfers.** Claim-to-receiver bypasses the receiver's token-level and address-level receive checks. Reroutes to `to != receiver` must satisfy token-level recipient authorization for `to`, that destination's address-level receive controls, and (for direct receiver reroutes through an access key) ordinary AccountKeychain spending-limit metering. -- **Recovery-contract authority is explicit and not retroactive.** If a receiver sets `recoveryContract`, that address is the sole direct claimer for future blocked receipts. Any delegate whitelist, originator self-claim, multisig approval, timelock, key-spend policy, or batching is a userland concern of the recovery contract. Older receipts remain keyed to the recovery contract captured when they were blocked, so rotation can strand funds if the old contract later becomes unusable. -- **Reward delegation requires consent.** `setRewardRecipient` and `claimRewards` now fail if the target recipient's address-level receive controls reject the reward flow. -- **Policy configuration is permanent state.** An address can functionally disable filtering by setting allow-all values, but the storage slot remains allocated. -- **Receipt witnesses are public.** Blocked-inbound events emit the full witness; anyone can construct a `ClaimReceipt`. The witness selects a receipt but does not authorize a claim — only `receiver` (when `recoveryContract == 0`) or `recoveryContract` may call `claimBlockedReceipt(...)`. -- **`recordBlockedInbound(...)` is privileged.** Only TIP-20 precompiles or protocol-internal system code may call it. Otherwise an attacker could mint synthetic receipts by replaying or fabricating witnesses without backing escrow balance. - -## 9. Invariants - -The following invariants MUST always hold: - -1. For every TIP-20 token: - - ```text - balances[ESCROW_ADDRESS] - = implementation_private_escrow_reserve[token] - + sum_over_open_blocked_receipts_for_token(receipt.amount) - ``` - -2. Userland `transfer(..., ESCROW_ADDRESS)`, `transferFrom(..., ESCROW_ADDRESS)`, `mint(ESCROW_ADDRESS, ...)`, and `mintWithMemo(ESCROW_ADDRESS, ...)` MUST revert. - -3. If token-level checks and sender-side or issuer-side checks pass, then a receiver-side address-policy failure MUST divert to escrow rather than revert. - -4. Token-level policy failure on the original transfer or mint path MUST still revert. - -5. Every blocked inbound MUST create exactly one blocked receipt bucket. - -6. Every blocked inbound MUST emit the literal requested recipient as `requestedRecipient`, even when the canonical `receiver` differs because of TIP-1022 resolution. - -7. Only `receiver` may claim a receipt bucket whose `recoveryContract == address(0)`. Only the receipt's `recoveryContract` may claim a receipt bucket whose `recoveryContract != address(0)`. +A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. -8. A claim to `receiver` MUST bypass the receiver's address-level receive controls and token-level recipient authorization. +## Interfaces -9. A rerouted claim to `to != receiver` MUST enforce token-level recipient authorization for `to` and MUST revert with `ClaimDestinationUnauthorized()` if it fails. +### TIP-403 Receive Policy Interface -10. A rerouted claim to `to != receiver` MUST enforce the destination's address-level receive controls against the consumed receipt's `originator`. +```solidity +interface IReceivePolicies { + function setReceivePolicy( + uint64 receivePolicyId, + uint64 tokenFilterId, + address recoveryContract + ) external; -11. If `recoveryContract == address(0)`, `to != receiver`, and the claim is initiated through an access key, the claim MUST meter the total claimed amount against the receiver's AccountKeychain spending limit as an ordinary TIP-20 spend by the receiver. + function receivePolicy(address account) + external + view + returns ( + bool hasReceivePolicy, + uint64 receivePolicyId, + PolicyType receivePolicyType, + uint64 tokenFilterId, + PolicyType tokenFilterType, + address recoveryContract + ); -12. Fee refunds via `transfer_fee_post_tx` MUST bypass address-level receive controls and MUST NOT be escrowed. + function validateReceivePolicy(address token, address sender, address receiver) + external + view + returns (bool authorized, BlockedReason blockedReason); +} +``` -13. `ESCROW_ADDRESS` MUST be treated as a protected system address by system-balance-sensitive TIP-20 logic, including any escrow-release and `burnBlocked`-like paths. +Implementations SHOULD read `addressRecoveryContract[receiver]` only after `validateReceivePolicy(...)` returns `authorized = false`. -14. Changing `addressRecoveryContract[receiver]` MUST affect only future blocked receipts. Existing receipt buckets remain governed by the recovery contract captured in their key. +### TIP-403 Token Filter Interface -15. Escrow-related raw TIP-20 events MUST be truthful: +```solidity +interface ITokenFilters { + function createTokenFilter(address admin, PolicyType filterType) + external + returns (uint64 newTokenFilterId); -- blocked transfer: `Transfer(from, ESCROW_ADDRESS, amount)` -- blocked mint: `Transfer(address(0), ESCROW_ADDRESS, amount)` and `Mint(ESCROW_ADDRESS, amount)` -- claim release: `Transfer(ESCROW_ADDRESS, beneficiary, amount)` + function createTokenFilterWithTokens( + address admin, + PolicyType filterType, + address[] calldata tokens + ) external returns (uint64 newTokenFilterId); -1. Every blocked inbound MUST emit exactly one blocked-receipt attribution event naming the receiver, the requested recipient, the reason, the governing `recoveryContract`, the receipt's version, the receipt's `blockedAt`, and the receipt's blocked nonce. That event's `blockedReason` MUST NOT be `NONE`. + function setTokenFilterAdmin(uint64 tokenFilterId, address admin) external; -2. Claims MUST consume whole blocked receipts. Once a receipt is claimed, its keyed amount MUST be deleted rather than partially decremented. + function modifyTokenFilterWhitelist(uint64 tokenFilterId, address token, bool allowed) external; + function modifyTokenFilterBlacklist(uint64 tokenFilterId, address token, bool restricted) external; -3. Reward accounting for blocked transfers, blocked mints, and claim releases MUST treat `ESCROW_ADDRESS` as a reward-exempt always-opted-out synthetic sink/source, preserve the same opted-in-supply effects as a movement into or out of an always-opted-out address, and MUST NOT create, update, or consult per-user reward state for `ESCROW_ADDRESS`. + function modifyTokenFilterWhitelistBatch( + uint64 tokenFilterId, + address[] calldata tokens, + bool[] calldata allowed + ) external; -4. `setRewardRecipient` MUST reject any nonzero recipient whose address-level receive controls would block the holder as originator. + function modifyTokenFilterBlacklistBatch( + uint64 tokenFilterId, + address[] calldata tokens, + bool[] calldata restricted + ) external; -5. `claimRewards` MUST reject any payout recipient whose address-level receive controls would block the token contract as originator. + function isTokenAllowed(uint64 tokenFilterId, address token) external view returns (bool); + function tokenFilterExists(uint64 tokenFilterId) external view returns (bool); + function tokenFilterData(uint64 tokenFilterId) + external + view + returns (PolicyType filterType, address admin); +} +``` -6. `verifyAddressInbound(...)` MUST return `blockedReason == NONE` exactly when `authorized == true`. +Batch update semantics: -## Appendix A. Solidity Reference Recovery Contract (Non-Normative) +- `tokens.length` and the boolean array length MUST match. +- Caller authorization and policy-type checks are identical to the single-entry mutation functions. +- Updates MUST apply in order. +- The call MUST be atomic. +- The implementation MUST emit the per-token update event once for each touched token, not a separate batch-only event. -The following contract is illustrative only. It is not part of the protocol, and receivers may use any recovery contract or smart wallet that obeys the rules in [Section 4.4](#44-claim-authorization) and [Section 4.5](#45-release-semantics). +### Escrow Interface ```solidity -pragma solidity ^0.8.24; - -interface IBlockedInboundEscrowReference { - enum BlockedReason { - NONE, - TOKEN_SET, - RECEIVE_POLICY, - TOKEN_SET_AND_RECEIVE_POLICY - } - +interface IBlockedInboundEscrow { enum InboundKind { TRANSFER, MINT @@ -867,87 +515,28 @@ interface IBlockedInboundEscrowReference { address receiver, address recoveryContract, ClaimReceipt calldata receipt - ) - external - view - returns (uint256 amount); + ) external view returns (uint256 amount); - function claimBlockedReceipt( + function claimBlocked( address token, address receiver, address recoveryContract, ClaimReceipt calldata receipt, address to ) external; -} - -contract BasicBlockedReceiptRecovery { - error Unauthorized(); - error OriginatorSelfClaimDisabled(); - error UseClaimToReceiver(); - - address public immutable receiver; - IBlockedInboundEscrowReference public immutable escrow; - - mapping(address => bool) public claimOperators; - mapping(address => bool) public rerouteOperators; - bool public originatorSelfClaimEnabled; - - constructor(address receiver_, address escrow_) { - receiver = receiver_; - escrow = IBlockedInboundEscrowReference(escrow_); - } - - function setClaimOperator(address operator, bool allowed) external { - if (msg.sender != receiver) revert Unauthorized(); - claimOperators[operator] = allowed; - } - - function setRerouteOperator(address operator, bool allowed) external { - if (msg.sender != receiver) revert Unauthorized(); - rerouteOperators[operator] = allowed; - } - - function setOriginatorSelfClaimEnabled(bool enabled) external { - if (msg.sender != receiver) revert Unauthorized(); - originatorSelfClaimEnabled = enabled; - } - function claimToReceiver( + function storeBlocked( address token, - IBlockedInboundEscrowReference.ClaimReceipt calldata receipt - ) external { - if (msg.sender != receiver && !claimOperators[msg.sender]) revert Unauthorized(); - escrow.claimBlockedReceipt(token, receiver, address(this), receipt, receiver); - } - - function claimTo( - address token, - IBlockedInboundEscrowReference.ClaimReceipt calldata receipt, - address to - ) external { - if (msg.sender != receiver && !rerouteOperators[msg.sender]) revert Unauthorized(); - if (to == receiver) revert UseClaimToReceiver(); - escrow.claimBlockedReceipt(token, receiver, address(this), receipt, to); - } - - function claimOwnReceipt( - address token, - IBlockedInboundEscrowReference.ClaimReceipt calldata receipt - ) external { - if (!originatorSelfClaimEnabled) revert OriginatorSelfClaimDisabled(); - if (receipt.originator != msg.sender) revert Unauthorized(); - - escrow.claimBlockedReceipt(token, receiver, address(this), receipt, msg.sender); - } + address originator, + address receiver, + address requestedRecipient, + address recoveryContract, + uint256 amount, + BlockedReason blockedReason, + InboundKind kind, + bytes32 memo + ) external returns (uint64 blockedNonce, uint64 blockedAt); } ``` -This reference design makes `receiver` the only configuration authority, separates claims back to `receiver` from reroutes to third parties, allows delegated claims without forcing that logic into the protocol, and makes originator self-claim, if enabled, explicit and narrowly scoped to the caller's own authenticated receipt witness. Receivers that need stronger policy MAY replace it with a multisig, smart wallet, timelock, or custom contract; if they do, that contract is responsible for any delegation, batching, spending-policy, or approval logic beyond the protocol's direct claimer checks. - -1. **TIP-20 transfer and mint flows add a receiver-side check**: - - Resolve TIP-1022 addresses - - Run existing issuer-side checks - - Call `verifyAddressInbound(...)` - - If authorized: credit the receiver - - If blocked: credit `ESCROW_ADDRESS` and record a receipt +`storeBlocked(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. From 2089fbb603cda5aa69c427b219dec38df0841d64 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 08:50:08 -0400 Subject: [PATCH 27/59] docs: wording --- tips/tip-1028.md | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index c737dfc666..c63c17adcf 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -176,22 +176,9 @@ A receive policy points at an existing TIP-403 policy through `receivePolicyId`. ## Receive Policy Evaluation -`validateReceivePolicy(token, sender, receiver)` evaluates the receiver's configuration and returns whether the inbound is allowed. +`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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. -It MUST: - -1. Read `addressReceiveConfig[receiver]`. -2. If `hasReceivePolicy == 0`, return `(true, NONE)`. -3. Otherwise, decode `receivePolicyId`, `receivePolicyType`, `tokenFilterId`, and `tokenFilterType`. -4. Evaluate `token` against the token filter. -5. Evaluate `sender` against the receive policy. -6. Return: - - `(true, NONE)` if both checks pass. - - `(false, TOKEN_FILTER)` if only the token filter check fails. - - `(false, RECEIVE_POLICY)` if only the receive policy check fails. - - `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` if both fail. - -`BlockedReason` classifies why an inbound was blocked: +The `BlockedReason` values used in the second return slot are: ```solidity enum BlockedReason { @@ -202,7 +189,7 @@ enum BlockedReason { } ``` -`NONE` is used only when the inbound is allowed. Blocked events MUST NOT use `NONE`. +`NONE` is used only when the call is allowed. Blocked events MUST NOT use `NONE`. ## Escrow Precompile @@ -214,19 +201,17 @@ The escrow precompile is a new system precompile that holds blocked funds and re address constant ESCROW_ADDRESS = 0xE5C0000000000000000000000000000000000000; ``` -`ESCROW_ADDRESS` is the address of the escrow precompile. The blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot. +The blocked balance for each TIP-20 token sits in that token's `balances[ESCROW_ADDRESS]` slot. ### Restrictions on `ESCROW_ADDRESS` -`ESCROW_ADDRESS` is a system precompile, not an account. It is only credited when the TIP-20 inbound dispatch escrows a blocked transfer or mint, and only debited when `claimBlocked(...)` releases a receipt. The following restrictions apply: +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` and any `recoveryContract == ESCROW_ADDRESS`. - Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. -Funds reach `balances[ESCROW_ADDRESS]` only when the TIP-20 inbound dispatch decides to escrow a blocked transfer or mint, and leave it only through `claimBlocked(...)`. - The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly 250,000 gas to the first blocked transfer or mint. TIP-20 implementations SHOULD move that cost to deployment by initializing the slot when the token is created. One acceptable pattern is an implementation-private escrow reserve created at deployment that is not claimable by users or recovery contracts and is preserved by release and burn logic. ### Escrow Model From 5e8b4fd45a8afad96b52cd2241ec9a75d37c5c85 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 08:56:13 -0400 Subject: [PATCH 28/59] docs: reorder section --- tips/tip-1028.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index c63c17adcf..56fff26078 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -307,8 +307,6 @@ sequenceDiagram TIP20-->>Destination: transfer funds ``` -## Invariants - ## Events and Errors ### Receive Policy Events @@ -467,14 +465,6 @@ interface ITokenFilters { } ``` -Batch update semantics: - -- `tokens.length` and the boolean array length MUST match. -- Caller authorization and policy-type checks are identical to the single-entry mutation functions. -- Updates MUST apply in order. -- The call MUST be atomic. -- The implementation MUST emit the per-token update event once for each touched token, not a separate batch-only event. - ### Escrow Interface ```solidity @@ -524,4 +514,4 @@ interface IBlockedInboundEscrow { } ``` -`storeBlocked(...)` MUST be callable only by TIP-20 precompiles or protocol-internal system code. +## Invariants From 4d8de8e7b5d10bb1c8d504730ec3d54e65fb42b4 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 11:25:35 -0400 Subject: [PATCH 29/59] docs: clarify revert on failure --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 56fff26078..d64d04b17d 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -36,7 +36,7 @@ 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. If allowed, the transfer proceeds as normal. If blocked, the funds are sent to the escrow precompile and a receipt is recorded. +- 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. From e57d10dc61acdb3132a6dcc7753a833da6194ba0 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 11:26:24 -0400 Subject: [PATCH 30/59] docs: update authors --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index d64d04b17d..2a679cbffe 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -2,7 +2,7 @@ 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 +authors: Mallesh Pai, 0xrusowsky, 0xKitsune status: Draft related: TIP-403, TIP-1015, TIP-20, TIP-1016, TIP-1022 protocolVersion: TBD From 13b7f07e66a8617498f2f71a91b88ab6bb40e2cb Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 11:31:14 -0400 Subject: [PATCH 31/59] docs: move sender policies --- tips/tip-1028.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 2a679cbffe..4ba0896ef3 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -134,6 +134,12 @@ Constraints on `setReceivePolicy(...)`: - `recoveryContract` MAY be `address(0)`. If nonzero, it MUST NOT equal `ESCROW_ADDRESS` and MUST NOT be a TIP-1022 virtual address. - The caller MUST NOT be a TIP-1022 virtual address. Virtual addresses are forwarding aliases and configure receive policies on their resolved master address. +## Sender Policies + +A receive policy points at an existing TIP-403 policy through `receivePolicyId`. 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 `receivePolicyId`. 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 control which TIP-20 tokens an address accepts. A receive policy references a token filter by `tokenFilterId`. @@ -168,12 +174,6 @@ Token filters must satisfy the following: `isTokenAllowed(tokenFilterId, token)` returns `false` for filter `0`, `true` for filter `1`, and otherwise reads the stored membership bit and applies it according to the filter's `filterType`. -## Sender Policies - -A receive policy points at an existing TIP-403 policy through `receivePolicyId`. 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 `receivePolicyId`. 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. - ## 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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. From fbba6b9d4e37c2e958e1ad2ff6aaf39540275ff4 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 11:35:46 -0400 Subject: [PATCH 32/59] docs: clarify receipt data --- tips/tip-1028.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 4ba0896ef3..5268af4311 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -216,9 +216,9 @@ The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can ad ### 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. The receipt keeps enough identity to recover the original sender and recipient, attribute the blocked transfer or mint, and authorize a later claim. +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 the receipt fields. The escrow precompile only stores the keyed amount. The other receipt fields are emitted in the blocked event when the receipt is created, and the claimer supplies them again at claim time as a witness. +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 From 6ebaab786e717f99fd89c87530889f9b97364ab8 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 13:14:48 -0400 Subject: [PATCH 33/59] docs: invariants --- tips/tip-1028.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 5268af4311..ea12ad31ba 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -515,3 +515,5 @@ interface IBlockedInboundEscrow { ``` ## Invariants + +- For every TIP-20 token, `balances[ESCROW_ADDRESS]` equals exactly the sum of `blockedReceiptAmount[receiptKey]` over all open receipts for that token. From e05f9de5bd2124daa617624c96dbd50167d3e177 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Fri, 1 May 2026 13:21:48 -0400 Subject: [PATCH 34/59] fmt: spacing --- tips/tip-1028.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index ea12ad31ba..5bf9e1d412 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -10,6 +10,8 @@ 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. @@ -20,6 +22,8 @@ The receiver or a designated recovery contract can later claim these funds. Clai 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. Some applications require receivers to restrict incoming funds based on the sender or the token. @@ -30,6 +34,8 @@ TIP-1028 lets receivers filter incoming transfers without causing those transfer The receiver (or a recovery contract) 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: @@ -60,6 +66,8 @@ sequenceDiagram TIP20-->>Receiver: transfer funds ``` +
+ ## TIP-20 Operations TIP-1028 applies to the following TIP-20 operations: `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, `systemTransferFrom`, `mint`, `mintWithMemo`. @@ -78,6 +86,8 @@ If `to` is a TIP-1022 virtual address, it is resolved to its master address befo 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: @@ -134,12 +144,16 @@ Constraints on `setReceivePolicy(...)`: - `recoveryContract` MAY be `address(0)`. If nonzero, it MUST NOT equal `ESCROW_ADDRESS` and MUST NOT be a TIP-1022 virtual address. - The caller MUST NOT be a TIP-1022 virtual address. Virtual addresses are forwarding aliases and configure receive policies on their resolved master address. +
+ ## Sender Policies A receive policy points at an existing TIP-403 policy through `receivePolicyId`. 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 `receivePolicyId`. 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 control which TIP-20 tokens an address accepts. A receive policy references a token filter by `tokenFilterId`. @@ -174,6 +188,8 @@ Token filters must satisfy the following: `isTokenAllowed(tokenFilterId, token)` returns `false` for filter `0`, `true` for filter `1`, and otherwise reads the stored membership bit and applies it according to the filter's `filterType`. +
+ ## 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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. @@ -191,6 +207,8 @@ enum BlockedReason { `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. @@ -307,6 +325,8 @@ sequenceDiagram TIP20-->>Destination: transfer funds ``` +
+ ## Events and Errors ### Receive Policy Events @@ -392,6 +412,8 @@ error InsufficientEscrowBalance(); A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the consumed receipt. +
+ ## Interfaces ### TIP-403 Receive Policy Interface @@ -514,6 +536,8 @@ interface IBlockedInboundEscrow { } ``` +
+ ## Invariants - For every TIP-20 token, `balances[ESCROW_ADDRESS]` equals exactly the sum of `blockedReceiptAmount[receiptKey]` over all open receipts for that token. From 93ed89d8a3d6f63538276878faeb8f647e253378 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:09:42 -0400 Subject: [PATCH 35/59] docs: wording Co-authored-by: malleshpai --- tips/tip-1028.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 5bf9e1d412..965b03efa2 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -26,7 +26,11 @@ TIP-403 authorization checks are unchanged and continue to revert on failure. TI ## 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. Some applications require receivers to restrict incoming funds based on the sender or the token. +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. From 898f6979c412b1178a9176850badb5af1a92aab6 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:12:40 -0400 Subject: [PATCH 36/59] docs: clarify recovery contract semantics Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 965b03efa2..3bb2586463 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -36,7 +36,7 @@ If a receiver rejects transfers, sending them tokens will fail. This can cause s 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) can claim those funds later. Each escrow entry keeps enough information to identify the original transfer, so it can be handled correctly offchain. +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.
From 5fca57925707906600835cd654cd684ab98e82e3 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:14:06 -0400 Subject: [PATCH 37/59] docs: wording Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 3bb2586463..48bef07708 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -103,7 +103,7 @@ Receive policies are stored per address in the TIP-403 registry. Each address ca - A TIP-403 policy (`receivePolicyId`) that defines which senders are allowed. - A token filter (`tokenFilterId`) that defines which TIP-20 tokens are allowed. -- An optional `recoveryContract` that can claim blocked funds. If not set, the receiver claims directly. +- An optional `recoveryContract` that can claim blocked funds. If not set, only the receiver can claim directly. If no receive policy is set, all transfers and mints are allowed. From e47fbefa897cc1eff45f2542c6d6903c18b6ba5f Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:14:23 -0400 Subject: [PATCH 38/59] docs: wording Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 48bef07708..f10e690fee 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -86,7 +86,7 @@ Note that TIP-1028 only applies to the TIP-20 operations listed above. It does n ### 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. +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. From 49843aaeaeca0ee0ae330af63c8709a0e2092f58 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:14:50 -0400 Subject: [PATCH 39/59] docs: wording Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index f10e690fee..f12a9b68ef 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -146,7 +146,7 @@ Constraints on `setReceivePolicy(...)`: - `receivePolicyId` 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 an existing token filter, or built-in `0` (reject all) or `1` (allow all). - `recoveryContract` MAY be `address(0)`. If nonzero, it MUST NOT equal `ESCROW_ADDRESS` and MUST NOT be a TIP-1022 virtual address. -- The caller MUST NOT be a TIP-1022 virtual address. Virtual addresses are forwarding aliases and configure receive policies on their resolved master address. +- 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.
From 8436436ca3649a167ac90d394556ebcb8adfc283 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:15:09 -0400 Subject: [PATCH 40/59] docs: wording Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index f12a9b68ef..8708200c9a 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -308,7 +308,7 @@ Each receipt is governed by the `recoveryContract` captured for that receipt at The destination `to` decides whether the claim is an **unwind** back to the receiver or a **reroute** to a new address. The two cases differ only in what they enforce on the destination side. -When `to == receiver`, the claim is an unwind of 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 transfer-recipient authorization. +When `to == receiver`, the claim is an unwind of 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 receiver. The reroute MUST reject `to == ESCROW_ADDRESS` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. From 11da04c4e8e8e30234491f7237e9da5cba4e80e8 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Fri, 1 May 2026 16:18:00 -0400 Subject: [PATCH 41/59] docs: fix wording Co-authored-by: malleshpai --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 8708200c9a..b5c9843d52 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -310,7 +310,7 @@ The destination `to` decides whether the claim is an **unwind** back to the rece When `to == receiver`, the claim is an unwind of 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 receiver. The reroute MUST reject `to == ESCROW_ADDRESS` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. +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` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. From 8030eb7f9d675066761b6dd8431d27abb22cd988 Mon Sep 17 00:00:00 2001 From: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> Date: Sat, 2 May 2026 12:22:45 -0400 Subject: [PATCH 42/59] docs: formatting Co-authored-by: samczsun --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index b5c9843d52..c52e4ccacf 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -29,7 +29,7 @@ TIP-403 authorization checks are unchanged and continue to revert on failure. TI 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. +- 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. From 45e8f40976468d9980856d3af87d7753b3f92c3a Mon Sep 17 00:00:00 2001 From: 0xkitsune <0xkitsune@protonmail.com> Date: Sun, 3 May 2026 02:02:57 -0400 Subject: [PATCH 43/59] docs: clarify storage semantics --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index c52e4ccacf..eb78743835 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -298,7 +298,7 @@ When `validateReceivePolicy(...)` returns blocked, the TIP-20 path credits `ESCR ### Claiming -A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, recomputes `receiptKey`, requires `blockedReceiptAmount[receiptKey] > 0`, deletes the slot, and releases the stored amount. Partial claims are not allowed. Claims MUST consume whole receipts. +A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, 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)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. From cd537c84c8691457a2ba382127a9aa1ab4005c16 Mon Sep 17 00:00:00 2001 From: 0xkitsune <0xkitsune@protonmail.com> Date: Sun, 3 May 2026 02:05:33 -0400 Subject: [PATCH 44/59] docs: update unwind --- tips/tip-1028.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index eb78743835..02526f7a67 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -18,7 +18,7 @@ TIP-1028 extends TIP-403 with address-level receive policies, letting a receiver 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 unwind the original inbound while claims to other addresses are treated as new transfers. +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. @@ -306,9 +306,9 @@ The receipt witness only tells the escrow which receipt to consume. It does not 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 is an **unwind** back to the receiver or a **reroute** to a new address. The two cases differ only in what they enforce on the destination side. +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 is an unwind of 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 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` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. From f5a7f90a78b99e0086b39df2fbda221b31a4d5f2 Mon Sep 17 00:00:00 2001 From: 0xkitsune <0xkitsune@protonmail.com> Date: Sun, 3 May 2026 02:35:55 -0400 Subject: [PATCH 45/59] docs: update gas --- tips/tip-1028.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 02526f7a67..7507f51a73 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -28,10 +28,9 @@ TIP-403 authorization checks are unchanged and continue to revert on failure. TI 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, +- 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. @@ -196,7 +195,7 @@ Token filters must satisfy the following: ## 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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. +`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 or token filter (`hasReceiveConfig == 0`), it returns `(true, NONE)`. Otherwise it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. The `BlockedReason` values used in the second return slot are: @@ -234,7 +233,7 @@ The following restrictions apply: - `setReceivePolicy(...)` MUST reject `account == ESCROW_ADDRESS` and any `recoveryContract == ESCROW_ADDRESS`. - Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. -The first zero-to-nonzero write to `balances[ESCROW_ADDRESS]` for a token can add roughly 250,000 gas to the first blocked transfer or mint. TIP-20 implementations SHOULD move that cost to deployment by initializing the slot when the token is created. One acceptable pattern is an implementation-private escrow reserve created at deployment that is not claimable by users or recovery contracts and is preserved by release and burn logic. +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 From d4432aad9ff73e43ffa97363bc4331737adfc82a Mon Sep 17 00:00:00 2001 From: 0xkitsune <0xkitsune@protonmail.com> Date: Sun, 3 May 2026 02:39:59 -0400 Subject: [PATCH 46/59] docs: update requestedRecepient to recepient --- tips/tip-1028.md | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 7507f51a73..9f4ba6fbc5 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -256,9 +256,8 @@ receiptKey = keccak256( abi.encode( receiptVersion, token, - receiver, originator, - requestedRecipient, + recipient, recoveryContract, blockedReason, kind, @@ -273,9 +272,8 @@ where: - `receiptVersion`: one-byte bucketing tag. MUST be `1` for receipts created under this TIP. Future receipt-key formats MUST use a different value. - `token`: the TIP-20 token whose balance ledger holds the blocked amount at `ESCROW_ADDRESS`. -- `receiver`: the canonical TIP-20 holder that owns the receipt. For TIP-1022 inbounds this is the resolved master address. -- `originator`: `from` for transfers, `msg.sender` for mints. -- `requestedRecipient`: the literal `to` supplied at the TIP-20 entrypoint. For non-virtual inbounds, `requestedRecipient == receiver`. +- `originator`: `from` for transfers, `address(0)` 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`, `RECEIVE_POLICY`, or `TOKEN_FILTER_AND_RECEIVE_POLICY`. - `kind`: `TRANSFER` or `MINT`. @@ -285,7 +283,7 @@ where: `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 `requestedRecipient` 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. Persistent state stays minimal: one keyed amount per receipt. The richer fields live in the witness and the blocked event. +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. @@ -297,7 +295,7 @@ When `validateReceivePolicy(...)` returns blocked, the TIP-20 path credits `ESCR ### Claiming -A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, 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. +A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, 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)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. @@ -362,7 +360,7 @@ event TransferBlocked( uint8 receiptVersion, uint64 blockedNonce, uint64 blockedAt, - address requestedRecipient, + address recipient, uint256 amount, BlockedReason blockedReason, address recoveryContract, @@ -376,7 +374,7 @@ event MintBlocked( uint8 receiptVersion, uint64 blockedNonce, uint64 blockedAt, - address requestedRecipient, + address recipient, uint256 amount, BlockedReason blockedReason, address recoveryContract, @@ -390,7 +388,7 @@ event BlockedReceiptClaimed( uint64 indexed blockedNonce, uint64 blockedAt, address originator, - address requestedRecipient, + address recipient, address recoveryContract, address caller, address to, @@ -502,7 +500,7 @@ interface IBlockedInboundEscrow { struct ClaimReceipt { uint8 receiptVersion; address originator; - address requestedRecipient; + address recipient; uint64 blockedAt; uint64 blockedNonce; BlockedReason blockedReason; @@ -512,14 +510,12 @@ interface IBlockedInboundEscrow { function blockedReceiptBalance( address token, - address receiver, address recoveryContract, ClaimReceipt calldata receipt ) external view returns (uint256 amount); function claimBlocked( address token, - address receiver, address recoveryContract, ClaimReceipt calldata receipt, address to @@ -529,7 +525,7 @@ interface IBlockedInboundEscrow { address token, address originator, address receiver, - address requestedRecipient, + address recipient, address recoveryContract, uint256 amount, BlockedReason blockedReason, From 98cc0611f5bf49f0debb9070cffc25f5e6f29f59 Mon Sep 17 00:00:00 2001 From: 0xkitsune <0xkitsune@protonmail.com> Date: Sun, 3 May 2026 15:18:08 -0400 Subject: [PATCH 47/59] docs: removed recovery address constraint --- tips/tip-1028.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 9f4ba6fbc5..dfaca9e46c 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -144,7 +144,6 @@ Constraints on `setReceivePolicy(...)`: - `receivePolicyId` 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 an existing token filter, or built-in `0` (reject all) or `1` (allow all). -- `recoveryContract` MAY be `address(0)`. If nonzero, it MUST NOT equal `ESCROW_ADDRESS` and MUST NOT be a TIP-1022 virtual address. - 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.
@@ -230,7 +229,7 @@ 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` and any `recoveryContract == ESCROW_ADDRESS`. +- `setReceivePolicy(...)` MUST reject `account == ESCROW_ADDRESS`. - Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `ESCROW_ADDRESS`. 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. From 0c592550a534b6cd681e5c951b896bd268039645 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xkitsune@protonmail.com> Date: Mon, 4 May 2026 00:08:38 -0400 Subject: [PATCH 48/59] docs: update policy variable names --- tips/tip-1028.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index dfaca9e46c..e70bcbfff8 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -98,11 +98,17 @@ A receive policy defines which transfers and mints an address accepts. It contro - Which TIP-20 tokens are allowed. - Which senders are allowed. -Receive policies are stored per address in the TIP-403 registry. Each address can configure: +Receive policies are stored per address in the TIP-403 registry. Conceptually, each account has: -- A TIP-403 policy (`receivePolicyId`) that defines which senders are allowed. -- A token filter (`tokenFilterId`) that defines which TIP-20 tokens are allowed. -- An optional `recoveryContract` that can claim blocked funds. If not set, only the receiver can claim directly. +```text +ReceivePolicy(account) = ( + senderPolicyId, // TIP-403 policy ref indicating which senders are allowed + tokenFilterId, // token filter 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. @@ -126,8 +132,8 @@ mapping(address => address) public addressRecoveryContract; | Bits | Size | Field | |---|---:|---| | `0` | 1 | `hasReceivePolicy` | -| `1..64` | 64 | `receivePolicyId` | -| `65..72` | 8 | `receivePolicyType` | +| `1..64` | 64 | `senderPolicyId` | +| `65..72` | 8 | `senderPolicyType` | | `73..136` | 64 | `tokenFilterId` | | `137..144` | 8 | `tokenFilterType` | | `145..255` | 111 | reserved, MUST be zero | @@ -138,11 +144,11 @@ When `hasReceivePolicy == 0`, the address has no receive policy and all transfer `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 `receivePolicyId = 1` and `tokenFilterId = 1`. The slot remains allocated. +An address that wants to functionally disable filtering SHOULD set `senderPolicyId = 1` and `tokenFilterId = 1`. The slot remains allocated. Constraints on `setReceivePolicy(...)`: -- `receivePolicyId` MUST reference a simple `WHITELIST` or `BLACKLIST` TIP-403 policy, or built-in policy `0` or `1`. `COMPOUND` policies are not valid. +- `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 an existing token filter, or built-in `0` (reject all) or `1` (allow all). - 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. @@ -150,9 +156,9 @@ Constraints on `setReceivePolicy(...)`: ## Sender Policies -A receive policy points at an existing TIP-403 policy through `receivePolicyId`. 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). +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 `receivePolicyId`. 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. +`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.
@@ -194,7 +200,7 @@ Token filters must satisfy the following: ## 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 or token filter (`hasReceiveConfig == 0`), it returns `(true, NONE)`. Otherwise it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. +`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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. The `BlockedReason` values used in the second return slot are: @@ -334,7 +340,7 @@ sequenceDiagram ```solidity event ReceivePolicyUpdated( address indexed account, - uint64 receivePolicyId, + uint64 senderPolicyId, uint64 tokenFilterId, address recoveryContract ); @@ -421,7 +427,7 @@ A successful claim MUST emit exactly one `BlockedReceiptClaimed` event for the c ```solidity interface IReceivePolicies { function setReceivePolicy( - uint64 receivePolicyId, + uint64 senderPolicyId, uint64 tokenFilterId, address recoveryContract ) external; @@ -431,8 +437,8 @@ interface IReceivePolicies { view returns ( bool hasReceivePolicy, - uint64 receivePolicyId, - PolicyType receivePolicyType, + uint64 senderPolicyId, + PolicyType senderPolicyType, uint64 tokenFilterId, PolicyType tokenFilterType, address recoveryContract From 1b99d3c3add60e821ef2e143e58211cf0e2cc0d2 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xkitsune@protonmail.com> Date: Mon, 4 May 2026 00:23:53 -0400 Subject: [PATCH 49/59] docs: remove version from receipt hash --- tips/tip-1028.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index e70bcbfff8..117ed0c488 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -254,7 +254,21 @@ uint64 public blockedReceiptNonce = 1; mapping(bytes32 => uint256) internal blockedReceiptAmount; ``` -The persistent escrow key for a blocked transfer or mint is: +The persistent escrow key for a blocked transfer or mint is versioned. `receiptVersion` is an outer discriminator, and the version-specific receipt body is encoded separately. For version `1`, the receipt body is: + +```solidity +struct ClaimReceiptV1 { + address originator; + address recipient; + uint64 blockedAt; + uint64 blockedNonce; + BlockedReason blockedReason; + InboundKind kind; + bytes32 memo; +} +``` + +The version `1` persistent escrow key is: ```text receiptKey = keccak256( @@ -275,7 +289,7 @@ receiptKey = keccak256( where: -- `receiptVersion`: one-byte bucketing tag. MUST be `1` for receipts created under this TIP. Future receipt-key formats MUST use a different value. +- `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, `address(0)` 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. @@ -300,7 +314,7 @@ When `validateReceivePolicy(...)` returns blocked, the TIP-20 path credits `ESCR ### Claiming -A claim consumes one full receipt and releases the funds to a single destination. `claimBlocked(...)` takes the receipt witness and a destination `to`, 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. +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)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. @@ -502,8 +516,7 @@ interface IBlockedInboundEscrow { MINT } - struct ClaimReceipt { - uint8 receiptVersion; + struct ClaimReceiptV1 { address originator; address recipient; uint64 blockedAt; @@ -516,13 +529,15 @@ interface IBlockedInboundEscrow { function blockedReceiptBalance( address token, address recoveryContract, - ClaimReceipt calldata receipt + uint8 receiptVersion, + bytes calldata receipt ) external view returns (uint256 amount); function claimBlocked( address token, address recoveryContract, - ClaimReceipt calldata receipt, + uint8 receiptVersion, + bytes calldata receipt, address to ) external; From 7abe64662920125558023ddd45eae4e488fdcecb Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xkitsune@protonmail.com> Date: Mon, 4 May 2026 00:25:23 -0400 Subject: [PATCH 50/59] docs: allow virtual addresses in claim --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 117ed0c488..8510f2bea1 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -326,7 +326,7 @@ The destination `to` decides whether the claim **resumes** the original transfer 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` and TIP-1022 virtual addresses, enforce token-level transfer-recipient authorization for `to`, and enforce `to`'s receive policy against the consumed receipt's `originator`. If either destination check fails, the call MUST revert with `ClaimDestinationUnauthorized()`. 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. +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. From 88c85b2805c9befc4ac3fa890fdc54c210b2f242 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xkitsune@protonmail.com> Date: Mon, 4 May 2026 00:55:51 -0400 Subject: [PATCH 51/59] docs: update blocked reason --- tips/tip-1028.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 8510f2bea1..87971a416a 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -200,7 +200,15 @@ Token filters must satisfy the following: ## 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 it checks `token` against the receiver's token filter and `sender` against the receiver's receive policy, and returns one of `(true, NONE)` when both pass, `(false, TOKEN_FILTER)` when only the token filter rejects, `(false, RECEIVE_POLICY)` when only the receive policy rejects, or `(false, TOKEN_FILTER_AND_RECEIVE_POLICY)` when both reject. +`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: @@ -208,8 +216,7 @@ The `BlockedReason` values used in the second return slot are: enum BlockedReason { NONE, TOKEN_FILTER, - RECEIVE_POLICY, - TOKEN_FILTER_AND_RECEIVE_POLICY + RECEIVE_POLICY } ``` @@ -294,7 +301,7 @@ where: - `originator`: `from` for transfers, `address(0)` 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`, `RECEIVE_POLICY`, or `TOKEN_FILTER_AND_RECEIVE_POLICY`. +- `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. From f361c33b41f2f799e5ca787ea10bb80e441344cf Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Mon, 4 May 2026 23:08:29 -0400 Subject: [PATCH 52/59] docs: updated mint originator --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 87971a416a..5281dc3f67 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -298,7 +298,7 @@ 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, `address(0)` for mints. +- `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`. From f9270a69e7259519b2ba66742b4ae3d1719f4198 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Mon, 4 May 2026 23:18:49 -0400 Subject: [PATCH 53/59] docs: update sequence diagram --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 5281dc3f67..d2e5b2a819 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -51,11 +51,11 @@ The sequence diagram below shows the high level flow for a transfer that is bloc ```mermaid sequenceDiagram - participant Receiver participant Sender participant TIP20 participant TIP403 participant Escrow + participant Receiver Receiver->>TIP403: setReceivePolicy(...) Sender->>TIP20: transfer(receiver, amount) From 3fe294a9ef12b7b51bf9cd310545c3275eda1e64 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Tue, 5 May 2026 00:03:52 -0400 Subject: [PATCH 54/59] docs: simplify token lists --- tips/tip-1028.md | 86 ++++-------------------------------------------- 1 file changed, 6 insertions(+), 80 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index d2e5b2a819..b94b9fde84 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -103,7 +103,7 @@ Receive policies are stored per address in the TIP-403 registry. Conceptually, e ```text ReceivePolicy(account) = ( senderPolicyId, // TIP-403 policy ref indicating which senders are allowed - tokenFilterId, // token filter indicating which TIP-20 tokens are allowed + tokenFilterId, // TIP-403 policy ref indicating which TIP-20 tokens are allowed recoveryContract ) ``` @@ -149,7 +149,7 @@ An address that wants to functionally disable filtering SHOULD set `senderPolicy 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 an existing token filter, or built-in `0` (reject all) or `1` (allow all). +- `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.
@@ -164,37 +164,9 @@ A receive policy points at an existing TIP-403 policy through `senderPolicyId`. ## Token Filters -Token filters control which TIP-20 tokens an address accepts. A receive policy references a token filter by `tokenFilterId`. +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. -A token filter is a list of token addresses with either allowlist or denylist semantics: - -- an allowlist filter allows only the listed tokens -- a denylist filter blocks the listed tokens - -This is separate from TIP-403 address policies, which filter by sender. Token filters operate on the token itself. - -```solidity -uint64 public tokenFilterIdCounter = 2; // 0 = reject all, 1 = allow all - -struct TokenFilterData { - PolicyType filterType; // WHITELIST or BLACKLIST - address admin; -} - -mapping(uint64 => TokenFilterData) internal _tokenFilterData; -mapping(uint64 => mapping(address => bool)) internal tokenFilterMembers; -``` - -Token filters are identified by an ID. `0` rejects all tokens and `1` allows all tokens. New filters are assigned incrementing IDs starting from `2`. - -Token filters must satisfy the following: - -- `filterType` is `WHITELIST` or `BLACKLIST`. -- `COMPOUND` token filters are forbidden. -- Filter type is immutable after creation. -- Membership is mutable by the filter admin. - -`isTokenAllowed(tokenFilterId, token)` returns `false` for filter `0`, `true` for filter `1`, and otherwise reads the stored membership bit and applies it according to the filter's `filterType`. +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.
@@ -369,12 +341,7 @@ event ReceivePolicyUpdated( ### Token Filter Events -```solidity -event TokenFilterCreated(uint64 indexed tokenFilterId, address indexed creator, PolicyType filterType); -event TokenFilterAdminUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed admin); -event TokenFilterWhitelistUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed token, bool allowed); -event TokenFilterBlacklistUpdated(uint64 indexed tokenFilterId, address indexed updater, address indexed token, bool restricted); -``` +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 @@ -427,9 +394,6 @@ event BlockedReceiptClaimed( ```solidity error InvalidReceivePolicyType(); error InvalidRecoveryContract(); -error TokenFilterNotFound(); -error InvalidTokenFilterType(); -error TokenFilterBatchLengthMismatch(); error EscrowAddressReserved(); error UnauthorizedClaimer(); error InvalidReceiptClaim(); @@ -474,45 +438,7 @@ interface IReceivePolicies { Implementations SHOULD read `addressRecoveryContract[receiver]` only after `validateReceivePolicy(...)` returns `authorized = false`. -### TIP-403 Token Filter Interface - -```solidity -interface ITokenFilters { - function createTokenFilter(address admin, PolicyType filterType) - external - returns (uint64 newTokenFilterId); - - function createTokenFilterWithTokens( - address admin, - PolicyType filterType, - address[] calldata tokens - ) external returns (uint64 newTokenFilterId); - - function setTokenFilterAdmin(uint64 tokenFilterId, address admin) external; - - function modifyTokenFilterWhitelist(uint64 tokenFilterId, address token, bool allowed) external; - function modifyTokenFilterBlacklist(uint64 tokenFilterId, address token, bool restricted) external; - - function modifyTokenFilterWhitelistBatch( - uint64 tokenFilterId, - address[] calldata tokens, - bool[] calldata allowed - ) external; - - function modifyTokenFilterBlacklistBatch( - uint64 tokenFilterId, - address[] calldata tokens, - bool[] calldata restricted - ) external; - - function isTokenAllowed(uint64 tokenFilterId, address token) external view returns (bool); - function tokenFilterExists(uint64 tokenFilterId) external view returns (bool); - function tokenFilterData(uint64 tokenFilterId) - external - view - returns (PolicyType filterType, address admin); -} -``` +Token filters are managed through the existing TIP-403 policy interface. TIP-1028 does not add a dedicated token-filter interface. ### Escrow Interface From 103d59bdadcc76562fdca01544ba0ff4c44d2bf7 Mon Sep 17 00:00:00 2001 From: malleshpai Date: Tue, 5 May 2026 10:03:44 -0400 Subject: [PATCH 55/59] Apply suggestion from @0xrusowsky Co-authored-by: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> --- tips/tip-1028.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index b94b9fde84..dcd6d83ba6 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -493,3 +493,5 @@ interface IBlockedInboundEscrow { ## Invariants - For every TIP-20 token, `balances[ESCROW_ADDRESS]` equals exactly the sum of `blockedReceiptAmount[receiptKey]` over all open receipts for that token. +- Unlike token-level (TIP-403 and TIP-1015) policies, address-level inbound policies CANNOT make transactions revert. +- Only the allowed claimer —address owner (when `recoveryContract = address(0)`) or `recoveryContract`— can claim the funds from the ESCROW precompile From 59bd7ba32e05375a3e89cca9f10634294d0ae1e7 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Tue, 5 May 2026 16:08:48 +0200 Subject: [PATCH 56/59] docs: explicitly mention event flow (#3815) review feedback --------- Co-authored-by: 0xKitsune <0xKitsune@protonmail.com> Co-authored-by: 0xKitsune <77890308+0xKitsune@users.noreply.github.com> --- tips/tip-1028.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index dcd6d83ba6..81d5f2d53f 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -287,7 +287,7 @@ The escrow precompile does not enumerate receipts onchain. Claimers MUST supply ### 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. The TIP-20 path then emits the raw `Transfer` (and `Mint`, for mint paths) event naming `ESCROW_ADDRESS` as the recipient, followed by the matching `TransferBlocked` or `MintBlocked` attribution event with `blockedReason != NONE`. Memo-bearing variants preserve the original memo in the attribution event. +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. @@ -295,7 +295,7 @@ When `validateReceivePolicy(...)` returns blocked, the TIP-20 path credits `ESCR 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)`, bypasses the token-level TIP-403 sender check for `ESCROW_ADDRESS`, and treats `ESCROW_ADDRESS` as a reward-exempt always-opted-out source. +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(...)`. @@ -324,6 +324,8 @@ sequenceDiagram 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 @@ -493,5 +495,3 @@ interface IBlockedInboundEscrow { ## Invariants - For every TIP-20 token, `balances[ESCROW_ADDRESS]` equals exactly the sum of `blockedReceiptAmount[receiptKey]` over all open receipts for that token. -- Unlike token-level (TIP-403 and TIP-1015) policies, address-level inbound policies CANNOT make transactions revert. -- Only the allowed claimer —address owner (when `recoveryContract = address(0)`) or `recoveryContract`— can claim the funds from the ESCROW precompile From c6da60e01e62c29519ff87721483091b23fd7694 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Tue, 5 May 2026 14:18:35 -0400 Subject: [PATCH 57/59] docs: wording --- tips/tip-1028.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 81d5f2d53f..7e6867c37a 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -28,8 +28,8 @@ TIP-403 authorization checks are unchanged and continue to revert on failure. TI 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. +- 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. @@ -215,7 +215,7 @@ 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`. -- Any TIP-20 logic that protects DEX or FeeManager balances as system balances MUST extend the same protection to `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. From 57def69cd9c2cc23a822273f443e5e6b35da046d Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Tue, 5 May 2026 14:35:50 -0400 Subject: [PATCH 58/59] docs: wording --- tips/tip-1028.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 7e6867c37a..700bec3ba8 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -233,7 +233,7 @@ uint64 public blockedReceiptNonce = 1; mapping(bytes32 => uint256) internal blockedReceiptAmount; ``` -The persistent escrow key for a blocked transfer or mint is versioned. `receiptVersion` is an outer discriminator, and the version-specific receipt body is encoded separately. For version `1`, the receipt body is: +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 { @@ -247,7 +247,7 @@ struct ClaimReceiptV1 { } ``` -The version `1` persistent escrow key is: +The v1 `receiptKey` is computed as: ```text receiptKey = keccak256( From 21b3edc0f74c7ea89537d1815144b7c9198c79b5 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Tue, 5 May 2026 14:43:49 -0400 Subject: [PATCH 59/59] docs: formatting, nits --- tips/tip-1028.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1028.md b/tips/tip-1028.md index 700bec3ba8..662c57a923 100644 --- a/tips/tip-1028.md +++ b/tips/tip-1028.md @@ -324,7 +324,7 @@ sequenceDiagram 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. +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.