---
id: TIP-1006
title: Burn At for TIP-20 Tokens
description: The burnAt function for TIP-20 tokens, enabling authorized administrators to burn tokens from any address.
authors: Dan Robinson, Mallesh Pai
status: Backlog
related: TIP-1028, TIP-1034, TIP-1091, TIP-1121
protocolVersion: TBD
---

# TIP-1006: Burn At for TIP-20 Tokens

## Abstract

This specification introduces a `burnAt` function to TIP-20 tokens, allowing holders of a new `BURN_AT_ROLE` to burn tokens from any address without transfer policy restrictions. This complements the existing `burnBlocked` function which is limited to burning from addresses blocked by the transfer policy.

## Motivation

The existing TIP-20 burn mechanisms have the following limitations:

1. `burn()` - Only burns from the caller's own balance, requires `ISSUER_ROLE`
2. `burnBlocked()` - Can burn from other addresses, but only if the target address is blocked by the transfer policy

There are legitimate use cases where token administrators may want a privileged caller to have the ability to burn tokens from any address regardless of their policy status, such as allowing a bridge contract to burn tokens that are being bridged out without requiring approval (as in the `crosschainBurn` function proposed in [ERC 7802](https://github.com/ethereum/ERCs/blob/master/ERCS/erc-7802.md)).

The `burnAt` function provides this capability with appropriate access controls via a dedicated role.

---

# Specification

## New Role

A new role constant is added to TIP-20:

```solidity
bytes32 public constant BURN_AT_ROLE = keccak256("BURN_AT_ROLE");
```

This role is administered by the `DEFAULT_ADMIN_ROLE` (same as other TIP-20 roles).

## New Event

```solidity
/// @notice Emitted when tokens are burned from any account.
/// @param burner The `BURN_AT_ROLE` holder that performed the burn.
/// @param from The address from which tokens were burned.
/// @param amount The amount of tokens burned.
event BurnAt(address indexed burner, address indexed from, uint256 indexed amount);
```

## New Function

```solidity
/// @notice Burns tokens from any account.
/// @dev Requires BURN_AT_ROLE. Cannot burn from protected addresses.
/// @param from The address to burn tokens from.
/// @param amount The amount of tokens to burn.
function burnAt(address from, uint256 amount) external;
```

### Behavior

1. **Pause**: Reverts with `ContractPaused` if the token is paused, matching `burnBlocked`
2. **Access Control**: Reverts with `Unauthorized` if caller does not have `BURN_AT_ROLE`
3. **Zero Amount**: Allows `amount == 0` when all other checks pass, matching `burnBlocked`
4. **Protected Addresses**: Reverts with `ProtectedAddress` if `from` is in the protected set shared with `burnBlocked`. At activation that set is:
   - The token's own address (`address(this)`), matching `burnBlocked`
   - `TIP_FEE_MANAGER_ADDRESS` (0xfeEC000000000000000000000000000000000000)
   - `STABLECOIN_DEX_ADDRESS` (0xDEc0000000000000000000000000000000000000)
   - `TIP20_CHANNEL_RESERVE_ADDRESS` (0x4D50500000000000000000000000000000000000), from [TIP-1034](./tip-1034.md)
   - `RECEIVE_POLICY_GUARD_ADDRESS` (0xB10C000000000000000000000000000000000000), from [TIP-1028](./tip-1028.md)
   - Any Zone portal, identified by the 12-byte address prefix `0x5AD000000000000000000000` from [TIP-1091](./tip-1091.md)

   Later additions to the `burnBlocked` protected set apply to `burnAt` as well.
5. **Balance Check**: Reverts with `InsufficientBalance` if `from` has insufficient balance
6. **No Policy Check**: Unlike `burnBlocked`, this function does NOT check transfer policy authorization
7. **State Changes**:
   - Decrements `balanceOf[from]` by `amount`
   - Decrements `_totalSupply` by `amount`
   - Leaves reward-accounting state unchanged, matching `burnBlocked` at T8 and later
8. **Events**: Emits `Transfer(from, address(0), amount)` and `BurnAt(msg.sender, from, amount)`
9. **Zone Availability**: `burnAt` MUST revert with `Unauthorized` when called on a Zone, matching `burnBlocked` behavior

**Access-key spending limits:** Before burning, `burnAt` MUST call the existing AccountKeychain spending-authorization helper for `(from, address(this), amount)`, using the same semantics as `system_transfer_from`. This enforces spending limits when `from` is the transaction origin and the transaction uses an access key with limits enabled, including existing errors, periodic resets, and `AccessKeySpend` events. Any deduction MUST revert together with the burn.

This check is needed for contemplated uses of `burnAt`, such as bridges that burn tokens directly from their caller. When a user invokes such a bridge in an access-key transaction, the bridge's `BURN_AT_ROLE` must not let the access key bypass the user's token spending limit. Charging the limit against `from` preserves the same protection as approval-free transfers, even though the bridge is the immediate caller of `burnAt`.

### Interface Addition

The `ITIP20` interface is extended with:

```solidity
/// @notice Returns the role identifier for burning tokens from any account.
/// @return The burn-at role identifier.
function BURN_AT_ROLE() external view returns (bytes32);

/// @notice Burns tokens from any account.
/// @param from The address to burn tokens from.
/// @param amount The amount of tokens to burn.
function burnAt(address from, uint256 amount) external;
```

# Threat Model

> **Warning:** `BURN_AT_ROLE` lets its holder destroy any holder's balance, with no approval from the holder and no limit on amount. Grant it only to tightly scoped contracts (for example, a bridge that burns only amounts the user has asked to bridge out) and never to an externally owned key used for day-to-day operations. A bug or compromised key in a role holder becomes the ability to burn from every unprotected account. Pausing the token stops `burnAt`.

Today, burning from a holder who is not blocked takes two steps: the policy admin blocks the holder in the transfer policy, which is visible on-chain, and then a `BURN_BLOCKED_ROLE` holder calls `burnBlocked`. `burnAt` removes that separation. An account holding both `BURN_AT_ROLE` and `ISSUER_ROLE` can burn from any holder and mint the same amount to itself in one step. Issuers who rely on the two-step process for internal controls should keep these roles on separate accounts.

## Protected Addresses

`burnAt` cannot reach tokens held at the protected addresses, including user deposits and tokens backing existing claims:

| Address | What it holds | How the issuer can act on it instead |
|---|---|---|
| The token's own address (`address(this)`) | Tokens backing outstanding settled reward claims | None through `burnAt` |
| `TIP_FEE_MANAGER_ADDRESS` | Collected fees awaiting distribution and Fee AMM pool liquidity | None through `burnAt` |
| `STABLECOIN_DEX_ADDRESS` | Resting orders and internal DEX balances of all users | None through `burnAt` |
| `TIP20_CHANNEL_RESERVE_ADDRESS` | Deposits backing open payment channels | None through `burnAt` |
| `RECEIVE_POLICY_GUARD_ADDRESS` | Inbound transfers held back by a receive policy | Burn a specific receipt through `ReceivePolicyGuard` ([TIP-1028](./tip-1028.md)) |
| Zone portals (`0x5AD000000000000000000000` prefix) | Deposits backing balances inside a Zone | None through `burnAt` |

Burning directly from any of these would break the accounting of the contract that holds the funds, because it tracks what it owes each user separately from the pooled balance.

# Invariants

These properties must hold in every reachable state and across every sequence of calls, not only after a single `burnAt`. The per-call rules are in [Behavior](#behavior).

1. **Supply Accounting**: `totalSupply` always equals the sum of all balances, and equals total minted minus total burned, where total burned includes every amount burned by `burnAt`.
2. **Authorized Burns Only**: Every `BurnAt(burner, from, amount)` event was emitted by a call whose caller held `BURN_AT_ROLE` on this token at the time of the call. The cumulative amount burned through `burnAt` by callers without the role is always zero.
3. **Protected Balances Are Untouched**: For every address in the protected set active at the current hardfork, the cumulative amount burned from it through `burnAt` is always zero. As a result, `burnAt` never breaks the accounting of the contracts that hold funds at those addresses: the funds each one owes its users stay backed by its token balance to the same extent as without `burnAt`.
4. **No Burns While Paused**: The cumulative amount burned through `burnAt` while the token is paused is always zero.
5. **Event Consistency**: The sum of `amount` over all `BurnAt` events equals the total reduction in `totalSupply` caused by `burnAt`. For each `from`, the sum of `amount` over `BurnAt` events with that `from` equals the total amount `burnAt` removed from `from`'s balance.
6. **Reward State Untouched**: No `burnAt` call changes reward-accounting state, as specified for `burnBlocked` at T8 and later in [TIP-1075](./tip-1075.md). Settled rewards that are claimable remain claimable under the normal claim rules.
7. **Access-Key Limits Hold**: In any transaction signed by an access key with spending limits, the total value that leaves the transaction origin's balance through transfers and `burnAt` never exceeds that key's remaining limit for the token at the start of the transaction.
