diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 70b4b23..ab2c2a5 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -15,12 +15,13 @@ pragma solidity >=0.8.20 <0.9.0; /// wallet or contract already expects. /// /// **Role model.** Standard OpenZeppelin AccessControl semantics: -/// five named roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, -/// `PAUSE_ROLE`, `UNPAUSE_ROLE`) plus arbitrary user-defined roles. -/// `grantRole`, `revokeRole`, `renounceRole`, and `setRoleAdmin` -/// work uniformly across all roles. The only protocol-level -/// constraint is that the LAST holder of `DEFAULT_ADMIN_ROLE` -/// cannot renounce: the token must always have at least one admin. +/// six named roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, +/// `BURN_BLOCKED_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`) plus arbitrary +/// user-defined roles. `grantRole`, `revokeRole`, `renounceRole`, and +/// `setRoleAdmin` work uniformly across all roles. The only +/// protocol-level constraint is that the LAST holder of +/// `DEFAULT_ADMIN_ROLE` cannot renounce: the token must always have +/// at least one admin. /// /// **Pause model.** Pause is granular: `pause(uint256 vectors)` /// accepts a bitmask indicating which classes of operation to halt @@ -33,19 +34,35 @@ pragma solidity >=0.8.20 <0.9.0; /// Functions whose capability bit is unset revert with /// `FeatureDisabled`, regardless of role state. See `Capabilities`. /// -/// **Policy model.** Every transfer, mint, and redeem passes -/// through the token's currently-set policy ID, resolved against -/// the singleton policy registry. Transfer checks consult the -/// policy for `from`, `to`, AND `msg.sender` (the spender, when -/// distinct from `from`). Mint checks consult the policy for the -/// recipient via the mint-recipient slot of a compound policy. -/// Redeem checks consult the policy for `msg.sender` via the -/// redeemer slot of a compound policy: tokens without redemption -/// configure that slot as always-reject, making `redeem` revert -/// for every caller. Burn checks consult only the role of the -/// caller; `BURN_ROLE` plus the caller's own balance are -/// sufficient. `approve` is NOT gated by the policy (only the -/// act of MOVING balance is gated). +/// **Policy model.** The token holds a generic `policyId` mapping +/// keyed by `bytes32 policyType`, where each standard policy type +/// is the `keccak256` hash of its name. Four standard types are +/// exposed as constants on this base surface: +/// - `TRANSFER_SENDER` — checked against `from` on every transfer +/// - `TRANSFER_RECEIVER` — checked against `to` on every transfer +/// - `TRANSFER_EXECUTOR` — checked against `msg.sender` on `transferFrom` +/// (when distinct from `from`) +/// - `MINT_RECEIVER` — checked against `to` on every mint +/// Variants may introduce additional policy-type constants for +/// variant-specific operations (e.g. `IB20Security` adds +/// `REDEEMER_SENDER` for its `redeem` path). The underlying +/// `policyId` mapping accepts any `bytes32` key, so the +/// variant-side additions are pure interface additions with no +/// change to the storage shape. +/// +/// Each policy slot defaults to built-in ID `0` (always-reject) so +/// newly minted tokens cannot move balance until the admin +/// configures their compliance regime. ID `1` (always-allow) is +/// the explicit opt-out for a given role. +/// +/// Asymmetric per-role configuration is expressed by pointing +/// different slots at different policies — for example, a +/// sanctions BLOCKLIST on `TRANSFER_SENDER` and an unrestricted +/// always-allow on `MINT_RECEIVER`. The registry stays flat; +/// all composition happens at the token layer. +/// +/// `approve` is NOT gated by any policy (only the act of MOVING +/// balance is gated). /// /// **Permit.** EIP-2612 permit, EOA signatures only. ERC-1271 /// contract signatures are NOT supported on the default surface @@ -66,13 +83,7 @@ interface IB20 { /// for the target role, and `setRoleAdmin` when the caller /// does not hold the current admin role for the target role. /// @dev Matches OZ AccessControl's `AccessControlUnauthorizedAccount` - /// error exactly. Since `getRoleAdmin(role)` defaults to - /// `DEFAULT_ADMIN_ROLE` for any role that has not had a - /// custom admin set, calls like - /// `grantRole(SOME_UNREGISTERED_ROLE, alice)` revert with - /// `neededRole == DEFAULT_ADMIN_ROLE` rather than a - /// "role does not exist" error: every `bytes32` is a valid - /// role identifier in this model. + /// error exactly. error AccessControlUnauthorizedAccount(address account, bytes32 neededRole); /// @notice Caller failed a positional authorization check that is NOT @@ -94,7 +105,7 @@ interface IB20 { error InsufficientAllowance(address spender, uint256 allowance, uint256 needed); /// @notice `sender`'s balance is less than `needed` for the requested - /// transfer, burn, or redeem. + /// transfer or burn. /// @dev Matches OZ ERC20 / ERC-6093 exactly. error InsufficientBalance(address sender, uint256 balance, uint256 needed); @@ -132,14 +143,22 @@ interface IB20 { /// @notice The mint would push `totalSupply` past the configured cap. error SupplyCapExceeded(uint256 cap, uint256 attempted); - /// @notice The active transfer policy denied the operation. `policyId` - /// is the ID currently set as `transferPolicyId`. - error PolicyForbids(uint64 policyId); + /// @notice A policy slot denied the operation. `policyType` identifies + /// which slot (e.g. `TRANSFER_SENDER`, `MINT_RECEIVER`) and + /// `policyId` is the ID currently set in that slot. + error PolicyForbids(bytes32 policyType, uint64 policyId); /// @notice The provided policy ID does not exist in the policy /// registry. error PolicyNotFound(uint64 policyId); + /// @notice `burnBlocked` was called against a `from` address that is + /// currently authorized under the active `TRANSFER_SENDER` + /// policy. `burnBlocked` exists specifically to seize supply + /// from policy-blocked addresses; calling it against a + /// non-blocked address is rejected by design. + error AccountNotBlocked(address account); + /// @notice An EIP-2612 `permit` was submitted with a `deadline` /// strictly less than the current block timestamp. /// @dev Matches OZ ERC20Permit's `ExpiredSignature` error @@ -157,10 +176,6 @@ interface IB20 { /// permanent. error FeatureDisabled(uint256 capability); - /// @notice The redemption amount is below the configured - /// `minimumRedeemable` threshold. - error MinimumRedeemableNotMet(uint256 amount, uint256 minimum); - /// @notice `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called when the /// caller is the last admin. Tokens MUST always have at least /// one admin; rotate to a new admin first via `grantRole`. @@ -180,7 +195,8 @@ interface IB20 { /// @notice ERC-20 standard transfer event. Emitted on every successful /// transfer (including memo'd variants), mint - /// (`from = address(0)`), and burn (`to = address(0)`). + /// (`from = address(0)`), and burn (`to = address(0)`, + /// including `burnBlocked` and `redeem`). event Transfer(address indexed from, address indexed to, uint256 amount); /// @notice ERC-20 standard approval event. @@ -196,6 +212,13 @@ interface IB20 { /// is shared. event Memo(bytes32 indexed memo); + /// @notice Emitted by `burnBlocked` in addition to the standard + /// `Transfer(from, address(0), amount)`. Distinguishes + /// compliance-driven seizure (which destroys balance belonging + /// to a third party) from `burn` (which destroys the caller's + /// own balance). + event BurnedBlocked(address indexed caller, address indexed from, uint256 amount); + /// @notice Emitted when `account` is granted `role`. `sender` is the /// account that originated the call (the admin for `role`, /// or the same as `account` if the grant happens via factory @@ -212,25 +235,25 @@ interface IB20 { event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); /// @notice Emitted when the admin role for `role` is changed via - /// `setRoleAdmin`. `DEFAULT_ADMIN_ROLE` is the implicit - /// starting admin for all roles, despite this event NOT - /// being emitted to signal that initial state. - /// @dev Matches OZ AccessControl's `RoleAdminChanged` event - /// exactly. Note OZ does NOT include a `sender` parameter - /// here; this is intentional alignment. + /// `setRoleAdmin`. + /// @dev Matches OZ AccessControl's `RoleAdminChanged` event exactly. event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); - /// @notice Emitted by `pause`. `vectors` is the bitmask added to the - /// current paused state (the result of `current | vectors`, - /// not the argument). `updater` is the caller. + /// @notice Emitted by `pause`. `vectors` is the bitmask argument to + /// the call (not the resulting paused state). `updater` is + /// the caller. event Paused(address indexed updater, uint256 vectors); /// @notice Emitted by `unpause`. All paused vectors are cleared. event Unpaused(address indexed updater); - /// @notice Emitted by `changeTransferPolicyId`. Includes the prior ID - /// for indexer convenience. - event TransferPolicyUpdated(address indexed updater, uint64 oldPolicyId, uint64 newPolicyId); + /// @notice Emitted by `updatePolicy` when a token's policy slot is + /// changed. `policyType` is one of the standard policy-type + /// identifiers (e.g. `TRANSFER_SENDER()`); `oldPolicyId` and + /// `newPolicyId` are the prior and current registry IDs for + /// that slot. Initial slot assignment at creation is also + /// emitted via `PolicyUpdated` with `oldPolicyId == 0`. + event PolicyUpdated(bytes32 indexed policyType, uint64 oldPolicyId, uint64 newPolicyId); /// @notice Emitted by `setSupplyCap`. Includes the prior cap for /// indexer convenience. @@ -249,18 +272,6 @@ interface IB20 { /// indexer consumption. event SymbolUpdated(address indexed updater, string newSymbol); - /// @notice Emitted by `redeem` and `redeemWithMemo` (in addition to - /// the standard `Transfer(holder, address(0), amount)`). - /// Distinguishes user-initiated redemption (which implies an - /// off-chain settlement obligation) from plain `burn`, which - /// emits the same `Transfer` event but carries no - /// off-chain meaning. - event Redeemed(address indexed holder, uint256 amount); - - /// @notice Emitted by `setMinimumRedeemable`. Includes the prior - /// minimum for indexer convenience. - event MinimumRedeemableUpdated(address indexed updater, uint256 oldMinimum, uint256 newMinimum); - /*////////////////////////////////////////////////////////////// ROLE IDENTIFIERS //////////////////////////////////////////////////////////////*/ @@ -268,26 +279,33 @@ interface IB20 { /// @notice The default top-level admin role, equal to `bytes32(0)` per /// the OpenZeppelin AccessControl convention. The admin /// manages all other roles via `grantRole`, `revokeRole`, and - /// `setRoleAdmin`. The admin can also `changeTransferPolicyId`, + /// `setRoleAdmin`. The admin can also `updatePolicy`, /// `setSupplyCap`, `setContractURI`, `setName`, and `setSymbol`. - /// @dev Unlike earlier drafts, there is NO two-step delay-protected - /// transfer for this role. `grantRole(DEFAULT_ADMIN_ROLE, ...)` - /// and `revokeRole(DEFAULT_ADMIN_ROLE, ...)` work uniformly. + /// @dev There is NO two-step delay-protected transfer for this + /// role. `grantRole(DEFAULT_ADMIN_ROLE, ...)` and + /// `revokeRole(DEFAULT_ADMIN_ROLE, ...)` work uniformly. /// The only constraint is that the last admin cannot renounce /// (see `LastAdminCannotRenounce`). function DEFAULT_ADMIN_ROLE() external view returns (bytes32); /// @notice Required to call `mint` and `mintWithMemo`. Held separately - /// from `BURN_ROLE` so issuance and destruction authority can - /// be split across teams (e.g. treasury team mints, redemption - /// team burns). + /// from `BURN_ROLE` and `BURN_BLOCKED_ROLE` so issuance and + /// destruction authority can be split across teams. function MINT_ROLE() external view returns (bytes32); - /// @notice Required to call `burn` and `burnWithMemo`. Note that burn - /// operates on the caller's own balance only; there is no - /// force-burn function on the Default surface. + /// @notice Required to call `burn` and `burnWithMemo`. Note that + /// `burn` operates on the caller's own balance only; to + /// destroy supply held by a third party (e.g. for sanctions + /// seizure), see `BURN_BLOCKED_ROLE` and `burnBlocked`. function BURN_ROLE() external view returns (bytes32); + /// @notice Required to call `burnBlocked`. Held separately from + /// `BURN_ROLE` so the authority to destroy a third party's + /// balance (gated on that party being unauthorized under the + /// active `TRANSFER_SENDER` policy) can be granted only to a + /// compliance role, not to general burn operators. + function BURN_BLOCKED_ROLE() external view returns (bytes32); + /// @notice Required to call `pause`. Held separately from /// `UNPAUSE_ROLE` so emergency-stop authority can be delegated /// to a 24/7 ops team without also granting unpause authority. @@ -298,6 +316,29 @@ interface IB20 { /// action than the pause itself. function UNPAUSE_ROLE() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// + POLICY TYPE IDENTIFIERS + //////////////////////////////////////////////////////////////*/ + + /// @notice The policy slot consulted against `from` on every transfer + /// (including the `from` side of `transferFrom`). Identifier + /// is `keccak256("TRANSFER_SENDER")`. + function TRANSFER_SENDER() external view returns (bytes32); + + /// @notice The policy slot consulted against `to` on every transfer. + /// Identifier is `keccak256("TRANSFER_RECEIVER")`. + function TRANSFER_RECEIVER() external view returns (bytes32); + + /// @notice The policy slot consulted against `msg.sender` on + /// `transferFrom` (the spender, when distinct from `from`). + /// Not consulted on `transfer` (where `msg.sender == from`). + /// Identifier is `keccak256("TRANSFER_EXECUTOR")`. + function TRANSFER_EXECUTOR() external view returns (bytes32); + + /// @notice The policy slot consulted against `to` on every mint. + /// Identifier is `keccak256("MINT_RECEIVER")`. + function MINT_RECEIVER() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// CAPABILITIES //////////////////////////////////////////////////////////////*/ @@ -343,14 +384,18 @@ interface IB20 { /// @notice Transfers `amount` from `msg.sender` to `to`. Reverts with: /// - `ContractPaused(TRANSFER)` if the `TRANSFER` pause vector /// is set. - /// - `PolicyForbids(transferPolicyId)` if the active transfer - /// policy denies the transfer. - /// - `InsufficientBalance(msg.sender, balance, amount)` - /// if the caller does not have enough balance. + /// - `PolicyForbids(TRANSFER_SENDER, policyId)` if `msg.sender` + /// is not authorized under the active `TRANSFER_SENDER` policy. + /// - `PolicyForbids(TRANSFER_RECEIVER, policyId)` if `to` is not + /// authorized under the active `TRANSFER_RECEIVER` policy. + /// - `InsufficientBalance(msg.sender, balance, amount)` if the + /// caller does not have enough balance. /// - `InvalidReceiver(to)` if `to == address(0)`. - /// @dev Policy check evaluates `msg.sender` (the sender of value) - /// and `to` (the recipient). When the token is configured as - /// a gas asset, fee debits go through this same path. + /// @dev Does NOT consult the `TRANSFER_EXECUTOR` policy: on direct + /// `transfer` the executor IS the sender, and the sender + /// check already covers that address. When the token is + /// configured as a gas asset, fee debits go through this + /// same path. function transfer(address to, uint256 amount) external returns (bool); /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s @@ -358,17 +403,18 @@ interface IB20 { /// - `InsufficientAllowance(msg.sender, allowance, amount)` /// if the caller does not have enough allowance from `from`. /// - `InvalidSender(from)` if `from == address(0)`. - /// @dev Policy check evaluates `from` (the sender of value), `to` - /// (the recipient), AND `msg.sender` (the spender, when - /// distinct from `from`). A sanctioned spender cannot move - /// tokens for a non-sanctioned holder. + /// - `PolicyForbids(TRANSFER_EXECUTOR, policyId)` if + /// `msg.sender != from` and `msg.sender` is not authorized + /// under the active `TRANSFER_EXECUTOR` policy. + /// @dev The sender-side check is performed against `from` (the + /// party whose balance moves), the receiver check against + /// `to`, and the executor check against `msg.sender` only + /// when distinct from `from`. A sanctioned spender cannot + /// move tokens for a non-sanctioned holder. function transferFrom(address from, address to, uint256 amount) external returns (bool); - /// @notice Sets `spender`'s allowance to `amount`. NOT gated by the - /// transfer policy or by pause; only the act of MOVING balance - /// is gated. A user on the policy blocklist may still - /// `approve` (the approval cannot be acted on by the spender, - /// since `transferFrom` would revert). + /// @notice Sets `spender`'s allowance to `amount`. NOT gated by any + /// policy or by pause; only the act of MOVING balance is gated. /// @dev Reverts with `InvalidApprover(msg.sender)` if the /// caller is `address(0)` (theoretically unreachable for /// normal callers but enforced for parity with OZ ERC20), @@ -422,16 +468,9 @@ interface IB20 { /// `SupplyCapExceeded`). /// 2. The `MINT` pause vector is unset (else /// `ContractPaused(MINT)`). - /// 3. The active transfer policy authorizes `to` as a mint - /// recipient (else `PolicyForbids`). - /// @dev There is no `MINTABLE` capability bit. To make a token - /// permanently fixed-supply, set `supplyCap == initialSupply` - /// at creation with `CAP_MUTABLE` unset; future mint calls - /// will revert with `SupplyCapExceeded` because the cap can - /// never be raised. To pause minting temporarily, set the - /// `MINT` pause vector or revoke `MINT_ROLE`. - /// - /// Per-minter rate limiting is NOT enshrined at any level + /// 3. `to` is authorized under the active `MINT_RECEIVER` + /// policy (else `PolicyForbids(MINT_RECEIVER, policyId)`). + /// @dev Per-minter rate limiting is NOT enshrined at any level /// (Default or variant). Minter quotas live in EVM /// periphery contracts: a controller / wrapper that holds /// `MINT_ROLE` and enforces per-caller quotas before @@ -447,14 +486,12 @@ interface IB20 { /// @notice Burns `amount` from the caller's own balance. Requires /// `BURN_ROLE`. Subject to the `BURN` pause vector being unset - /// (else `ContractPaused(BURN)`). NOT subject to the transfer - /// policy: burn destroys the caller's own supply with no - /// recipient. Reverts with - /// `InsufficientBalance(caller, balance, amount)` if the - /// caller does not have enough balance. - /// @dev There is no force-burn function on the Default surface. - /// Sanctions seizure flows live in token variants (e.g. - /// Security via `adminBurn`) or in periphery contracts. + /// (else `ContractPaused(BURN)`). NOT subject to any policy: + /// burn destroys the caller's own supply with no recipient. + /// Reverts with `InsufficientBalance(caller, balance, amount)` + /// if the caller does not have enough balance. + /// @dev To destroy balance held by a third party (compliance + /// seizure from a policy-blocked address), use `burnBlocked`. /// Emits `Transfer(caller, address(0), amount)`. function burn(uint256 amount) external; @@ -462,53 +499,25 @@ interface IB20 { /// after the standard `Transfer` event. function burnWithMemo(uint256 amount, bytes32 memo) external; - /*////////////////////////////////////////////////////////////// - REDEEM - //////////////////////////////////////////////////////////////*/ - - /// @notice Destroys `amount` of the caller's balance, signaling an - /// off-chain redemption claim against the issuer. Subject to: - /// 1. `amount >= minimumRedeemable()` (else - /// `MinimumRedeemableNotMet(amount, minimum)`). - /// 2. `amount <= balanceOf(msg.sender)` (else - /// `InsufficientBalance(msg.sender, balance, amount)`). - /// 3. The `REDEEM` pause vector is unset (else - /// `ContractPaused(REDEEM)`). - /// 4. The active transfer policy authorizes `msg.sender` as - /// a redeemer (else `PolicyForbids(transferPolicyId)`). - /// @dev No role is required: redemption is a user-initiated - /// operation on the caller's own balance, gated entirely by - /// the policy's redeemer slot. - /// - /// Tokens that do not offer redemption configure their - /// transfer policy with the redeemer slot pointed at policy - /// ID `0` (always-reject); calls to `redeem` then revert - /// with `PolicyForbids` for every caller. The function is - /// present on every Default token but its availability is - /// policy-driven. - /// - /// Distinct from `burn` (which requires `BURN_ROLE` and - /// carries no off-chain settlement implication). Both emit - /// `Transfer(holder, address(0), amount)`; `redeem` - /// additionally emits `Redeemed(holder, amount)` so indexers - /// can distinguish. - function redeem(uint256 amount) external; - - /// @notice Same as `redeem`, with a memo. Emits `Memo(memo)` - /// immediately after the standard `Transfer` event (and - /// after `Redeemed`). - function redeemWithMemo(uint256 amount, bytes32 memo) external; - - /// @notice The minimum amount that may be redeemed in a single call - /// to `redeem` / `redeemWithMemo`. Defaults to 0 (no - /// minimum) at creation. - function minimumRedeemable() external view returns (uint256); - - /// @notice Sets a new minimum redeemable amount. Requires - /// `DEFAULT_ADMIN_ROLE`. May be set to 0 to disable the - /// minimum entirely. Takes effect immediately for the next - /// redemption. - function setMinimumRedeemable(uint256 newMinimum) external; + /// @notice Destroys `amount` of `from`'s balance. Requires + /// `BURN_BLOCKED_ROLE`. Subject to: + /// 1. The `BURN` pause vector is unset (else + /// `ContractPaused(BURN)`). + /// 2. `from` is NOT authorized under the active + /// `TRANSFER_SENDER` policy (else `AccountNotBlocked(from)`). + /// `burnBlocked` exists for seizure of policy-blocked + /// balance; calling it against an authorized address is + /// rejected by design. + /// 3. `amount <= balanceOf(from)` (else + /// `InsufficientBalance(from, balance, amount)`). + /// @dev Designed for sanctions-seizure flows where compliance + /// requires destruction of balance held by a blocked + /// address. Tokens that follow a "freeze, never seize" + /// philosophy (e.g. CDP Custom Stablecoin) simply never + /// grant `BURN_BLOCKED_ROLE`. + /// Emits `Transfer(from, address(0), amount)` and + /// `BurnedBlocked(caller, from, amount)`. + function burnBlocked(address from, uint256 amount) external; /*////////////////////////////////////////////////////////////// ROLES @@ -573,10 +582,6 @@ interface IB20 { /// `currentPaused | vectors`. Requires `PAUSABLE` capability /// and `PAUSE_ROLE`. Reverts with `InvalidAmount` if /// `vectors == 0`. - /// @dev See `PauseVectors` for the bit definitions. Pausing a - /// vector that is already set is a no-op for the bitmask but - /// still emits `Paused(updater, vectors)` with the argument - /// as supplied (for indexer trace). function pause(uint256 vectors) external; /// @notice Unpauses ALL currently-paused vectors. Requires `PAUSABLE` @@ -584,26 +589,35 @@ interface IB20 { /// not support unpausing a subset of vectors; admin must /// unpause everything and re-pause the still-blocked vectors /// in a follow-up call if granular resumption is desired. - /// @dev No-op if no vectors are currently paused; still emits - /// `Unpaused(updater)`. function unpause() external; /*////////////////////////////////////////////////////////////// POLICY //////////////////////////////////////////////////////////////*/ - /// @notice The policy ID currently gating this token's transfers and - /// mints. Resolved against the singleton policy registry - /// precompile. ID `0` always rejects (functional soft-pause - /// via policy); ID `1` always allows. - function transferPolicyId() external view returns (uint64); - - /// @notice Sets a new transfer policy. Requires `DEFAULT_ADMIN_ROLE`. - /// The policy MUST exist in the registry (or be one of the - /// built-in IDs `0` or `1`); otherwise reverts with - /// `PolicyNotFound`. Takes effect immediately for the next - /// transfer or mint. - function changeTransferPolicyId(uint64 newPolicyId) external; + /// @notice The current policy ID configured for `policyType`. Returns + /// `0` (always-reject built-in) for any policy slot that has + /// never been assigned. Standard policy types are exposed as + /// the role-identifier constants `TRANSFER_SENDER()`, + /// `TRANSFER_RECEIVER()`, `TRANSFER_EXECUTOR()`, and + /// `MINT_RECEIVER()`. Variants add their own constants for + /// variant-specific operations (e.g. `REDEEMER_SENDER()` on + /// `IB20Security`). User-defined policy types are also + /// supported and may be used by periphery contracts that + /// layer additional gating on top. + /// @dev All slots default to `0` (always-reject) at token creation: + /// newly minted tokens cannot move balance until the admin + /// configures their policy slots. To explicitly opt out of a + /// given slot's enforcement, point it at `1` (always-allow). + function policyId(bytes32 policyType) external view returns (uint64); + + /// @notice Updates the policy ID assigned to `policyType`. Requires + /// `DEFAULT_ADMIN_ROLE`. The target policy MUST exist in the + /// registry (or be one of the built-in IDs `0` or `1`); + /// otherwise reverts with `PolicyNotFound`. Takes effect + /// immediately for the next operation that consults this + /// slot. Emits `PolicyUpdated`. + function updatePolicy(bytes32 policyType, uint64 newPolicyId) external; /*////////////////////////////////////////////////////////////// SUPPLY CAP diff --git a/src/interfaces/IB20Security.sol b/src/interfaces/IB20Security.sol index ffb9853..6d4dc15 100644 --- a/src/interfaces/IB20Security.sol +++ b/src/interfaces/IB20Security.sol @@ -5,26 +5,34 @@ import {IB20} from "./IB20.sol"; /// @title IB20Security /// @notice A B-20 token variant for tokenized securities (equities, ETFs, -/// commodities, etc.). Extends `IB20` with primitives -/// specific to securities: holder-impacting announcements, -/// split-safe share-ratio accounting, security-identifier -/// metadata, compliant issuance via `create`, and cold-path +/// commodities, etc.). Extends `IB20` with primitives specific to +/// securities: end-user `redeem` (onchain destruction signaling an +/// off-chain settlement claim), holder-impacting announcements, +/// split-safe share-ratio accounting, security-identifier metadata, +/// compliant primary-market issuance via `create`, and cold-path /// admin batch mint / burn for unusual corporate actions. /// -/// @dev **Inherited surface.** `IB20` already provides the -/// pieces that are shared with stablecoins and other variants: -/// ERC-20 surface, mint / burn (gated by `MINT_ROLE` / `BURN_ROLE`), -/// redeem / redeemWithMemo / minimumRedeemable / setMinimumRedeemable -/// (gated by the redeemer slot of the compound transfer policy), -/// pause vectors (including REDEEM at bit 3), permit, contract URI, -/// supply cap, and OZ-style role management. Security tokens use -/// all of these as-is and do not redeclare them here. +/// @dev **Inherited surface.** `IB20` already provides the pieces +/// shared with stablecoins and other variants: ERC-20 surface, +/// `mint` / `burn` (gated by `MINT_ROLE` / `BURN_ROLE`), +/// `burnBlocked` for sanctions seizure (gated by +/// `BURN_BLOCKED_ROLE`), pause vectors, permit, contract URI, +/// supply cap, OZ-style role management, and the generic policy +/// system (`policyId(bytes32)` / `updatePolicy(bytes32, uint64)` +/// with the standard five policy-type constants). /// /// **Security-specific additions.** This interface adds: -/// 1. `announcement(...)` plus an `ANNOUNCE_ROLE` for posting +/// 1. `redeem(...)` / `redeemWithMemo(...)` plus +/// `minimumRedeemable` / `setMinimumRedeemable`: end-user +/// redemption that destroys onchain supply and signals an +/// off-chain settlement obligation. Gated by the inherited +/// `REDEEMER_SENDER` policy slot rather than by any role +/// (admins manage who can redeem by updating the policy slot, +/// not by granting / revoking a role). +/// 2. `announcement(...)` plus an `ANNOUNCE_ROLE` for posting /// holder-impacting disclosures (corporate actions, name /// changes, splits, etc.). -/// 2. **Announcement coupling**: every security-specific +/// 3. **Announcement coupling**: every security-specific /// metadata-changing operation (`updateShareRatio`, /// `updateSecurityIdentifier`, `updateName`, `updateSymbol`, /// `adminMint`, `adminBurn`) MUST reference an announcement @@ -32,22 +40,22 @@ import {IB20} from "./IB20.sol"; /// transaction. Implementations enforce this via transient /// storage so the chain itself, not the issuer's policy, /// guarantees the audit-trail invariant. -/// 3. `shareRatio` + `toShares` + `sharesOf` for split-safe +/// 4. `shareRatio` + `toShares` + `sharesOf` for split-safe /// DeFi-compatible share accounting. -/// 4. `create(...)` plus `ISSUER_ROLE` and a per-caller rate +/// 5. `create(...)` plus `ISSUER_ROLE` and a per-caller rate /// limit for the compliant primary-market issuance path. /// Distinct from the inherited `mint` because securities /// have legal definitions around what constitutes "creation". -/// 5. `adminMint(...)` / `adminBurn(...)` cold-path batch +/// 6. `adminMint(...)` / `adminBurn(...)` cold-path batch /// operations for unusual corporate actions. -/// 6. `updateName(...)` / `updateSymbol(...)` security-specific +/// 7. `updateName(...)` / `updateSymbol(...)` security-specific /// paths that take an announcement ID. These are the /// canonical name/symbol update functions for security /// tokens; the inherited `setName` / `setSymbol` from /// `IB20` are present in the interface but /// implementations typically revert them on security tokens /// so that name/symbol changes always carry an announcement. -/// 7. `securityIdentifier` / `updateSecurityIdentifier` for +/// 8. `securityIdentifier` / `updateSecurityIdentifier` for /// ISIN, CUSIP, FIGI, and similar off-chain registry IDs. /// /// **Operationally typical configuration.** Security-token @@ -55,14 +63,21 @@ import {IB20} from "./IB20.sol"; /// path is disabled in favor of `create` and `adminMint`) and /// do NOT grant `BURN_ROLE` (holders use `redeem` for off-chain /// settlement; admins use `adminBurn` for cold-path destruction). -/// Capability bits relevant to securities live in the -/// `Capabilities` library bits 16..23 (e.g. `SECURITY_CREATABLE`, -/// `SHARE_RATIO_MUTABLE`). +/// The `REDEEMER_SENDER` policy slot is typically pointed at an +/// ALLOWLIST of brokerage-verified holders (Coinbase adds +/// addresses to this allowlist as users complete KYC and connect +/// a valid brokerage account). Capability bits relevant to +/// securities live in the `Capabilities` library bits 16..23 +/// (e.g. `SECURITY_CREATABLE`, `SHARE_RATIO_MUTABLE`). interface IB20Security is IB20 { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ + /// @notice The redemption amount is below the configured + /// `minimumRedeemable` threshold. + error MinimumRedeemableNotMet(uint256 amount, uint256 minimum); + /// @notice A security-specific operation was called without a /// matching prior `announcement(id, ...)` in the same /// transaction. @@ -89,6 +104,18 @@ interface IB20Security is IB20 { EVENTS //////////////////////////////////////////////////////////////*/ + /// @notice Emitted by `redeem` and `redeemWithMemo` (in addition to + /// the standard `Transfer(holder, address(0), amount)`). + /// Distinguishes user-initiated redemption (which implies an + /// off-chain settlement obligation) from plain `burn` / + /// `burnBlocked`, which emit the same `Transfer` event but + /// carry no off-chain meaning. + event Redeemed(address indexed holder, uint256 amount); + + /// @notice Emitted by `setMinimumRedeemable`. Includes the prior + /// minimum for indexer convenience. + event MinimumRedeemableUpdated(address indexed updater, uint256 oldMinimum, uint256 newMinimum); + /// @notice A holder-impacting announcement. Posted before any /// metadata-changing operation that references the same /// `id`. @@ -125,14 +152,12 @@ interface IB20Security is IB20 { /// @notice Per-caller create rate-limit configuration changed. event CreateRateLimitConfigured(address indexed caller, uint256 maxAmount, uint256 interval); - // NOTE on `NameUpdated` / `SymbolUpdated` / `Redeemed` / - // `MinimumRedeemableUpdated`: all four are inherited from - // `IB20` and are not redeclared here. Security - // implementations of `updateName` / `updateSymbol` emit the - // inherited `NameUpdated` / `SymbolUpdated` event after the matching - // `Announcement(id, ...)` has been emitted earlier in the - // transaction; indexers correlate the two via the shared - // transaction hash. + // NOTE on `NameUpdated` / `SymbolUpdated`: both are inherited from + // `IB20` and are not redeclared here. Security implementations of + // `updateName` / `updateSymbol` emit the inherited `NameUpdated` / + // `SymbolUpdated` event after the matching `Announcement(id, ...)` + // has been emitted earlier in the transaction; indexers correlate + // the two via the shared transaction hash. /*////////////////////////////////////////////////////////////// ROLE IDENTIFIERS @@ -152,6 +177,66 @@ interface IB20Security is IB20 { /// tokens). function ISSUER_ROLE() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// + POLICY TYPE IDENTIFIERS + //////////////////////////////////////////////////////////////*/ + + /// @notice The policy slot consulted against `msg.sender` on + /// `redeem` / `redeemWithMemo`. Identifier is + /// `keccak256("REDEEMER_SENDER")`. Security-specific because + /// the redeem surface is itself security-specific; the + /// underlying `policyId(bytes32)` mapping on `IB20` accepts + /// any key, so this is a pure interface addition with no + /// change to base storage shape. + function REDEEMER_SENDER() external view returns (bytes32); + + /*////////////////////////////////////////////////////////////// + REDEEM + //////////////////////////////////////////////////////////////*/ + + /// @notice Destroys `amount` of the caller's balance, signaling an + /// off-chain redemption claim against the issuer. Subject to: + /// 1. `amount >= minimumRedeemable()` (else + /// `MinimumRedeemableNotMet(amount, minimum)`). + /// 2. `amount <= balanceOf(msg.sender)` (else + /// `InsufficientBalance(msg.sender, balance, amount)`). + /// 3. The `REDEEM` pause vector is unset (else + /// `ContractPaused(REDEEM)`). + /// 4. `msg.sender` is authorized under the active + /// `REDEEMER_SENDER` policy (else + /// `PolicyForbids(REDEEMER_SENDER, policyId)`). + /// @dev No role is required: redemption is a user-initiated + /// operation on the caller's own balance, gated entirely by + /// the `REDEEMER_SENDER` policy slot. Tokens that do not + /// offer redemption configure the `REDEEMER_SENDER` slot to + /// policy ID `0` (always-reject); calls to `redeem` then + /// revert with `PolicyForbids` for every caller. + /// + /// Distinct from `burn` (which requires `BURN_ROLE` and + /// carries no off-chain settlement implication) and + /// `burnBlocked` (which destroys a third party's balance + /// for compliance seizure). All three emit + /// `Transfer(holder, address(0), amount)`; `redeem` + /// additionally emits `Redeemed(holder, amount)` so indexers + /// can distinguish. + function redeem(uint256 amount) external; + + /// @notice Same as `redeem`, with a memo. Emits `Memo(memo)` + /// immediately after the standard `Transfer` event (and + /// after `Redeemed`). + function redeemWithMemo(uint256 amount, bytes32 memo) external; + + /// @notice The minimum amount that may be redeemed in a single call + /// to `redeem` / `redeemWithMemo`. Defaults to 0 (no + /// minimum) at creation. + function minimumRedeemable() external view returns (uint256); + + /// @notice Sets a new minimum redeemable amount. Requires + /// `DEFAULT_ADMIN_ROLE`. May be set to 0 to disable the + /// minimum entirely. Takes effect immediately for the next + /// redemption. + function setMinimumRedeemable(uint256 newMinimum) external; + /*////////////////////////////////////////////////////////////// ANNOUNCEMENTS //////////////////////////////////////////////////////////////*/ @@ -203,9 +288,8 @@ interface IB20Security is IB20 { //////////////////////////////////////////////////////////////*/ /// @notice The compliant issuance path. Mints `amount` to `to` - /// subject to the standard transfer-policy mint-recipient - /// check AND to a per-caller rate limit configured by the - /// admin. + /// subject to the inherited `MINT_RECEIVER` policy check + /// AND to a per-caller rate limit configured by the admin. /// @dev Requires `ISSUER_ROLE`. Subject to the inherited supply /// cap (`supplyCap`). Distinct from the inherited `mint` /// semantically because securities have legal definitions @@ -232,9 +316,8 @@ interface IB20Security is IB20 { /// @notice Cold-path batch mint. Used for unusual or emergency /// issuance (e.g. distribution of a stock dividend to many - /// holders). All recipients must satisfy - /// `isAuthorizedMintRecipient` on the active transfer - /// policy. + /// holders). All recipients must satisfy the inherited + /// `MINT_RECEIVER` policy check. /// @dev Requires `ISSUER_ROLE` and an `Announcement(id, ...)` /// emitted earlier in the same transaction with the same /// `announcementId`. Subject to the inherited `supplyCap`. diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index b3f0733..579a566 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -2,243 +2,245 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IPolicyRegistry -/// @notice Singleton registry of transfer-authorization policies for B-20 -/// tokens. Each B-20 token holds a single `transferPolicyId` -/// pointing into this registry; on every transfer or mint, the -/// token consults the registry to determine whether the involved -/// addresses are authorized. +/// @notice Singleton registry of address-membership policies. B-20 tokens +/// reference policies in this registry by `uint64 policyId` to +/// enforce authorization at the protocol level: every transfer, +/// mint, and redeem on a B-20 token resolves to one or more +/// `isAuthorized(policyId, account)` calls into this registry. /// -/// Three policy types are supported in v1: -/// - WHITELIST: only listed addresses are authorized. -/// - BLACKLIST: all addresses except listed ones are authorized. -/// - COMPOUND: references three simple policies, one for senders, -/// one for recipients, one for mint recipients. Lets a single -/// policy ID carry asymmetric rules. +/// Two policy types are supported in v1: +/// - **ALLOWLIST**: an account is authorized only if it is on the +/// policy's member set. +/// - **BLOCKLIST**: an account is authorized unless it is on the +/// policy's member set. /// -/// @dev Adapted from Tempo TIP-403 + TIP-1015 with three deliberate -/// omissions: no virtual-address rejection logic (no TIP-1022 on -/// Base), no receive policies (no TIP-1028 escrow), no callback / -/// richer guard policies (could be added in a future hardfork). +/// The registry deliberately stops at flat membership checks. +/// There is no on-registry composition (no AND/OR/COMPOUND), +/// no callback or richer guard policies, no amount conditioning. +/// Asymmetric per-role rules on a token are expressed by storing +/// multiple policy IDs on the token itself (one per role slot), +/// not by composing inside the registry. See `IB20`'s policy +/// model for how this is wired on the token side. /// -/// The registry is a singleton at a fixed precompile address. All -/// B-20 tokens on the chain reference the same `policyId` namespace. -/// Anyone may create policies; the creator picks the admin -/// (typically themselves or a multisig). +/// @dev The registry is a singleton precompile at a fixed address. +/// All B-20 tokens on the chain share the same `policyId` +/// namespace. Anyone may create a policy; the creator nominates +/// the policy admin (typically themselves or a multisig). /// -/// Built-in policy IDs (always present, never need to be created): -/// - `0` — always-reject. All authorization queries return false. +/// **Built-in policy IDs** (always present, never need to be +/// created): +/// - `0` — always-reject. `isAuthorized(0, any)` returns false. /// Useful as the safe default for newly created tokens /// that should not transfer until compliance is configured, -/// and as a "kill switch" independent of pause state. -/// - `1` — always-allow. All authorization queries return true. -/// Useful for tokens that opt out of compliance gating, -/// and as the identity element in compound policies. +/// and as a "kill switch" independent of token-level pause. +/// - `1` — always-allow. `isAuthorized(1, any)` returns true. +/// Useful for tokens that opt out of policy gating for a +/// given role slot. /// -/// Custom policy IDs start at 2 and are assigned monotonically by -/// `policyIdCounter`. +/// Custom policy IDs start at `2` and are assigned monotonically +/// by `nextPolicyId`. +/// +/// **Future extensions** (not in v1 scope, intended path): +/// - Union / intersect policies: compose two same-typed policies +/// into a derived membership check. Would be added as new enum +/// values (`UNION_ALLOWLIST`, `INTERSECT_ALLOWLIST`, and +/// blocklist counterparts) with sibling `createUnionPolicy` / +/// `createIntersectPolicy` creators. Enum extension is +/// backward-compatible; existing policies and consumers stay +/// valid. Defer to a future hardfork. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES //////////////////////////////////////////////////////////////*/ /// @notice Policy type discriminator. - /// @param WHITELIST An address is authorized only if it is in the policy's set. - /// @param BLACKLIST An address is authorized unless it is in the policy's set. - /// @param COMPOUND The policy carries no member set of its own. It - /// references three simple policies and delegates the - /// per-role check. + /// @param ALLOWLIST An account is authorized only if it is in the policy's set. + /// @param BLOCKLIST An account is authorized unless it is in the policy's set. enum PolicyType { - WHITELIST, - BLACKLIST, - COMPOUND - } - - /// @notice Top-level data for any policy (simple or compound). - /// @param policyType The type of the policy. - /// @param admin The address that may modify this policy. Zero for - /// COMPOUND policies (they are structurally immutable). - struct PolicyData { - PolicyType policyType; - address admin; - } - - /// @notice Constituent policy IDs for a compound policy. - /// @param senderPolicyId Policy checked for transfer senders. - /// @param recipientPolicyId Policy checked for transfer recipients. - /// @param mintRecipientPolicyId Policy checked for mint recipients. - struct CompoundPolicyData { - uint64 senderPolicyId; - uint64 recipientPolicyId; - uint64 mintRecipientPolicyId; + ALLOWLIST, + BLOCKLIST } /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice Caller is not the policy admin. + /// @notice Caller is not the policy admin (where current admin is + /// required) or is not the pending admin (where pending admin + /// is required by `finalizeUpdateAdmin`). error Unauthorized(); /// @notice The referenced policy ID does not exist (and is not built-in). error PolicyNotFound(); - /// @notice A compound policy attempted to reference another compound - /// policy as a constituent. Only simple policies (WHITELIST, - /// BLACKLIST) and the built-in IDs (0, 1) are valid constituents. - error PolicyNotSimple(); - /// @notice The operation is incompatible with the policy's type. For - /// example, calling `modifyPolicyWhitelist` on a BLACKLIST - /// policy, or `compoundPolicyData` on a non-COMPOUND policy. + /// example, calling `updateAllowlist` on a BLOCKLIST policy. error IncompatiblePolicyType(); - /// @notice The provided policy type value is not in the `PolicyType` - /// enum, or is not legal for the requested operation (e.g. - /// calling `createPolicy` with `COMPOUND`). + /// @notice The provided policy type value is not in the `PolicyType` enum. error InvalidPolicyType(); /// @notice A required address argument was the zero address. error ZeroAddress(); + /// @notice `finalizeUpdateAdmin` was called for a policy with no + /// currently-staged pending admin. + error NoPendingAdmin(); + /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice Emitted when a new simple (WHITELIST or BLACKLIST) policy is - /// created. For compound policies, see `CompoundPolicyCreated`. + /// @notice A new policy was created. The creator may or may not be the + /// policy admin (the admin is set explicitly at creation). event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); - /// @notice Emitted when a new compound policy is created. - event CompoundPolicyCreated( - uint64 indexed policyId, - address indexed creator, - uint64 senderPolicyId, - uint64 recipientPolicyId, - uint64 mintRecipientPolicyId - ); - - /// @notice Emitted when a policy's admin is updated (including initial - /// assignment at creation). - event PolicyAdminUpdated(uint64 indexed policyId, address indexed updater, address indexed admin); - - /// @notice Emitted when an account's whitelist status is updated for a - /// WHITELIST policy. - event WhitelistUpdated(uint64 indexed policyId, address indexed updater, address indexed account, bool allowed); - - /// @notice Emitted when an account's blacklist status is updated for a - /// BLACKLIST policy. - event BlacklistUpdated(uint64 indexed policyId, address indexed updater, address indexed account, bool restricted); + /// @notice A new admin was staged via `stageUpdateAdmin`. The active + /// admin does not change until `finalizeUpdateAdmin` is called + /// by `pendingAdmin`. `pendingAdmin == address(0)` indicates + /// a previously-staged transfer was cleared. + event PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); + + /// @notice The active admin actually changed: either via + /// `finalizeUpdateAdmin` (where `newAdmin` is the previously + /// pending admin) or via `renounceAdmin` (where + /// `newAdmin == address(0)`). Initial admin assignment at + /// policy creation is also emitted as a `PolicyAdminUpdated` + /// with `previousAdmin == address(0)`. + event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); + + /// @notice One or more accounts had their ALLOWLIST membership set to + /// `allowed`. Emitted once per `updateAllowlist` call, carrying + /// the full batch. + event AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts); + + /// @notice One or more accounts had their BLOCKLIST membership set to + /// `blocked`. Emitted once per `updateBlocklist` call, carrying + /// the full batch. + event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); /*////////////////////////////////////////////////////////////// POLICY CREATION //////////////////////////////////////////////////////////////*/ - /// @notice Creates a new simple (WHITELIST or BLACKLIST) policy. - /// @dev Permissionless. Reverts with `InvalidPolicyType` if - /// `policyType` is `COMPOUND` (use `createCompoundPolicy`), - /// and with `ZeroAddress` if `admin` is `address(0)`. - /// @param admin The address authorized to modify this policy. - /// @param policyType WHITELIST or BLACKLIST. + /// @notice Creates a new policy with no initial members. + /// @dev Permissionless. Reverts with `ZeroAddress` if `admin` is + /// `address(0)`, and with `InvalidPolicyType` if `policyType` + /// is not a valid `PolicyType` enum value. + /// @param admin The address authorized to modify membership on + /// this policy and to transfer or renounce + /// administration. + /// @param policyType ALLOWLIST or BLOCKLIST. /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); - /// @notice Same as `createPolicy`, but additionally seeds the policy's - /// member set with `accounts`. Convenience for one-shot - /// creation flows that don't need an empty initial state. + /// @notice Same as `createPolicy`, but seeds the policy's member set + /// with `accounts` in a single call. Useful for one-shot + /// creation flows that ship with a non-empty initial state. function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64 newPolicyId); - /// @notice Creates a new compound policy referencing three constituent - /// simple policies. Compound policies are structurally - /// immutable: the constituent IDs cannot be changed after - /// creation, and there is no admin. To rotate the configuration, - /// create a new compound policy and re-point the consuming - /// token's `transferPolicyId`. - /// @dev Permissionless. Each constituent MUST exist and MUST be a - /// simple policy (WHITELIST, BLACKLIST) OR a built-in (IDs 0 - /// or 1). Reverts with `PolicyNotFound` for unknown IDs and - /// `PolicyNotSimple` if any constituent is itself COMPOUND. - function createCompoundPolicy(uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId) - external - returns (uint64 newPolicyId); - /*////////////////////////////////////////////////////////////// POLICY ADMINISTRATION //////////////////////////////////////////////////////////////*/ - /// @notice Transfers admin rights for a simple policy. Caller must be - /// the current admin. Reverts on COMPOUND policies (they have - /// no admin). - function setPolicyAdmin(uint64 policyId, address newAdmin) external; - - /// @notice Adds or removes an account from a WHITELIST policy. Caller - /// must be the policy admin. + /// @notice Stages a proposed new admin for `policyId`. Caller MUST be + /// the current admin. The active admin does NOT change until + /// `pendingAdmin` calls `finalizeUpdateAdmin(policyId)`. + /// @dev Calling `stageUpdateAdmin` while a pending admin already + /// exists overwrites the prior nomination (the previously + /// pending admin loses their ability to finalize). Pass + /// `address(0)` to clear a previously-staged transfer + /// without nominating a new candidate. + /// + /// Two-step transfer guards against typos and key compromise: + /// the candidate must actively claim the role, and the + /// current admin retains control until they do. + /// @param policyId The policy whose admin is being staged. + /// @param newAdmin The proposed new admin, or `address(0)` to clear. + function stageUpdateAdmin(uint64 policyId, address newAdmin) external; + + /// @notice Completes a two-step admin transfer. Caller MUST be the + /// address most recently staged via `stageUpdateAdmin`. + /// Promotes the caller to active admin and clears the pending + /// slot. Reverts with `NoPendingAdmin` if no transfer is in + /// flight. + function finalizeUpdateAdmin(uint64 policyId) external; + + /// @notice Single-step: the current admin permanently relinquishes + /// administration of `policyId`. Caller MUST be the current + /// admin. After this call, `policyAdmin(policyId)` returns + /// `address(0)` and no further admin-gated operations on this + /// policy can succeed: the policy's member set is frozen + /// forever, and the policy can never be re-administered. + /// @dev Any in-flight pending admin (set via `stageUpdateAdmin`) + /// is cleared as a side effect of renunciation. The policy + /// continues to exist and remains a valid target of + /// `isAuthorized` queries; only mutation is disabled. + function renounceAdmin(uint64 policyId) external; + + /// @notice Adds or removes `accounts` from an ALLOWLIST policy. All + /// accounts receive the same `allowed` setting in one batch. + /// Caller MUST be the current policy admin. /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// WHITELIST. - function modifyPolicyWhitelist(uint64 policyId, address account, bool allowed) external; + /// ALLOWLIST. Emits a single `AllowlistUpdated` event + /// carrying the full batch. + function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; - /// @notice Adds or removes an account from a BLACKLIST policy. Caller - /// must be the policy admin. + /// @notice Adds or removes `accounts` from a BLOCKLIST policy. All + /// accounts receive the same `blocked` setting in one batch. + /// Caller MUST be the current policy admin. /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// BLACKLIST. - function modifyPolicyBlacklist(uint64 policyId, address account, bool restricted) external; + /// BLOCKLIST. Emits a single `BlocklistUpdated` event + /// carrying the full batch. + function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice Composite check returning `isAuthorizedSender(p, u) && - /// isAuthorizedRecipient(p, u)`. Provided for callers that - /// want a single-call answer to "is `user` authorized for - /// both directions under this policy." - function isAuthorized(uint64 policyId, address user) external view returns (bool); - - /// @notice Whether `user` is authorized as a transfer sender under - /// `policyId`. For simple policies this is equivalent to a - /// single membership check; for compound policies it delegates - /// to the policy's `senderPolicyId`. - function isAuthorizedSender(uint64 policyId, address user) external view returns (bool); - - /// @notice Whether `user` is authorized as a transfer recipient under - /// `policyId`. For compound policies it delegates to the - /// policy's `recipientPolicyId`. - function isAuthorizedRecipient(uint64 policyId, address user) external view returns (bool); - - /// @notice Whether `user` is authorized as a mint recipient under - /// `policyId`. Distinct from `isAuthorizedRecipient` for - /// compound policies, which carry separate sender / recipient - /// / mint-recipient slots. For simple policies this returns - /// the same result as `isAuthorizedRecipient`. - function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool); + /// @notice Whether `account` is authorized under `policyId`. + /// - For ALLOWLIST: returns true iff `account` is on the + /// policy's member set. + /// - For BLOCKLIST: returns true iff `account` is NOT on the + /// policy's member set. + /// - For built-in ID `0` (always-reject): always returns false. + /// - For built-in ID `1` (always-allow): always returns true. + /// @dev Reverts with `PolicyNotFound` if `policyId` is neither a + /// built-in nor a previously-created policy. + function isAuthorized(uint64 policyId, address account) external view returns (bool); /*////////////////////////////////////////////////////////////// POLICY QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice The next policy ID that will be assigned by `createPolicy` / - /// `createPolicyWithAccounts` / `createCompoundPolicy`. Starts - /// at 2 (IDs 0 and 1 are reserved for the built-ins). - function policyIdCounter() external view returns (uint64); + /// @notice The next policy ID that will be assigned by the next call + /// to `createPolicy` / `createPolicyWithAccounts`. Starts at + /// `2` (IDs 0 and 1 are reserved for the built-ins). + function nextPolicyId() external view returns (uint64); /// @notice Whether `policyId` exists. The built-in IDs (0, 1) always - /// exist; custom IDs (>=2) exist iff they have been created. + /// exist; custom IDs (>= 2) exist iff they have been created. function policyExists(uint64 policyId) external view returns (bool); - /// @notice Returns the type and admin of `policyId`. - /// @dev For COMPOUND policies, `admin` is `address(0)`. For built-in - /// policies, `admin` is `address(0)` and `policyType` is - /// implementation-defined (the built-ins are not categorized as - /// WHITELIST or BLACKLIST since they have no member set). - /// Reverts with `PolicyNotFound` for unknown policy IDs. - function policyData(uint64 policyId) external view returns (PolicyType policyType, address admin); - - /// @notice Returns the constituent policy IDs of a compound policy. - /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// COMPOUND, and with `PolicyNotFound` if the policy does not - /// exist. - function compoundPolicyData(uint64 policyId) - external - view - returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId); + /// @notice The type of `policyId`. Reverts with `PolicyNotFound` for + /// unknown IDs. For built-in IDs the returned value is + /// implementation-defined (the built-ins have no member set + /// and are not categorized as ALLOWLIST or BLOCKLIST); + /// callers should treat the built-ins as a separate case. + function policyType(uint64 policyId) external view returns (PolicyType); + + /// @notice The current admin of `policyId`. Returns `address(0)` for + /// built-in policies (which have no admin) and for policies + /// whose admin has been renounced via `renounceAdmin`. + /// Reverts with `PolicyNotFound` for unknown IDs. + function policyAdmin(uint64 policyId) external view returns (address); + + /// @notice The currently-staged pending admin for `policyId`, set by + /// the most recent `stageUpdateAdmin` and cleared on + /// `finalizeUpdateAdmin` or `renounceAdmin`. Returns + /// `address(0)` when no transfer is in flight. Always + /// `address(0)` for built-in policies. + function pendingPolicyAdmin(uint64 policyId) external view returns (address); }