From d3f0c280974f9fb7033b5b16e9f630bf614539a0 Mon Sep 17 00:00:00 2001 From: joey <10592664+joey0612@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:58:11 +0800 Subject: [PATCH 01/41] doc: add New Tokens on BNB Smart Chain bep --- BEPs/BEP-702.md | 389 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 390 insertions(+) create mode 100644 BEPs/BEP-702.md diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md new file mode 100644 index 00000000..24fdf4c6 --- /dev/null +++ b/BEPs/BEP-702.md @@ -0,0 +1,389 @@ +
+  BEP: 702
+  Title: New Tokens on BNB Smart Chain
+  Status: Draft
+  Type: Standards
+  Created: 2026-07-16
+  Description: A protocol-native fungible token standard on BSC, implemented as stateful precompiled contracts rather than deployed bytecode.
+
+ +# BEP-702: New Tokens on BNB Smart Chain + +- [BEP-702: New Tokens on BNB Smart Chain](#bep-702-new-tokens-on-bnb-smart-chain) + - [1. Summary](#1-summary) + - [2. Motivation](#2-motivation) + - [3. Specification](#3-specification) + - [3.1 Architecture Overview](#31-architecture-overview) + - [3.2 Stateful Precompiled Contracts](#32-stateful-precompiled-contracts) + - [3.3 Native Token Address Space](#33-native-token-address-space) + - [3.4 Token Registry](#34-token-registry) + - [3.5 Variants](#35-variants) + - [3.6 Core Token Interface](#36-core-token-interface) + - [3.7 Roles and Access Control](#37-roles-and-access-control) + - [3.8 Transfer Policies](#38-transfer-policies) + - [3.9 Pause](#39-pause) + - [3.10 Supply Cap](#310-supply-cap) + - [3.11 Permit (EIP-2612)](#311-permit-eip-2612) + - [3.12 Asset Variant: Multiplier](#312-asset-variant-multiplier) + - [3.13 Gas Accounting](#313-gas-accounting) + - [4. Rationale](#4-rationale) + - [5. Backward Compatibility](#5-backward-compatibility) + - [6. Security Considerations](#6-security-considerations) + - [7. License](#7-license) + +## 1. Summary + +This BEP introduces a protocol-native fungible token standard for BSC, in two variants — a payment-oriented **Standard** form and an **Asset** form carrying a protocol-level rescaling mechanism for tokenized real-world assets. Tokens created under this standard are not deployed bytecode: they are instantiated through a singleton precompiled contract and executed by native node logic, while remaining fully selector- and event-compatible with [BEP-20](./BEP20.md)/[ERC-20](https://eips.ethereum.org/EIPS/eip-20). The standard bundles the access-control, pausability, supply-cap, and transfer-compliance primitives that regulated and institutional issuers currently have to re-implement (and re-audit) individually on top of BEP-20. + +## 2. Motivation + +BEP-20 defines an ABI convention, not a guarantee about behavior. Any address can claim to implement it while running arbitrary bytecode behind that ABI, and in practice this has caused recurring, costly problems for the ecosystem: + +- **Behavioral drift.** Fee-on-transfer logic, hidden mint backdoors, silent blacklists, or an upgradeable proxy whose implementation changes after audit can all sit behind a perfectly normal-looking BEP-20 interface. Wallets, bridges, and DeFi protocols have no way to distinguish an audited, well-behaved token from a malicious one without re-auditing every single contract they integrate. +- **Duplicated compliance engineering.** Stablecoin and tokenized real-world-asset issuers on BSC each independently re-implement the same handful of primitives — role-gated mint/burn, seizure of blocked balances, granular pause, supply caps — with subtly different guarantees and different bug surfaces every time. +- **Real-world assets need redenomination, not just transfers.** Tokenized real-world assets — equities, commodities, bonds — periodically need their per-unit value rescaled across every holder at once, independent of any transfer taking place. This is a distinct need from a payment-oriented stablecoin, and BEP-20 has no standard answer for it today; issuers either bolt on a custom rebasing mechanism or avoid the token model entirely. +- **Execution overhead.** A BEP-20 transfer pays for two SLOADs, two SSTOREs, and full EVM bytecode interpretation and ABI decoding on top, even though the underlying operation (move a balance from A to B) is one of the simplest state transitions in the system. +- **Trust asymmetry with native BNB.** Native BNB transfers are cheap, simple, and behaviorally guaranteed by the protocol. Fungible tokens, which carry the overwhelming majority of value and volume on BSC, get none of that guarantee today. + +A protocol-native token module addresses all of these at once: behavior is fixed by client code that ships through the same review and hard-fork process as consensus changes, gas cost reflects the actual state-access cost of the operation rather than bytecode interpretation, and a standard set of compliance and (for asset-type tokens) redenomination primitives is available to every issuer without a bespoke implementation. + +## 3. Specification + +### 3.1 Architecture Overview + +Three new pieces of node logic are introduced: + +1. A new class of **stateful precompiled contract** ([§3.2](#32-stateful-precompiled-contracts)), extending BSC's existing (stateless) precompile mechanism with StateDB access, caller/value context, and log emission. +2. A singleton **Token Registry** precompiled contract ([§3.4](#34-token-registry)), the sole entry point for creating a new native token, in one of two variants ([§3.5](#35-variants)). +3. A singleton **Transfer List Registry** precompiled contract ([§3.8](#38-transfer-policies)), holding reusable allow-lists and block-lists that native tokens reference for transfer-compliance checks. + +Every native token created through the Token Registry is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-native-token-address-space)). No bytecode is ever stored at that address; instead, the EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared native token handler, parameterized by the target address. The handler decodes the variant discriminant directly from the target address to decide which method set to expose — the shared [§3.6](#36-core-token-interface) surface alone for a Standard-variant address, or that surface plus [§3.12](#312-asset-variant-multiplier) for an Asset-variant one — and then the target address alone determines which token's state (balances, allowances, roles, policy IDs, pause bits, supply cap, and — for the Asset variant — multiplier) is read and written. This is exactly the same pattern the singleton Token Registry and Transfer List Registry already use: one piece of code serving every caller, differentiated only by address and, here, by the byte encoding the variant. + +Activation of this standard is gated by the hard fork in which it ships, consistent with every other consensus-level change in this repository; no separate on-chain feature-flag contract is introduced (see [§4](#4-rationale)). + +### 3.2 Stateful Precompiled Contracts + +BSC's current precompiled-contract interface (`core/vm.PrecompiledContract`) is intentionally stateless: + +```go +type PrecompiledContract interface { + RequiredGas(input []byte) uint64 + Run(input []byte) ([]byte, error) + Name() string +} +``` + +This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A native token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: + +```go +type StatefulPrecompiledContract interface { + PrecompiledContract + RunWithState(evm *EVM, caller common.Address, self common.Address, input []byte, value *big.Int) ([]byte, error) +} +``` + +The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. + +### 3.3 Native Token Address Space + +A native token address is 20 bytes: + +| Bytes | Length | Content | Meaning | +|---|---|---|---| +| `[0:2]` | 2 | `0xBC20` | Fixed marker identifying the address as a native token | +| `[2:9]` | 7 | `0x00` × 7 | Reserved padding — makes accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | +| `[9]` | 1 (the 10th byte) | Variant discriminant | `0x00` = Standard, `0x01` = Asset ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | +| `[10:20]` | 10 | `keccak256(creator ++ salt)[0:10]` | Identity fingerprint, where `creator` is the account that called the Token Registry and `salt` is caller-supplied entropy | + +`isNativeToken(address) -> bool` checks only bytes `[0:9]` against the fixed pattern above — no storage read is required, and it deliberately does not inspect the variant byte, so that a future variant this standard does not yet define is still recognized as a native token by existing tooling. `variantOf(address) -> Variant` reads byte `[9]` on its own, again with no storage read; this is the same byte the call-dispatch handler consults to decide which method set a given address exposes ([§3.1](#31-architecture-overview)). `getTokenAddress(creator, variant, salt) -> address` (a view method on the Token Registry) lets callers predict a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. + +The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:9]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a native token address. + +### 3.4 Token Registry + +The Token Registry is a singleton precompiled contract. Its interface: + +```solidity +interface INativeTokenRegistry { + enum Variant { STANDARD, ASSET } + + event TokenCreated(address indexed token, address indexed creator, Variant variant, string name, string symbol, uint8 decimals); + + /// @notice Creates a new native token and returns its deterministic address. + /// @param variant STANDARD or ASSET; permanently fixed in the token's address. + /// @param name Token name. + /// @param symbol Token symbol. + /// @param decimals Number of decimals, in the inclusive range [0, 18]. + /// @param initialAdmin The first holder of ADMIN_ROLE. MUST NOT be the zero address; see 3.7 + /// for how to reach an admin-less token after granting whatever other roles it needs. + /// @param supplyCap Maximum total supply. type(uint256).max means uncapped. + /// @param salt Caller-chosen entropy; combined with msg.sender and variant to derive the token's address. + function createToken( + Variant variant, + string calldata name, + string calldata symbol, + uint8 decimals, + address initialAdmin, + uint256 supplyCap, + bytes32 salt + ) external returns (address token); + + /// @notice Predicts the address `createToken` would produce for (msg.sender, variant, salt), without deploying. + function getTokenAddress(address creator, Variant variant, bytes32 salt) external view returns (address); + + /// @notice Returns true if `account` lies in the reserved native-token address space (see 3.3). + /// Does not imply a token has actually been created at that address; see `isTokenCreated`. + function isNativeToken(address account) external pure returns (bool); + + /// @notice Decodes the variant discriminant directly from `token`'s address. Pure — no storage read. + function variantOf(address token) external pure returns (Variant); + + /// @notice Returns true exactly once `createToken` has completed for this address. + function isTokenCreated(address token) external view returns (bool); +} +``` + +`createToken` reverts if `decimals` is out of range, if `initialAdmin == address(0)`, or if the derived address has already been claimed by an earlier `createToken` call for the same `(msg.sender, variant, salt)` triple — the caller should retry with a different `salt`. `initialAdmin` cannot be the zero address at creation time because nothing in this standard lets a privileged caller grant roles on the token's behalf after the fact ([§4](#4-rationale)): a token created with no admin would have no path for anyone to ever be granted `MINT_ROLE`, `BURN_ROLE`, or any other role, since granting a role itself requires `ADMIN_ROLE` authority that would never have existed. An issuer that wants an admin-less token grants whatever roles and configuration it needs while it still has an admin, then calls `renounceAdmin()` ([§3.7](#37-roles-and-access-control)) — see that section for the resulting guarantees. All further interaction with the created token — transfers, admin operations, role management — happens directly against the token's own address; the Registry has no ongoing role in a token's lifecycle after creation and is granted no privileges on it. + +### 3.5 Variants + +Every native token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-native-token-address-space)): + +- **Standard** (`variant = STANDARD`) — the token described in [§3.6](#36-core-token-interface) through [§3.11](#311-permit-eip-2612), with no further extension. Its fixed-unit balance accounting suits payment-oriented use cases such as stablecoins, where a token unit is expected to hold a constant meaning over time. +- **Asset** (`variant = ASSET`) — everything in the Standard variant, plus a protocol-uniform multiplier that lets an issuer rescale every holder's displayed balance in a single call ([§3.12](#312-asset-variant-multiplier)). This suits tokenized real-world assets whose per-unit value is periodically redenominated independently of any transfer activity. + +Both variants share identical roles, transfer-policy scopes, pause features, supply-cap mechanism, and permit implementation. A wallet or indexer that only understands the Standard surface can safely ignore the Asset-only multiplier methods on an Asset-variant token — `balanceOf`, `transfer`, and every other shared method behave identically either way. + +### 3.6 Core Token Interface + +Every native token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: + +```solidity +interface INativeToken { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + + // Supply-affecting, each gated by its own role (see 3.7) + function mint(address to, uint256 value) external; + function burn(uint256 value) external; + function seize(address account, uint256 value) external; + + function updateName(string calldata newName) external; + function updateSymbol(string calldata newSymbol) external; +} +``` + +`mint` increases `to`'s balance and `totalSupply` together; the call fails with `MintExceedsCap` when the resulting supply would sit above the configured cap. `burn` reduces only the caller's own balance — there is no path for `burn` to touch anyone else's funds. `seize` is scoped entirely to accounts the token has already denied under `SENDER_POLICY` ([§3.8](#38-transfer-policies)): calling it against any account still in good standing fails with `TargetNotDenylisted`, so a compliance seizure can never be the first action taken against an account — it must already be frozen before its balance can be swept. `updateName`/`updateSymbol` rewrite the corresponding metadata field, and `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. On an Asset-variant token, `balanceOf` and every method above continue to report and move raw balances only — see [§3.12](#312-asset-variant-multiplier) for how the display-facing multiplier layers on top without touching this interface. + +### 3.7 Roles and Access Control + +Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `ADMIN_ROLE`) authorized to grant or revoke it. + +```solidity +interface INativeTokenAccess { + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); + event AdminRenounced(address indexed previousAdmin); + + function hasRole(bytes32 role, address account) external view returns (bool); + function getRoleAdmin(bytes32 role) external view returns (bytes32); + function grantRole(bytes32 role, address account) external; + function revokeRole(bytes32 role, address account) external; + function renounceRole(bytes32 role, address callerConfirmation) external; + function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; + + /// @notice Irreversibly transitions the token to admin-less. Requires the caller to be + /// the sole remaining ADMIN_ROLE holder. + function renounceAdmin() external; +} +``` + +Built-in roles shared by both variants: + +| Role | Gates | +|---|---| +| `ADMIN_ROLE` | Role grants/revocations, `updatePolicy`, `updateSupplyCap` | +| `MINT_ROLE` | `mint` | +| `BURN_ROLE` | `burn` (self-burn only) | +| `SEIZE_ROLE` | `seize` | +| `PAUSE_ROLE` | `pause` | +| `UNPAUSE_ROLE` | `unpause` | +| `METADATA_ROLE` | `updateName`, `updateSymbol` | + +The Asset variant defines one additional role, `REBASE_ROLE`, gating `updateMultiplier` — see [§3.12](#312-asset-variant-multiplier). + +Minting and burning are split into separate roles so the two keys can be issued, rotated, and revoked independently — a compromised mint key and a compromised burn key are different incidents with different blast radii, and forcing an issuer to protect them as a single credential would only widen the damage either one can do. Pause and unpause are split for a narrower reason: whoever can freeze the token in an emergency should not, by the same credential, also be able to decide it's safe to unfreeze it — a single leaked or misused key should only ever be able to push the token in one direction. `SEIZE_ROLE` stands apart from `BURN_ROLE` for a different reason again: `burn` destroys funds the caller owns, `seize` destroys funds the caller does not own and only once policy has already denied the target — collapsing those into one key would let a routine treasury operation and a compliance seizure be authorized by the same signature. + +Each token tracks how many addresses currently hold `ADMIN_ROLE`, and three invariants follow from that count. First, neither `revokeRole` nor `renounceRole` can strip the last remaining holder — both fail with `SoleAdminLocked` if attempted, so admin control cannot be lost by accident. Second, choosing to give it up on purpose goes through a distinct entry point, `renounceAdmin()`, callable only by that last remaining holder — "we lost access by mistake" and "we chose to make this token immutable" are never the same code path. Third, once the holder count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` all fail unconditionally for every role, not just `ADMIN_ROLE` — there is no `setRoleAdmin`/`grantRole` detour through an unrelated custom role that reinstates an admin, because the check applies regardless of how the call was routed. + +This is a one-way transition, and it only freezes *membership changes* — it does not freeze the roles' effects. Whatever `MINT_ROLE`, `PAUSE_ROLE`, and the rest were already assigned to at the moment the last admin renounced keep working exactly as before: an existing `MINT_ROLE` holder can still call `mint`, an existing `PAUSE_ROLE` holder can still call `pause`, and any holder of any role can still give up their own membership via `renounceRole` (which needs no admin authority to begin with). What becomes impossible is granting any role to a new holder or revoking it from an existing one — both require `ADMIN_ROLE` authority, and there is none left to give that authority. Because of this, `renounceAdmin()` is only useful once the token's role assignments are already exactly as the issuer wants them permanently frozen; an issuer should grant `MINT_ROLE`, `BURN_ROLE`, and any other roles it needs while it still has an admin, and only then call `renounceAdmin()`. + +Custom, user-defined roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value; holding one has no built-in effect on any token operation and exists purely as an on-chain membership registry the issuer or third-party contracts can query — for example, as an intermediate admin role in a `setRoleAdmin` hierarchy, or as an external permission record unrelated to this token's own gated operations. + +### 3.8 Transfer Policies + +Native tokens support compliance gating on transfers and mints via three named policy scopes, each pointing at a policy in the singleton **Transfer List Registry**: + +| Scope | Checked against | +|---|---| +| `SENDER_POLICY` | The `from` of `transfer`/`transferFrom` | +| `RECEIVER_POLICY` | The `to` of `transfer`/`transferFrom` | +| `MINT_POLICY` | The `to` of `mint` | + +```solidity +interface INativeTokenPolicy { + event PolicyUpdated(bytes32 indexed scope, uint64 policyId); + + function policyId(bytes32 scope) external view returns (uint64); + function updatePolicy(bytes32 scope, uint64 policyId) external; // ADMIN_ROLE-gated +} +``` + +Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a freshly created token carries no compliance restriction until its admin opts into one. The gate applies only to the operations that actually move a balance — `transfer`, `transferFrom`, `mint` — and not to `approve`: an account denied under `SENDER_POLICY`/`RECEIVER_POLICY` can still set or hold an allowance, since granting permission to move funds is not itself a movement of funds. + +The Transfer List Registry is a separate singleton precompiled contract, independent of any specific token, so multiple tokens (or other consumers) can share the same list: + +```solidity +interface ITransferListRegistry { + enum ListType { BLOCK, ALLOW } + + event ListCreated(uint64 indexed listId, address indexed creator, ListType listType); + event ListAdminUpdated(uint64 indexed listId, address indexed previousAdmin, address indexed newAdmin); + event MembersUpdated(uint64 indexed listId, address indexed updater, bool included, address[] accounts); + + function createList(address admin, ListType listType) external returns (uint64 listId); + function updateMembers(uint64 listId, bool included, address[] calldata accounts) external; + function transferAdmin(uint64 listId, address newAdmin) external; + function renounceAdmin(uint64 listId) external; + + function isAuthorized(uint64 listId, address account) external view returns (bool); + function listExists(uint64 listId) external view returns (bool); + function listAdmin(uint64 listId) external view returns (address); +} +``` + +A `BLOCK` list authorizes everyone except the accounts explicitly added to it; an `ALLOW` list authorizes no one except the accounts explicitly added. List IDs `0` (`ALWAYS_ALLOW`) and `1` (`ALWAYS_BLOCK`) exist without any `createList` call ever being made. `isAuthorized` is built to answer for any `uint64` input, including one that was never created — an unrecognized ID is treated as an empty list of whichever type its own bit pattern implies, rather than reverting. That pushes a real obligation onto callers: `updatePolicy` MUST confirm `listExists(policyId)` before writing it into a scope, because storing a nonexistent `BLOCK`-type ID would otherwise be indistinguishable on-chain from `ALWAYS_ALLOW` — nothing would ever be denied, and nothing on-chain would say why. + +### 3.9 Pause + +```solidity +interface INativeTokenPause { + enum Feature { TRANSFER, MINT, BURN } + + event Paused(Feature feature, address account); + event Unpaused(Feature feature, address account); + + function pause(Feature feature) external; // PAUSE_ROLE-gated + function unpause(Feature feature) external; // UNPAUSE_ROLE-gated + function isPaused(Feature feature) external view returns (bool); +} +``` + +`Feature` is append-only across future protocol versions so existing positions never shift. A token is unpaused across all features at creation. `seize` is deliberately not gated by any `Feature`: `pause(BURN)` stops self-service `burn` but has no effect on `seize`, since a compliance seizure is exactly the kind of action an issuer may need to keep exercising while ordinary activity (transfers, minting, self-burns) is frozen during an incident. + +### 3.10 Supply Cap + +```solidity +interface INativeTokenSupplyCap { + event SupplyCapChanged(uint256 previousCap, uint256 newCap); + + function supplyCap() external view returns (uint256); + function updateSupplyCap(uint256 newCap) external; // ADMIN_ROLE-gated +} +``` + +Passing `type(uint256).max` to `createToken` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. + +### 3.11 Permit (EIP-2612) + +Native tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: + +```solidity +interface INativeTokenPermit { + event EIP712DomainChanged(); + + function permit( + address owner, address spender, uint256 value, + uint256 deadline, uint8 v, bytes32 r, bytes32 s + ) external; + function nonces(address owner) external view returns (uint256); + function DOMAIN_SEPARATOR() external view returns (bytes32); +} +``` + +Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-core-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. + +### 3.12 Asset Variant: Multiplier + +An Asset-variant token stores one raw balance per account, exactly like the Standard variant, plus a single token-wide scaling factor: + +```solidity +interface INativeTokenAsset { + event MultiplierChanged(uint256 previousMultiplier, uint256 newMultiplier); + + /// @notice Current scaling factor, at 1e18 (WAD) precision. 1e18 means the scaled + /// view equals the raw balance exactly. + function multiplier() external view returns (uint256); + + /// @notice Rescales every holder's displayed balance at once. Gated by REBASE_ROLE. + function updateMultiplier(uint256 newMultiplier) external; + + function toScaledBalance(uint256 rawBalance) external view returns (uint256); + function toRawBalance(uint256 scaledBalance) external view returns (uint256); + function scaledBalanceOf(address account) external view returns (uint256); +} +``` + +`balanceOf` (inherited from [§3.6](#36-core-token-interface)) always returns the raw, unscaled balance, and `multiplier()` never changes what `transfer`, `transferFrom`, `mint`, or `burn` actually move — only the display-facing conversion functions are affected. `toScaledBalance`/`toRawBalance` apply the current multiplier in each direction and exist purely as a display convenience for wallets and explorers; `scaledBalanceOf(account)` is equivalent to calling `toScaledBalance(balanceOf(account))` in a single round trip. Because the conversion divides by `1e18`, converting a raw amount to its scaled form and back is not guaranteed to reproduce the original value exactly — callers that need precise accounting MUST treat the raw balance as authoritative and use the scaled value for display only. + +`updateMultiplier` is gated by `REBASE_ROLE` rather than `ADMIN_ROLE` or `METADATA_ROLE`: a single call changes what every holder sees as their balance across the entire token, a materially larger blast radius than an ordinary metadata edit, so an issuer may want a different signer (or a slower, multisig-gated process) for this specific action than for day-to-day administration. `updateMultiplier` MUST reject a `newMultiplier` of zero, since a zero multiplier would make every account's scaled balance permanently zero with no way to recover a meaningful display value; see [§6](#6-security-considerations) for the broader range of multiplier-related risks issuers should account for. + +This section applies only to tokens created with `variant = ASSET`. A Standard-variant token does not implement `INativeTokenAsset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes `INativeTokenAsset`'s selectors, so a call to `multiplier()` or any other method in this section against a Standard-variant address reverts, exactly as calling any other selector the handler does not recognize would. + +### 3.13 Gas Accounting + +Each entry point charges a fixed gas cost via `RequiredGas`, calibrated to the actual state-access cost of the operation (comparable to the SLOAD/SSTORE cost of the equivalent BEP-20 bytecode path) plus a small fixed overhead for native dispatch, rather than being interpreted opcode-by-opcode. Exact values are a matter of implementation and benchmarking and are expected to be tuned before this BEP leaves Draft status; they MUST NOT be lower than the cost of the state accesses actually performed, to avoid opening a state-growth-vs-gas underpricing gap. + +## 4. Rationale + +**Two singleton precompiles instead of a wider set of registries.** The Token Registry only ever creates tokens; the Transfer List Registry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. + +**Hard-fork gating instead of an on-chain activation switch.** BSC already treats the hard-fork block/timestamp as the canonical readiness signal for every consensus-level change; adding a second, contract-level on/off flag for this specific feature would create two sources of truth for whether it is live, with no corresponding benefit on a chain where upgrades are already coordinated through the hard-fork process. + +**Typed creation parameters instead of an arbitrary post-deploy call bundle.** `createToken` takes explicit, typed fields (variant, admin, decimals, supply cap) rather than an open-ended list of encoded calls to replay against the freshly created token. This is simpler to specify and audit exhaustively, at the cost of not supporting arbitrary one-transaction bootstrap sequences beyond what the typed parameters cover; issuers that need more than that can compose `createToken` with immediate follow-up calls (e.g., `grantRole`, `updatePolicy`) in their own wrapping transaction or contract. + +**Deterministic, prefix-recognizable addressing, with the variant encoded directly in the address.** Encoding a fixed marker — and, a few bytes later, the variant discriminant — in the address lets any caller determine both "is this a native token" and "which variant is it" with a pure address check and no RPC round-trip, the same property BSC's own existing precompiles already have by virtue of occupying low, fixed addresses. `isNativeToken` deliberately checks only the marker, not the discriminant, so tooling built against this version of the standard keeps recognizing native-token addresses even after a future BEP adds a variant it doesn't yet know about. + +**A dedicated role for rescaling, not folded into `ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit or policy update — so it is gated by its own role ([§3.12](#312-asset-variant-multiplier)) rather than requiring full administrative authority. + +## 5. Backward Compatibility + +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-native-token-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. + +Native token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with native tokens without changes. One observable difference integrators MUST account for: `EXTCODESIZE`/`EXTCODEHASH` on a native token address returns `0`/the empty-code hash, since no bytecode is stored there — identical to the existing behavior of BSC's other precompiled addresses. Contracts that use a nonzero code size as an "is this a contract" heuristic (rather than, e.g., `isNativeToken()`) will misclassify native token addresses as externally-owned accounts. + +## 6. Security Considerations + +- **Precompile address collisions.** The addresses chosen for the Token Registry and Transfer List Registry, and the reserved native-token address-space prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status, to avoid a collision at implementation time. +- **No external calls from native token logic.** Because token operations execute as native code rather than delegated bytecode, they perform no external calls and expose no hooks (no `ERC-777`-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. +- **Irreversibility of `renounceAdmin`.** As described in [§3.7](#37-roles-and-access-control), once a token's admin count reaches zero, admin-gated operations (policy updates, supply-cap changes, role management) are permanently uncallable. This is by design, but issuers MUST treat it as one-way and irreversible. +- **`EXTCODESIZE` heuristic breakage.** See [§5](#5-backward-compatibility). Auditors of contracts that will hold or route native tokens should specifically check for this pattern. +- **Multiplier extremes and precision loss.** `toScaledBalance`/`toRawBalance` compute a product-then-divide over `1e18`; the implementation of this standard MUST use an overflow-safe wide intermediate (e.g., 512-bit `mulDiv`) so that a large raw balance combined with a large multiplier cannot overflow or silently truncate the view functions. An extreme multiplier (very large or very small) can still produce a scaled value that is either out of any meaningful range or rounds down to zero for small holders; issuers SHOULD bound the multiplier values they are willing to set at the application layer, since the protocol itself only rejects zero. +- **Multiplier-update centralization.** `REBASE_ROLE` can materially change what every holder believes their Asset-variant holding is worth, in display terms, without moving a single raw token. Issuers SHOULD use a multisig or a timelocked process for this role rather than a single hot key, given how directly it affects user-facing balances. +- **Consensus-level blast radius.** Because this logic runs as native client code rather than an isolated smart contract, a single implementation bug can affect every native token at once — every token of the affected variant at minimum, and every native token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher bar of testing (fuzzing, differential testing against a BEP-20 reference contract) than a typical single-contract deployment before mainnet activation. +- **Transfer List Registry admin key compromise.** Compromise of a list's admin key affects every token that references that list's ID in a policy scope; issuers sharing a list across multiple tokens should weigh this concentration of risk against the convenience of reuse. + +## 7. License + +The content is licensed under [CC0](https://creativecommons.org/publicdomain/zero/1.0/). diff --git a/README.md b/README.md index a7c3b346..c30a1369 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Here is the list of subjects of BEPs: | [BEP-677](./BEPs/BEP-677.md) | Implement EIP-8056 Scaled UI Amount | Standards | Draft | | [BEP-682](./BEPs/BEP-682.md) | Reject Duplicate Validators in CometBFT Light Block Validation | Standards | Draft | | [BEP-695](./BEPs/BEP-695.md) | Staking and Governance Security Hardening | Standards | Draft | +| [BEP-702](./BEPs/BEP-702.md) | New Tokens on BNB Smart Chain | Standards | Draft | # BAPs BAP (BNB Application Proposal) defines standards for application layer interactions on BNB Chain. Unlike BEPs which govern core protocol changes, BAPs focus on establishing conventions and interfaces for how applications communicate and interact with each other within the BNB Chain ecosystem. From fcb5320efb45344a8451f80870c8fd41cff047ad Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 14:47:13 +0800 Subject: [PATCH 02/41] BEP-702: align with B-20 design doc Reconciles the BEP against the authoritative B-20 design document, which diverged from it in five places and covered thirteen features it omitted. Corrections (the BEP previously specified the opposite): - Address layout is 0xb2 + 9 zero bytes + variant at byte[10] + a 9-byte fingerprint, not 0xBC20 + 7 zeros + variant at byte[9] + 10 bytes. - Variants are Asset (0x00) and Stablecoin (0x01). The previous STANDARD/ASSET pair gave 0x00 the opposite meaning. - initialAdmin may be the zero address, yielding a token immutable from birth. It was previously forbidden. - Creation replays caller-supplied initCalls in a privileged bootstrap window. Section 4 previously argued against exactly this. - decimals is [6,18] for Asset only; Stablecoin is fixed at 6, unstored. Added: - memo surface over transfer/transferFrom/mint/burn, emitting a separate indexed Memo event rather than widening Transfer - TRANSFER_EXECUTOR_POLICY, a fourth scope constraining who may move another account's balance - pause as a TRANSFER/MINT/BURN bitmask taking arrays - Stablecoin currency() as a new section 3.13, an immutable ISO-4217 code - Asset announce/batchMint/extraMetadata, and contractURI per ERC-7572 - two-step policy admin handover, 64-address batch cap, createPolicyWithAccounts, checkActivated - ActivationRegistry as a distinct fourth component, with the Activation -> RBAC -> Policy gating table Naming follows the doc throughout: B20Factory, PolicyRegistry, IB20*, BURN_BLOCKED_ROLE, OPERATOR_ROLE, DEFAULT_ADMIN_ROLE, renounceLastAdmin. Four deliberate departures from the doc: - PolicyRegistry and ActivationRegistry addresses are left unallocated; the doc's 0x8453... values encode Base's chain ID. - setActivationAdmin is kept. The doc's registry has no admin rotation, but 6.2 relies on remediating a compromised key without a hard fork. - The multiplier keeps its uint128 bound. The doc requires only non-zero, which would let raw * multiplier overflow given a uint128 supply cap. - 6.3 is recalculated for the now 72-bit fingerprint. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 593 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 447 insertions(+), 146 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 24fdf4c6..e6d2fcdb 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -15,25 +15,34 @@ - [3. Specification](#3-specification) - [3.1 Architecture Overview](#31-architecture-overview) - [3.2 Stateful Precompiled Contracts](#32-stateful-precompiled-contracts) - - [3.3 Native Token Address Space](#33-native-token-address-space) - - [3.4 Token Registry](#34-token-registry) + - [3.3 B-20 Address Space](#33-b-20-address-space) + - [3.4 B20Factory](#34-b20factory) - [3.5 Variants](#35-variants) - - [3.6 Core Token Interface](#36-core-token-interface) + - [3.6 Shared Token Interface](#36-shared-token-interface) - [3.7 Roles and Access Control](#37-roles-and-access-control) - [3.8 Transfer Policies](#38-transfer-policies) - [3.9 Pause](#39-pause) - [3.10 Supply Cap](#310-supply-cap) - [3.11 Permit (EIP-2612)](#311-permit-eip-2612) - - [3.12 Asset Variant: Multiplier](#312-asset-variant-multiplier) - - [3.13 Gas Accounting](#313-gas-accounting) + - [3.12 Asset Variant Extensions](#312-asset-variant-extensions) + - [3.13 Stablecoin Variant Extension](#313-stablecoin-variant-extension) + - [3.14 Gas Accounting](#314-gas-accounting) + - [3.15 Feature Activation](#315-feature-activation) - [4. Rationale](#4-rationale) - [5. Backward Compatibility](#5-backward-compatibility) - [6. Security Considerations](#6-security-considerations) + - [6.1 Consensus-Level Blast Radius](#61-consensus-level-blast-radius) + - [6.2 Activation Authority](#62-activation-authority) + - [6.3 Identity-Fingerprint Strength](#63-identity-fingerprint-strength) + - [6.4 Privileged Keys](#64-privileged-keys) + - [6.5 Multiplier Bounds and Precision](#65-multiplier-bounds-and-precision) + - [6.6 State Growth](#66-state-growth) + - [6.7 Integration Assumptions](#67-integration-assumptions) - [7. License](#7-license) ## 1. Summary -This BEP introduces a protocol-native fungible token standard for BSC, in two variants — a payment-oriented **Standard** form and an **Asset** form carrying a protocol-level rescaling mechanism for tokenized real-world assets. Tokens created under this standard are not deployed bytecode: they are instantiated through a singleton precompiled contract and executed by native node logic, while remaining fully selector- and event-compatible with [BEP-20](./BEP20.md)/[ERC-20](https://eips.ethereum.org/EIPS/eip-20). The standard bundles the access-control, pausability, supply-cap, and transfer-compliance primitives that regulated and institutional issuers currently have to re-implement (and re-audit) individually on top of BEP-20. +This BEP introduces a protocol-native fungible token standard for BSC, in two variants — an **Asset** form carrying a protocol-level rescaling mechanism for tokenized real-world assets, and a deliberately narrow **Stablecoin** form for payment tokens. Tokens created under this standard are not deployed bytecode: they are instantiated through a singleton factory precompile and executed by native node logic, while remaining fully selector- and event-compatible with [BEP-20](./BEP20.md)/[ERC-20](https://eips.ethereum.org/EIPS/eip-20). The standard bundles the access-control, pausability, supply-cap, reconciliation-memo, and transfer-compliance primitives that regulated and institutional issuers currently have to re-implement (and re-audit) individually on top of BEP-20. ## 2. Motivation @@ -51,15 +60,36 @@ A protocol-native token module addresses all of these at once: behavior is fixed ### 3.1 Architecture Overview -Three new pieces of node logic are introduced: +Four new pieces of node logic are introduced: 1. A new class of **stateful precompiled contract** ([§3.2](#32-stateful-precompiled-contracts)), extending BSC's existing (stateless) precompile mechanism with StateDB access, caller/value context, and log emission. -2. A singleton **Token Registry** precompiled contract ([§3.4](#34-token-registry)), the sole entry point for creating a new native token, in one of two variants ([§3.5](#35-variants)). -3. A singleton **Transfer List Registry** precompiled contract ([§3.8](#38-transfer-policies)), holding reusable allow-lists and block-lists that native tokens reference for transfer-compliance checks. +2. A singleton **B20Factory** ([§3.4](#34-b20factory)), the sole entry point for creating a B-20 token, in one of two variants ([§3.5](#35-variants)). +3. A singleton **PolicyRegistry** ([§3.8](#38-transfer-policies)), holding reusable allowlists and blocklists that B-20 tokens reference for transfer-compliance checks. +4. A singleton **ActivationRegistry** ([§3.15](#315-feature-activation)), a per-feature governance switch controlling what may be created on a given network. -Every native token created through the Token Registry is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-native-token-address-space)). No bytecode is ever stored at that address; instead, the EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared native token handler, parameterized by the target address. The handler decodes the variant discriminant directly from the target address to decide which method set to expose — the shared [§3.6](#36-core-token-interface) surface alone for a Standard-variant address, or that surface plus [§3.12](#312-asset-variant-multiplier) for an Asset-variant one — and then the target address alone determines which token's state (balances, allowances, roles, policy IDs, pause bits, supply cap, and — for the Asset variant — multiplier) is read and written. This is exactly the same pattern the singleton Token Registry and Transfer List Registry already use: one piece of code serving every caller, differentiated only by address and, here, by the byte encoding the variant. +The three singletons occupy fixed addresses, identical on every network: -Activation of this standard is gated by the hard fork in which it ships, consistent with every other consensus-level change in this repository; no separate on-chain feature-flag contract is introduced (see [§4](#4-rationale)). +| Contract | Address | +|---|---| +| B20Factory | `0xB20F000000000000000000000000000000000000` | +| ActivationRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | +| PolicyRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | + +B-20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b-20-address-space)). + +Authorization is layered, and each layer answers a different question: + +| Layer | Question | Controlled by | +|---|---|---| +| ActivationRegistry | Is this feature open on this chain at all? | Chain governance | +| RBAC ([§3.7](#37-roles-and-access-control)) | Is this caller allowed to perform this operation? | The token's issuer | +| PolicyRegistry ([§3.8](#38-transfer-policies)) | Is this address allowed to be operated on? | The policy's admin | + +A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. + +Every B-20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b-20-address-space)). No bytecode is ever stored there. Instead, the EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B-20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the two registries use: one piece of code serving every caller, differentiated only by address. + +Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and lists, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). ### 3.2 Stateful Precompiled Contracts @@ -73,7 +103,7 @@ type PrecompiledContract interface { } ``` -This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A native token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: +This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A B-20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: ```go type StatefulPrecompiledContract interface { @@ -84,88 +114,137 @@ type StatefulPrecompiledContract interface { The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. -### 3.3 Native Token Address Space +Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the B20Factory, the PolicyRegistry, and a B-20 token observes the following rules: -A native token address is 20 bytes: +| Call form | Behavior | +|---|---| +| `CALL` with zero value | Permitted; the normal path | +| `CALL` with non-zero value | Reverts. Every entry point is nonpayable. This MUST be enforced, not merely documented: the EVM performs the value transfer *before* dispatching, so an entry point that ignored a non-zero value would leave it permanently stranded at an address from which this standard provides no path of withdrawal | +| `STATICCALL` | Permitted for reads; any state-mutating entry point fails with a write-protection error | +| `DELEGATECALL` / `CALLCODE` | Reverts. Under delegation the callee address no longer identifies whose state is being addressed, so the storage owner could not be determined unambiguously | + +All state mutations, logs, and any attached value transfer are reverted together with the enclosing call frame when an entry point fails. + +### 3.3 B-20 Address Space + +A B-20 token address is 20 bytes: | Bytes | Length | Content | Meaning | |---|---|---|---| -| `[0:2]` | 2 | `0xBC20` | Fixed marker identifying the address as a native token | -| `[2:9]` | 7 | `0x00` × 7 | Reserved padding — makes accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | -| `[9]` | 1 (the 10th byte) | Variant discriminant | `0x00` = Standard, `0x01` = Asset ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | -| `[10:20]` | 10 | `keccak256(creator ++ salt)[0:10]` | Identity fingerprint, where `creator` is the account that called the Token Registry and `salt` is caller-supplied entropy | +| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a B-20 token | +| `[1:10]` | 9 | `0x00` × 9 | Namespace padding — together with `[0]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | +| `[10]` | 1 (the 11th byte) | Variant discriminant | `0x00` = Asset, `0x01` = Stablecoin ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | +| `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the B20Factory and `salt` is caller-supplied entropy | + +Three helpers follow from the layout alone, none requiring a storage read: + +- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a B-20 address by existing tooling. +- `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). +- `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. + +The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a B-20 token address. + +Lying inside the reserved space is not the same as existing. An address may match the marker while no `createB20` call has ever produced it, and the two cases must be distinguished: -`isNativeToken(address) -> bool` checks only bytes `[0:9]` against the fixed pattern above — no storage read is required, and it deliberately does not inspect the variant byte, so that a future variant this standard does not yet define is still recognized as a native token by existing tooling. `variantOf(address) -> Variant` reads byte `[9]` on its own, again with no storage read; this is the same byte the call-dispatch handler consults to decide which method set a given address exposes ([§3.1](#31-architecture-overview)). `getTokenAddress(creator, variant, salt) -> address` (a view method on the Token Registry) lets callers predict a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. +| Target address | Behavior | +|---|---| +| Marker matches, `isB20Initialized` true, variant recognized | Routed to the B-20 token handler bound to that address | +| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | +| Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | +| Marker does not match | Ordinary account, unchanged | + +Existence MUST be determined solely from the Registry's own record, and MUST NOT be inferred from the target's code, nonce, or balance: no bytecode is ever stored at a B-20 token address, and any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. -The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:9]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a native token address. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createB20` SHOULD reject a derived address in that condition. -### 3.4 Token Registry +### 3.4 B20Factory -The Token Registry is a singleton precompiled contract. Its interface: +The B20Factory is the singleton entry point for token creation. Its interface: ```solidity -interface INativeTokenRegistry { - enum Variant { STANDARD, ASSET } - - event TokenCreated(address indexed token, address indexed creator, Variant variant, string name, string symbol, uint8 decimals); - - /// @notice Creates a new native token and returns its deterministic address. - /// @param variant STANDARD or ASSET; permanently fixed in the token's address. - /// @param name Token name. - /// @param symbol Token symbol. - /// @param decimals Number of decimals, in the inclusive range [0, 18]. - /// @param initialAdmin The first holder of ADMIN_ROLE. MUST NOT be the zero address; see 3.7 - /// for how to reach an admin-less token after granting whatever other roles it needs. - /// @param supplyCap Maximum total supply. type(uint256).max means uncapped. - /// @param salt Caller-chosen entropy; combined with msg.sender and variant to derive the token's address. - function createToken( +interface IB20Factory { + enum Variant { ASSET, STABLECOIN } + + event B20Created( + address indexed token, address indexed creator, Variant variant, + string name, string symbol, bytes variantEventParams + ); + + /// @notice Creates a B-20 token and runs its full initialization in one transaction. + /// @param variant ASSET or STABLECOIN; permanently fixed in byte[10] of the token's address. + /// @param salt Caller-chosen entropy; combined with msg.sender to derive the token's address. + /// @param params ABI-encoded B20AssetCreateParams or B20StablecoinCreateParams (see below). + /// @param initCalls Calls executed against the new token inside the bootstrap window. + function createB20( Variant variant, - string calldata name, - string calldata symbol, - uint8 decimals, - address initialAdmin, - uint256 supplyCap, - bytes32 salt + bytes32 salt, + bytes calldata params, + bytes[] calldata initCalls ) external returns (address token); - /// @notice Predicts the address `createToken` would produce for (msg.sender, variant, salt), without deploying. - function getTokenAddress(address creator, Variant variant, bytes32 salt) external view returns (address); + /// @notice Predicts the address `createB20` would produce, without creating anything. + function getB20Address(Variant variant, address creator, bytes32 salt) external view returns (address); - /// @notice Returns true if `account` lies in the reserved native-token address space (see 3.3). - /// Does not imply a token has actually been created at that address; see `isTokenCreated`. - function isNativeToken(address account) external pure returns (bool); + /// @notice True if `account` lies in the reserved B-20 address space (see 3.3). + /// Does not imply a token exists there; see `isB20Initialized`. + function isB20(address account) external pure returns (bool); - /// @notice Decodes the variant discriminant directly from `token`'s address. Pure — no storage read. + /// @notice Decodes the variant discriminant from the address. Pure — no storage read. function variantOf(address token) external pure returns (Variant); - /// @notice Returns true exactly once `createToken` has completed for this address. - function isTokenCreated(address token) external view returns (bool); + /// @notice True exactly once `createB20` has completed for this address. + function isB20Initialized(address token) external view returns (bool); } ``` -`createToken` reverts if `decimals` is out of range, if `initialAdmin == address(0)`, or if the derived address has already been claimed by an earlier `createToken` call for the same `(msg.sender, variant, salt)` triple — the caller should retry with a different `salt`. `initialAdmin` cannot be the zero address at creation time because nothing in this standard lets a privileged caller grant roles on the token's behalf after the fact ([§4](#4-rationale)): a token created with no admin would have no path for anyone to ever be granted `MINT_ROLE`, `BURN_ROLE`, or any other role, since granting a role itself requires `ADMIN_ROLE` authority that would never have existed. An issuer that wants an admin-less token grants whatever roles and configuration it needs while it still has an admin, then calls `renounceAdmin()` ([§3.7](#37-roles-and-access-control)) — see that section for the resulting guarantees. All further interaction with the created token — transfers, admin operations, role management — happens directly against the token's own address; the Registry has no ongoing role in a token's lifecycle after creation and is granted no privileges on it. +Creation parameters are variant-specific: + +| Variant | Fields | Validation | +|---|---|---| +| `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | +| `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | + +`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b-20-address-space)); validate `params`; reject a derived address that already holds a token (`TokenAlreadyExists` — retry with a different `salt`); write the initial storage and the factory-created marker that `isB20Initialized` reads; execute `initCalls`; emit `B20Created`. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. + +**The bootstrap window.** `initCalls` are executed by the factory against the new token as *privileged* calls, in order, atomically with creation. This exists because a token is born with no role holders at all: without a privileged window, granting the first `MINT_ROLE` would already require an authority that does not yet exist. Inside the window the factory skips the role gate and the transfer-side policy gates. Three checks are **never** skipped, on any path: + +- `MINT_RECEIVER` policy ([§3.8](#38-transfer-policies)) — the bootstrap may not mint to an address the token's own compliance configuration forbids. +- Pause state ([§3.9](#39-pause)) and the supply cap ([§3.10](#310-supply-cap)). +- The admin anti-resurrection guard ([§3.7](#37-roles-and-access-control)) — a token that has renounced its last admin can never regain one, and routing the grant through the bootstrap path does not change that. + +Any failing `initCall` reverts the entire creation. A call shorter than four bytes is rejected with `InternalCallMalformed`. + +**Admin-less tokens.** `initialAdmin` MAY be the zero address. Combined with `initCalls`, this is how an issuer creates a permanently immutable token: roles, policies, supply cap, and initial distribution are all configured inside the bootstrap window, after which no admin exists and no role assignment can ever change ([§3.7](#37-roles-and-access-control)). An issuer that wants an admin during setup and immutability afterwards instead passes a real `initialAdmin` and calls `renounceLastAdmin()` when finished. Both routes reach the same terminal state; they differ only in whether an admin key ever existed. + +`decimals` is fixed at creation and never changes. For the Asset variant, the lower bound of `6` exists because less precision cannot represent the sub-unit amounts that asset use cases routinely need, and `18` is the widest precision the surrounding ecosystem handles uniformly. Issuers SHOULD prefer `18` where the multiplier will be used: every multiplier-derived read floors ([§3.12](#312-asset-variant-extensions)), so at low precision a large reverse rescaling can leave rounding dust that is economically visible. ### 3.5 Variants -Every native token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-native-token-address-space)): +Every B-20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-b-20-address-space)): -- **Standard** (`variant = STANDARD`) — the token described in [§3.6](#36-core-token-interface) through [§3.11](#311-permit-eip-2612), with no further extension. Its fixed-unit balance accounting suits payment-oriented use cases such as stablecoins, where a token unit is expected to hold a constant meaning over time. -- **Asset** (`variant = ASSET`) — everything in the Standard variant, plus a protocol-uniform multiplier that lets an issuer rescale every holder's displayed balance in a single call ([§3.12](#312-asset-variant-multiplier)). This suits tokenized real-world assets whose per-unit value is periodically redenominated independently of any transfer activity. +- **Asset** (`variant = ASSET`, byte `0x00`) — the shared surface of [§3.6](#36-shared-token-interface) through [§3.11](#311-permit-eip-2612), plus the extensions of [§3.12](#312-asset-variant-extensions): a protocol-uniform multiplier, on-chain announcements, batch minting, and free-form extra metadata. `decimals` is chosen at creation in the inclusive range `[6, 18]`. This suits tokenized real-world assets — funds, equities, bonds — whose per-unit value is periodically redenominated independently of any transfer activity. +- **Stablecoin** (`variant = STABLECOIN`, byte `0x01`) — the shared surface plus exactly one extension, an immutable `currency()` code ([§3.13](#313-stablecoin-variant-extension)). `decimals` is fixed at `6` and is not stored. The variant is deliberately narrow: a unit is expected to hold a constant meaning over time, so nothing that could rescale or restate it is exposed. -Both variants share identical roles, transfer-policy scopes, pause features, supply-cap mechanism, and permit implementation. A wallet or indexer that only understands the Standard surface can safely ignore the Asset-only multiplier methods on an Asset-variant token — `balanceOf`, `transfer`, and every other shared method behave identically either way. +Both variants share identical roles, transfer-policy scopes, pause features, supply-cap mechanism, memo surface, and permit implementation — the shared traits execute the same code for both. A wallet or indexer that understands only the shared surface can safely ignore the variant-specific methods: `balanceOf`, `transfer`, and every other shared method behave identically either way. -### 3.6 Core Token Interface +### 3.6 Shared Token Interface -Every native token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: +Every B-20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: ```solidity -interface INativeToken { +interface IB20 { event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); + event Memo(address indexed caller, bytes32 indexed memo); + event NameUpdated(string newName); + event SymbolUpdated(string newSymbol); + event ContractURIUpdated(); + event BurnedBlocked(address indexed from, uint256 value); function name() external view returns (string memory); function symbol() external view returns (string memory); function decimals() external view returns (uint8); + function contractURI() external view returns (string memory); function totalSupply() external view returns (uint256); function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 value) external returns (bool); @@ -173,28 +252,59 @@ interface INativeToken { function approve(address spender, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool); + // Memo variants — identical semantics, plus one Memo event (see below) + function transferWithMemo(address to, uint256 value, bytes32 memo) external returns (bool); + function transferFromWithMemo(address from, address to, uint256 value, bytes32 memo) external returns (bool); + function mintWithMemo(address to, uint256 value, bytes32 memo) external; + function burnWithMemo(uint256 value, bytes32 memo) external; + // Supply-affecting, each gated by its own role (see 3.7) function mint(address to, uint256 value) external; function burn(uint256 value) external; - function seize(address account, uint256 value) external; + function burnBlocked(address from, uint256 value) external; function updateName(string calldata newName) external; function updateSymbol(string calldata newSymbol) external; + function updateContractURI(string calldata newURI) external; } ``` -`mint` increases `to`'s balance and `totalSupply` together; the call fails with `MintExceedsCap` when the resulting supply would sit above the configured cap. `burn` reduces only the caller's own balance — there is no path for `burn` to touch anyone else's funds. `seize` is scoped entirely to accounts the token has already denied under `SENDER_POLICY` ([§3.8](#38-transfer-policies)): calling it against any account still in good standing fails with `TargetNotDenylisted`, so a compliance seizure can never be the first action taken against an account — it must already be frozen before its balance can be swept. `updateName`/`updateSymbol` rewrite the corresponding metadata field, and `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. On an Asset-variant token, `balanceOf` and every method above continue to report and move raw balances only — see [§3.12](#312-asset-variant-multiplier) for how the display-facing multiplier layers on top without touching this interface. +- `mint` increases `to`'s balance and `totalSupply` together, failing with `SupplyCapExceeded` if the result would exceed the configured cap. +- `burn` reduces only the caller's own balance; it has no path to anyone else's funds. It is nonetheless role-gated, so an issuer controls who may retire supply. +- `burnBlocked` is scoped entirely to accounts already denied under `TRANSFER_SENDER_POLICY` ([§3.8](#38-transfer-policies)). Against an account in good standing it fails with `AccountNotBlocked`. This makes the enforcement sequence structural rather than procedural: an address MUST be frozen by policy before its balance can be swept, so a seizure can never be the first action taken against it. It emits `BurnedBlocked` in addition to the `Transfer` to the zero address, giving indexers a distinguishable enforcement record. +- `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. + +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`. Behavior is identical to the base method, plus one `Memo` event emitted immediately after the operation's own event. A memo of zero is permitted. The memo is a reconciliation reference — invoice number, customer ID, cost centre, purchase order — mirroring the structured reference that accompanies a payment in traditional systems. Both fields are indexed so an indexer can filter by payer or by reference without scanning. Data exceeding 32 bytes, or carrying personal information, SHOULD be committed by hash with the payload held off-chain rather than expanded on-chain. + +Emitting the memo as a **separate event** rather than widening `Transfer` is deliberate: `Transfer`'s signature is the single most depended-upon ABI in the ecosystem, and altering it would break every existing indexer. A separate event costs one additional log and leaves the compatibility surface untouched. + +On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. + +Because these selectors are the compatibility surface every integrator relies on, the boundary cases MUST be fixed by this standard rather than left to the implementation: + +| Case | Required behavior | +|---|---| +| `transfer`/`transferFrom`/`mint` to the zero address | Reverts. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would silently strand supply instead | +| Zero-value `transfer`/`transferFrom` | Succeeds and emits `Transfer`, per ERC-20 | +| Zero-value `mint`/`burn` | Succeeds and emits the corresponding `Transfer`, leaving `totalSupply` unchanged | +| `approve` to the zero address | Reverts | +| An allowance of `type(uint256).max` | Treated as unlimited and never decremented | +| Allowance decrement inside `transferFrom` | Does **not** emit an additional `Approval`; only `approve` and `permit` emit it | +| `transfer`/`transferFrom` where `from == to` | Succeeds and emits `Transfer`, leaving the balance unchanged | +| Insufficient balance or allowance, or a supply-cap breach | Reverts with the corresponding error; no partial state change | + +Every failure mode in this interface reverts with a specific error rather than returning `false`, so integrators never have to distinguish a rejected transfer from a successful one by inspecting the return value. ### 3.7 Roles and Access Control -Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `ADMIN_ROLE`) authorized to grant or revoke it. +Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `DEFAULT_ADMIN_ROLE`) authorized to grant or revoke it. ```solidity -interface INativeTokenAccess { +interface IB20Roles { event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); - event AdminRenounced(address indexed previousAdmin); + event LastAdminRenounced(address indexed previousAdmin); function hasRole(bytes32 role, address account) external view returns (bool); function getRoleAdmin(bytes32 role) external view returns (bytes32); @@ -204,113 +314,154 @@ interface INativeTokenAccess { function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; /// @notice Irreversibly transitions the token to admin-less. Requires the caller to be - /// the sole remaining ADMIN_ROLE holder. - function renounceAdmin() external; + /// the sole remaining DEFAULT_ADMIN_ROLE holder. + function renounceLastAdmin() external; } ``` Built-in roles shared by both variants: -| Role | Gates | -|---|---| -| `ADMIN_ROLE` | Role grants/revocations, `updatePolicy`, `updateSupplyCap` | -| `MINT_ROLE` | `mint` | -| `BURN_ROLE` | `burn` (self-burn only) | -| `SEIZE_ROLE` | `seize` | -| `PAUSE_ROLE` | `pause` | -| `UNPAUSE_ROLE` | `unpause` | -| `METADATA_ROLE` | `updateName`, `updateSymbol` | +| Role | ID | Gates | +|---|---|---| +| `DEFAULT_ADMIN_ROLE` | `bytes32(0)` | Role grants/revocations, `setRoleAdmin`, `updatePolicy`, `updateSupplyCap` | +| `MINT_ROLE` | `keccak256("MINT_ROLE")` | `mint`, `mintWithMemo`, `batchMint` | +| `BURN_ROLE` | `keccak256("BURN_ROLE")` | `burn`, `burnWithMemo` (self-burn only) | +| `BURN_BLOCKED_ROLE` | `keccak256("BURN_BLOCKED_ROLE")` | `burnBlocked` | +| `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")` | `pause` | +| `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")` | `unpause` | +| `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, `updateExtraMetadata` | + +The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). -The Asset variant defines one additional role, `REBASE_ROLE`, gating `updateMultiplier` — see [§3.12](#312-asset-variant-multiplier). +Three splits are deliberate. **Mint and burn** are separate keys so the two can be issued, rotated, and revoked independently: a compromised mint key and a compromised burn key are different incidents with different blast radii. **Pause and unpause** are separate so a single leaked key can only ever push the token in one direction — an attacker holding one cannot complete a stop-then-resume cycle. **`BURN_BLOCKED_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `burnBlocked` destroys funds it does not own, and only once policy has already denied the target. Collapsing them would let one signature authorize both. -Minting and burning are split into separate roles so the two keys can be issued, rotated, and revoked independently — a compromised mint key and a compromised burn key are different incidents with different blast radii, and forcing an issuer to protect them as a single credential would only widen the damage either one can do. Pause and unpause are split for a narrower reason: whoever can freeze the token in an emergency should not, by the same credential, also be able to decide it's safe to unfreeze it — a single leaked or misused key should only ever be able to push the token in one direction. `SEIZE_ROLE` stands apart from `BURN_ROLE` for a different reason again: `burn` destroys funds the caller owns, `seize` destroys funds the caller does not own and only once policy has already denied the target — collapsing those into one key would let a routine treasury operation and a compliance seizure be authorized by the same signature. +Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. -Each token tracks how many addresses currently hold `ADMIN_ROLE`, and three invariants follow from that count. First, neither `revokeRole` nor `renounceRole` can strip the last remaining holder — both fail with `SoleAdminLocked` if attempted, so admin control cannot be lost by accident. Second, choosing to give it up on purpose goes through a distinct entry point, `renounceAdmin()`, callable only by that last remaining holder — "we lost access by mistake" and "we chose to make this token immutable" are never the same code path. Third, once the holder count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` all fail unconditionally for every role, not just `ADMIN_ROLE` — there is no `setRoleAdmin`/`grantRole` detour through an unrelated custom role that reinstates an admin, because the check applies regardless of how the call was routed. +**The last admin.** Each token maintains a count of `DEFAULT_ADMIN_ROLE` holders — the one piece of state this model adds over the conventional `AccessControl` layout — and three protections follow from it: -This is a one-way transition, and it only freezes *membership changes* — it does not freeze the roles' effects. Whatever `MINT_ROLE`, `PAUSE_ROLE`, and the rest were already assigned to at the moment the last admin renounced keep working exactly as before: an existing `MINT_ROLE` holder can still call `mint`, an existing `PAUSE_ROLE` holder can still call `pause`, and any holder of any role can still give up their own membership via `renounceRole` (which needs no admin authority to begin with). What becomes impossible is granting any role to a new holder or revoking it from an existing one — both require `ADMIN_ROLE` authority, and there is none left to give that authority. Because of this, `renounceAdmin()` is only useful once the token's role assignments are already exactly as the issuer wants them permanently frozen; an issuer should grant `MINT_ROLE`, `BURN_ROLE`, and any other roles it needs while it still has an admin, and only then call `renounceAdmin()`. +- Neither `revokeRole` nor `renounceRole` can strip the last remaining holder; both fail with `LastAdminCannotRenounce`, so admin control cannot be lost by accident. +- Giving it up deliberately goes through a distinct entry point, `renounceLastAdmin()`, callable only by the sole holder (`NotSoleAdmin` otherwise). "We lost access by mistake" and "we chose to make this token immutable" are never the same code path — the dangerous action has to be named explicitly. +- Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-b20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. -Custom, user-defined roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value; holding one has no built-in effect on any token operation and exists purely as an on-chain membership registry the issuer or third-party contracts can query — for example, as an intermediate admin role in a `setRoleAdmin` hierarchy, or as an external permission record unrelated to this token's own gated operations. +`renounceRole` requires `callerConfirmation == msg.sender` (`AccessControlBadConfirmation` otherwise), guarding against a mis-signed or replayed calldata; renouncing a role the caller does not hold succeeds silently, matching `AccessControl` semantics. + +The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-b20factory)). + +Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. ### 3.8 Transfer Policies -Native tokens support compliance gating on transfers and mints via three named policy scopes, each pointing at a policy in the singleton **Transfer List Registry**: +Every B-20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: -| Scope | Checked against | -|---|---| -| `SENDER_POLICY` | The `from` of `transfer`/`transferFrom` | -| `RECEIVER_POLICY` | The `to` of `transfer`/`transferFrom` | -| `MINT_POLICY` | The `to` of `mint` | +| Scope | Checked against | Applies to | +|---|---|---| +| `TRANSFER_SENDER_POLICY` | the `from` | `transfer`, `transferFrom` | +| `TRANSFER_RECEIVER_POLICY` | the `to` | `transfer`, `transferFrom` | +| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transferFrom` only, and only when `msg.sender != from` | +| `MINT_RECEIVER_POLICY` | the `to` | `mint`, `mintWithMemo`, `batchMint` — including inside the factory bootstrap window | ```solidity -interface INativeTokenPolicy { +interface IB20Policy { event PolicyUpdated(bytes32 indexed scope, uint64 policyId); function policyId(bytes32 scope) external view returns (uint64); - function updatePolicy(bytes32 scope, uint64 policyId) external; // ADMIN_ROLE-gated + function updatePolicy(bytes32 scope, uint64 policyId) external; // DEFAULT_ADMIN_ROLE-gated } ``` -Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a freshly created token carries no compliance restriction until its admin opts into one. The gate applies only to the operations that actually move a balance — `transfer`, `transferFrom`, `mint` — and not to `approve`: an account denied under `SENDER_POLICY`/`RECEIVER_POLICY` can still set or hold an allowance, since granting permission to move funds is not itself a movement of funds. +`TRANSFER_EXECUTOR_POLICY` is the axis that has no equivalent in a plain BEP-20 blocklist: it constrains *who may move someone else's balance*, independently of whether either party is itself authorized. A regulated asset can require that delegated transfers be executed only by licensed brokers, while holders remain under a separate investor allowlist. It is checked only for genuine third-party transfers — a `transferFrom` where the spender is the owner is not a delegated transfer and is not subject to it. + +Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a freshly created token is completely open and compliance is opt-in. The gate applies only to operations that move a balance, never to `approve`: an account denied under any scope can still set or hold an allowance, since granting permission to move funds is not itself a movement of funds. `burn` (self-burn) and all reads are likewise ungated. -The Transfer List Registry is a separate singleton precompiled contract, independent of any specific token, so multiple tokens (or other consumers) can share the same list: +The PolicyRegistry is a separate singleton precompiled contract, independent of any specific token, so many tokens can share one list — the central reason for hoisting compliance state out of the token: ```solidity -interface ITransferListRegistry { - enum ListType { BLOCK, ALLOW } +interface IPolicyRegistry { + enum PolicyType { BLOCKLIST, ALLOWLIST } - event ListCreated(uint64 indexed listId, address indexed creator, ListType listType); - event ListAdminUpdated(uint64 indexed listId, address indexed previousAdmin, address indexed newAdmin); - event MembersUpdated(uint64 indexed listId, address indexed updater, bool included, address[] accounts); + event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); + event PolicyAdminStaged(uint64 indexed policyId, address indexed nominee); + event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); + event MembersUpdated(uint64 indexed policyId, address indexed updater, bool included, address[] accounts); - function createList(address admin, ListType listType) external returns (uint64 listId); - function updateMembers(uint64 listId, bool included, address[] calldata accounts) external; - function transferAdmin(uint64 listId, address newAdmin) external; - function renounceAdmin(uint64 listId) external; + function createPolicy(address admin, PolicyType policyType) external returns (uint64 policyId); + function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) + external returns (uint64 policyId); - function isAuthorized(uint64 listId, address account) external view returns (bool); - function listExists(uint64 listId) external view returns (bool); - function listAdmin(uint64 listId) external view returns (address); + function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; + function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; + + function stageUpdateAdmin(uint64 policyId, address newAdmin) external; // current admin + function finalizeUpdateAdmin(uint64 policyId) external; // nominee only + function renounceAdmin(uint64 policyId) external; // freezes the policy + + function isAuthorized(uint64 policyId, address account) external view returns (bool); + function policyExists(uint64 policyId) external view returns (bool); + function policyAdmin(uint64 policyId) external view returns (address); + function pendingPolicyAdmin(uint64 policyId) external view returns (address); } ``` -A `BLOCK` list authorizes everyone except the accounts explicitly added to it; an `ALLOW` list authorizes no one except the accounts explicitly added. List IDs `0` (`ALWAYS_ALLOW`) and `1` (`ALWAYS_BLOCK`) exist without any `createList` call ever being made. `isAuthorized` is built to answer for any `uint64` input, including one that was never created — an unrecognized ID is treated as an empty list of whichever type its own bit pattern implies, rather than reverting. That pushes a real obligation onto callers: `updatePolicy` MUST confirm `listExists(policyId)` before writing it into a scope, because storing a nonexistent `BLOCK`-type ID would otherwise be indistinguishable on-chain from `ALWAYS_ALLOW` — nothing would ever be denied, and nothing on-chain would say why. +A `BLOCKLIST` authorizes everyone except the accounts added to it; an `ALLOWLIST` authorizes no one except the accounts added. Membership updates are type-checked: calling `updateAllowlist` on a `BLOCKLIST` fails with `IncompatiblePolicyType`. Each batch is limited to **64 addresses** (`BatchSizeTooLarge`), which bounds the work a single call can do — a requirement of [§3.14](#314-gas-accounting) for any entry point whose cost scales with its input. Adding an existing member or removing an absent one is idempotent. + +**Admin lifecycle.** Admin transfer is two-step: the incumbent calls `stageUpdateAdmin` to nominate (passing the zero address cancels), and the nominee must call `finalizeUpdateAdmin` themselves to take over. A single-step transfer to a mistyped address would hand control of a live compliance list to an unreachable account; requiring the nominee to act proves the destination is controlled. `renounceAdmin` permanently freezes a policy: its membership can never change again, while all reads keep working. A frozen policy is a product in its own right — a genesis allowlist that is guaranteed never to expand. The registry distinguishes a frozen policy (exists, judged against its final membership) from one that was never created (judged as the empty set). + +A `policyId` is self-describing: its most significant byte encodes the `PolicyType` (`0x00` = `BLOCKLIST`, `0x01` = `ALLOWLIST`; any other value is not a valid type), and its low 56 bits are a counter. Any caller can determine a policy's type from the ID alone, with no storage read — the same principle as encoding the variant in the token address. + +`isAuthorized` **never reverts**, because it sits on the path of every transfer: if it could revert, a single misconfigured policy would render a token permanently unusable. An unrecognized ID is treated as an **empty policy of the type its own most significant byte encodes**, and an ID whose type byte is not a valid `PolicyType` authorizes no one. The refusal itself is raised by the token, as `PolicyForbids(scope, policyId)`; the registry only ever answers true or false. + +Two sentinel IDs therefore exist without any `createPolicy` call, and both follow from the rule above rather than being special-cased: + +| Sentinel | Value | Type byte | Empty-policy meaning | +|---|---|---|---| +| `ALWAYS_ALLOW` | `0x0000000000000000` | `0x00` = `BLOCKLIST` | An empty blocklist authorizes everyone | +| `ALWAYS_BLOCK` | `0x0100000000000001` | `0x01` = `ALLOWLIST` | An empty allowlist authorizes no one | + +`ALWAYS_ALLOW` is deliberately `0`, so a freshly created token — whose policy scopes are all zero — carries no compliance restriction; counters start at `2`. A sentinel value of plain `1` would **not** work: its most significant byte is `0x00`, so the rule above would read it as an empty `BLOCKLIST` and authorize everyone, the opposite of the intended meaning. + +**Tolerant reads require strict writes.** The empty-set fallback has a sharp corollary: a token referencing a never-created `BLOCKLIST` ID would authorize everyone, which looks like an open hole. It is closed on the write side — `updatePolicy` MUST verify `policyExists(newId)` and revert `PolicyNotFound` otherwise. Because every ID that reaches a token slot is guaranteed to exist, the read path's tolerance can never be exploited. ### 3.9 Pause ```solidity -interface INativeTokenPause { - enum Feature { TRANSFER, MINT, BURN } +interface IB20Pausable { + enum Feature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask - event Paused(Feature feature, address account); - event Unpaused(Feature feature, address account); + event Paused(Feature[] features, address account); + event Unpaused(Feature[] features, address account); - function pause(Feature feature) external; // PAUSE_ROLE-gated - function unpause(Feature feature) external; // UNPAUSE_ROLE-gated + function pause(Feature[] calldata features) external; // PAUSE_ROLE-gated + function unpause(Feature[] calldata features) external; // UNPAUSE_ROLE-gated function isPaused(Feature feature) external view returns (bool); + function pausedFeatures() external view returns (Feature[] memory); } ``` -`Feature` is append-only across future protocol versions so existing positions never shift. A token is unpaused across all features at creation. `seize` is deliberately not gated by any `Feature`: `pause(BURN)` stops self-service `burn` but has no effect on `seize`, since a compliance seizure is exactly the kind of action an issuer may need to keep exercising while ordinary activity (transfers, minting, self-burns) is frozen during an incident. +Pause state is a bitmask over `Feature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An empty array is rejected with `EmptyFeatureSet`, since it can only be a caller error. `Feature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. + +`burnBlocked` is deliberately outside the mask: `pause(BURN)` stops self-service `burn` but has no effect on a compliance seizure, which is exactly the action an issuer may need to keep exercising while ordinary activity is frozen during an incident. ### 3.10 Supply Cap ```solidity -interface INativeTokenSupplyCap { - event SupplyCapChanged(uint256 previousCap, uint256 newCap); +interface IB20SupplyCap { + event SupplyCapUpdated(uint256 previousCap, uint256 newCap); function supplyCap() external view returns (uint256); function updateSupplyCap(uint256 newCap) external; // ADMIN_ROLE-gated } ``` -Passing `type(uint256).max` to `createToken` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +Passing `type(uint128).max` to `createB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. + +A cap above `type(uint128).max` is rejected with `SupplyCapTooLarge`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. ### 3.11 Permit (EIP-2612) -Native tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: +B-20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: ```solidity -interface INativeTokenPermit { +interface IB20Permit { event EIP712DomainChanged(); function permit( @@ -322,67 +473,217 @@ interface INativeTokenPermit { } ``` -Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-core-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. +Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. -### 3.12 Asset Variant: Multiplier +### 3.12 Asset Variant Extensions -An Asset-variant token stores one raw balance per account, exactly like the Standard variant, plus a single token-wide scaling factor: +An Asset-variant token stores one raw balance per account, exactly like the Stablecoin variant, and adds four things: a token-wide scaling factor, on-chain announcements, batch minting, and free-form extra metadata. ```solidity -interface INativeTokenAsset { - event MultiplierChanged(uint256 previousMultiplier, uint256 newMultiplier); - - /// @notice Current scaling factor, at 1e18 (WAD) precision. 1e18 means the scaled - /// view equals the raw balance exactly. - function multiplier() external view returns (uint256); +interface IB20Asset { + event MultiplierUpdated(uint256 multiplier); + event Announcement(address indexed caller, uint256 indexed id, string description, string uri); + event EndAnnouncement(uint256 indexed id); + event ExtraMetadataUpdated(string key, string value); - /// @notice Rescales every holder's displayed balance at once. Gated by REBASE_ROLE. - function updateMultiplier(uint256 newMultiplier) external; + function OPERATOR_ROLE() external pure returns (bytes32); + function WAD_PRECISION() external pure returns (uint256); // 1e18 + // --- Multiplier --- + function multiplier() external view returns (uint256); + function updateMultiplier(uint256 newMultiplier) external; // OPERATOR_ROLE function toScaledBalance(uint256 rawBalance) external view returns (uint256); function toRawBalance(uint256 scaledBalance) external view returns (uint256); function scaledBalanceOf(address account) external view returns (uint256); + + // --- Announcements --- + function announce(bytes[] calldata internalCalls, uint256 id, string calldata description, string calldata uri) + external; // OPERATOR_ROLE + function isAnnouncementIdUsed(uint256 id) external view returns (bool); + + // --- Batch issuance --- + function batchMint(address[] calldata recipients, uint256[] calldata amounts) external; // MINT_ROLE + + // --- Extra metadata --- + function extraMetadata(string calldata key) external view returns (string memory); + function updateExtraMetadata(string calldata key, string calldata value) external; // METADATA_ROLE } ``` -`balanceOf` (inherited from [§3.6](#36-core-token-interface)) always returns the raw, unscaled balance, and `multiplier()` never changes what `transfer`, `transferFrom`, `mint`, or `burn` actually move — only the display-facing conversion functions are affected. `toScaledBalance`/`toRawBalance` apply the current multiplier in each direction and exist purely as a display convenience for wallets and explorers; `scaledBalanceOf(account)` is equivalent to calling `toScaledBalance(balanceOf(account))` in a single round trip. Because the conversion divides by `1e18`, converting a raw amount to its scaled form and back is not guaranteed to reproduce the original value exactly — callers that need precise accounting MUST treat the raw balance as authoritative and use the scaled value for display only. +`balanceOf` (inherited from [§3.6](#36-shared-token-interface)) always returns the raw, unscaled balance, and `multiplier()` never changes what `transfer`, `transferFrom`, `mint`, or `burn` actually move — only the display-facing conversion functions are affected. `toScaledBalance`/`toRawBalance` apply the current multiplier in each direction and exist purely as a display convenience for wallets and explorers; `scaledBalanceOf(account)` is equivalent to calling `toScaledBalance(balanceOf(account))` in a single round trip. Because the conversion divides by `1e18`, converting a raw amount to its scaled form and back is not guaranteed to reproduce the original value exactly — callers that need precise accounting MUST treat the raw balance as authoritative and use the scaled value for display only. + +`updateMultiplier` is gated by `OPERATOR_ROLE`, a role of its own rather than `DEFAULT_ADMIN_ROLE` or `METADATA_ROLE` ([§4](#4-rationale)). It MUST reject a `newMultiplier` of zero, which would make every account's scaled balance permanently zero with no way to recover a meaningful display value. + +It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMultiplier`. This bound is the other half of the overflow guard described in [§3.10](#310-supply-cap): with both the supply cap and the multiplier bounded by `type(uint128).max`, the product `rawBalance * multiplier` never exceeds `uint256`, and dividing by `WAD` only shrinks it further. Two consequences are deliberate — an implementation needs no wide-arithmetic intermediate, and no scaled read can revert or truncate because of overflow. See [§6.5](#65-multiplier-bounds-and-precision) for the risks the bounds do not remove. + +**Issuance must be converted at the current multiplier.** The protocol does not do this for the issuer: `mint` and `batchMint` take *raw* amounts. At a multiplier of `1.2`, a subscription worth 120 units of value must mint `120 / 1.2 = 100` raw — minting 120 raw would hand the subscriber 144 units of scaled value and dilute every existing holder. This is issuer accounting discipline, not something the protocol can enforce, and it is the main reason `updateMultiplier` and `batchMint` are gated by different roles and SHOULD both be wrapped in an announcement. -`updateMultiplier` is gated by `REBASE_ROLE` rather than `ADMIN_ROLE` or `METADATA_ROLE`: a single call changes what every holder sees as their balance across the entire token, a materially larger blast radius than an ordinary metadata edit, so an issuer may want a different signer (or a slower, multisig-gated process) for this specific action than for day-to-day administration. `updateMultiplier` MUST reject a `newMultiplier` of zero, since a zero multiplier would make every account's scaled balance permanently zero with no way to recover a meaningful display value; see [§6](#6-security-considerations) for the broader range of multiplier-related risks issuers should account for. +**Announcements.** `announce` publishes a disclosure and atomically executes a bundle of operations against the token itself, so that the disclosure and the act it describes are bound together on-chain — the analogue of a corporate-action filing. Each `internalCall` executes against this token preserving the original `msg.sender`, so role checks still apply to the announcer; the bundle may be empty, making a pure disclosure. `id` is single-use (`AnnouncementIdAlreadyUsed`) and is marked used before execution. A call shorter than four bytes fails with `InternalCallMalformed`, a nested `announce` with `AnnouncementInProgress`, and a reverting call with `InternalCallFailed` — the inner reason is not propagated. Any failure rolls back the entire announcement, emitted events included. Indexers pair `Announcement` and `EndAnnouncement` by `id`; every log between them belongs to that disclosure. -This section applies only to tokens created with `variant = ASSET`. A Standard-variant token does not implement `INativeTokenAsset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes `INativeTokenAsset`'s selectors, so a call to `multiplier()` or any other method in this section against a Standard-variant address reverts, exactly as calling any other selector the handler does not recognize would. +**Batch minting.** `batchMint` requires equal-length, non-empty arrays (`LengthMismatch`). The role and pause gates are checked once for the batch, and the per-recipient mints then run privileged to avoid re-checking them — but `MINT_RECEIVER_POLICY` and the supply cap are enforced on **every** recipient individually. The batch is all-or-nothing: one non-compliant recipient reverts the whole call. -### 3.13 Gas Accounting +**Extra metadata.** A free-form `string => string` map for issuer-defined attributes (`"isin"`, `"category"`, `"region"`) that institutional systems read. An unset key returns the empty string; writing the empty string deletes the entry. An empty key is rejected with `InvalidMetadataKey`. -Each entry point charges a fixed gas cost via `RequiredGas`, calibrated to the actual state-access cost of the operation (comparable to the SLOAD/SSTORE cost of the equivalent BEP-20 bytecode path) plus a small fixed overhead for native dispatch, rather than being interpreted opcode-by-opcode. Exact values are a matter of implementation and benchmarking and are expected to be tuned before this BEP leaves Draft status; they MUST NOT be lower than the cost of the state accesses actually performed, to avoid opening a state-growth-vs-gas underpricing gap. +**Relationship to [BEP-677](./BEP-677.md).** BEP-677 brings the same scaling concept to BEP-20 contracts via [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056), and additionally specifies *scheduled* multiplier changes that take effect at a future timestamp. Two differences are intentional. First, BEP-677 governs issuer-written code and can therefore only *recommend* multiplier bounds at the application layer; this standard governs protocol code and enforces them. Second, this standard defines an immediate multiplier only — an issuer needing scheduled changes today implements BEP-677 on a BEP-20 contract, and extending scheduling to the Asset variant is left to a future BEP, which would have to define how a pending change interacts with `pause` and with the supply cap. + +**The multiplier is not a yield mechanism.** It rescales every holder by the same factor, with no opt-in, no per-holder accounting, and no separately claimable asset. It therefore expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out and claimed. This standard defines no yield or reward-distribution primitive; an issuer needing one builds it at the application layer. + +This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. + +### 3.13 Stablecoin Variant Extension + +The Stablecoin variant adds exactly one method to the shared surface: + +```solidity +interface IB20Stablecoin { + function currency() external view returns (string memory); +} +``` + +`currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-b20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. + +The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `B20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. + +The variant is deliberately narrow: no multiplier, no announcements, no batch mint, no extra metadata, and `decimals` fixed at `6`. A unit of a payment token is expected to mean the same thing tomorrow as today, so nothing that could restate it is exposed. + +### 3.14 Gas Accounting + +Each entry point charges a **fixed dispatch cost** via `RequiredGas`, plus **dynamic, per-operation costs** metered as the operation executes. A single fixed price per entry point is not sufficient, because several do work proportional to their input (`updateMembers` over an address array, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), and a flat number would either overcharge small inputs or underprice large ones. + +Because B-20 token operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B-20 token operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. Specifically: + +| Work performed | Charged as | +|---|---| +| Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm base cost always, plus the cold surcharge on first access within the transaction | +| Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the EIP-2929 cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds (including refund reversal on revert) | +| Log emission | Log base cost plus per-topic and per-byte costs | +| Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | +| Calldata | Per-word cost, charged once per dispatch | +| Writing account code | The equivalent contract-creation costs | + +**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B-20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B-20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. + +Exact values are a matter of implementation and benchmarking and are expected to be tuned before this BEP leaves Draft status. Two acceptance criteria SHOULD be recorded alongside the final schedule: the worst-case number of new storage slots a single block can create, compared against the equivalent BEP-20 path; and the worst-case wall-clock execution time of each entry point, since a chain targeting sub-second blocks is bounded by execution time as well as by gas. Every entry point whose work scales with its input MUST additionally carry a protocol-level bound on that input. + +### 3.15 Feature Activation + +Shipping the code and permitting its use are separate decisions ([§4](#4-rationale)). This standard therefore defines a per-feature activation switch, held in the singleton **ActivationRegistry** and controlled by governance. + +```solidity +interface IActivationRegistry { + event FeatureActivated(bytes32 indexed feature, address indexed caller); + event FeatureDeactivated(bytes32 indexed feature, address indexed caller); + event ActivationAdminChanged(address indexed previousAdmin, address indexed newAdmin); + + error FeatureNotActivated(bytes32 feature); + error AlreadyActivated(bytes32 feature); + error AlreadyDeactivated(bytes32 feature); + error NotActivationAdmin(address caller); + error ZeroActivationAdmin(); + + function isActivated(bytes32 feature) external view returns (bool); + + /// @notice Assertion form of `isActivated`: reverts FeatureNotActivated when inactive. + /// Exists so callers need not each define the same error. + function checkActivated(bytes32 feature) external view; + + function admin() external view returns (address); + + function activate(bytes32 feature) external; // activation admin only + function deactivate(bytes32 feature) external; // activation admin only + function setActivationAdmin(address newAdmin) external; // activation admin only +} +``` + +`isActivated` never reverts. `activate` on an already-active feature fails with `AlreadyActivated`, and `deactivate` on an inactive one with `AlreadyDeactivated`, so a no-op governance action is surfaced rather than silently accepted. + +A feature is identified by a `bytes32` value, defined as the keccak-256 hash of its canonical name, so that a later BEP can introduce a new feature without modifying this interface: + +| Feature | Identifier | Gates | +|---|---|---| +| Asset variant | `keccak256("bsc.b20_asset")` | `createB20` with `variant == ASSET` | +| Stablecoin variant | `keccak256("bsc.b20_stablecoin")` | `createB20` with `variant == STABLECOIN` | +| Policy registry | `keccak256("bsc.policy_registry")` | `createPolicy`, `createPolicyWithAccounts`, `updateAllowlist`, `updateBlocklist`, and the admin-lifecycle methods | + +Every feature starts deactivated, so the fork can ship without opening anything, and each network activates on its own schedule. + +**What the switch does and does not reach.** There are exactly three call sites at which activation is checked — `createB20`, the PolicyRegistry's write methods, and `checkActivated` itself. Everything else is ungated: + +| Operation | Gated | +|---|---| +| `createB20`; PolicyRegistry write methods | Yes | +| Every method on a token that already exists — transfers, approvals, mint, burn, `burnBlocked`, roles, pause, policy binding, permit, multiplier, announcements | **No** | +| Every read, including `isAuthorized` and `policyExists` | **No** | + +A token's dispatch entry point performs no activation check at all. This is a normative requirement, not an implementation detail: a conforming implementation MUST NOT consult the ActivationRegistry on the path of an existing token's operations. + +Deactivating a feature therefore stops new issuance and nothing else: tokens already created keep working exactly as before. Freezing activity on a specific token remains the issuer's decision, exercised through that token's own `pause` ([§3.9](#39-pause)), not something a chain operator can do through this switch. Reads are never gated because `isAuthorized` sits on the path of every transfer, and a network-level switch must not be able to make transfers fail. + +**Authority.** `activationAdmin` MUST be a governance-controlled address — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from the moment this standard activates, so authority can move without a further hard fork. Its initial value is set in chain configuration; a zero address means no feature can be activated on that network. Because `activate`/`deactivate`/`setActivationAdmin` mutate state, they reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)). Resolving a feature flag reads consensus state and MUST be charged as a storage access ([§3.14](#314-gas-accounting)). ## 4. Rationale -**Two singleton precompiles instead of a wider set of registries.** The Token Registry only ever creates tokens; the Transfer List Registry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. +**Two singleton precompiles instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. + +**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the B20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. + +**A privileged bootstrap window instead of typed-only creation parameters.** A newly created token has no role holders, so the first `grantRole` cannot be authorized by anything. Two ways out exist: enumerate every possible initial setting as a typed factory parameter, or open a bounded privileged window in which the factory replays caller-supplied calls against the new token. This standard takes the second. The typed-parameter approach is simpler to audit, but it fixes the set of things that can be configured at birth, and every later extension of the standard would have to widen the factory signature. The window instead reuses the token's own methods, so it stays correct as the token surface grows. -**Hard-fork gating instead of an on-chain activation switch.** BSC already treats the hard-fork block/timestamp as the canonical readiness signal for every consensus-level change; adding a second, contract-level on/off flag for this specific feature would create two sources of truth for whether it is live, with no corresponding benefit on a chain where upgrades are already coordinated through the hard-fork process. +What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. -**Typed creation parameters instead of an arbitrary post-deploy call bundle.** `createToken` takes explicit, typed fields (variant, admin, decimals, supply cap) rather than an open-ended list of encoded calls to replay against the freshly created token. This is simpler to specify and audit exhaustively, at the cost of not supporting arbitrary one-transaction bootstrap sequences beyond what the typed parameters cover; issuers that need more than that can compose `createToken` with immediate follow-up calls (e.g., `grantRole`, `updatePolicy`) in their own wrapping transaction or contract. +**Four policy scopes rather than one account flag.** Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers are open. Collapsing these onto a single per-account flag forces every issuer to write custom logic for the asymmetry — one of the duplications this standard exists to remove. Four independent slots ([§3.8](#38-transfer-policies)) express all of them by configuration, and the executor axis in particular has no BEP-20 equivalent. -**Deterministic, prefix-recognizable addressing, with the variant encoded directly in the address.** Encoding a fixed marker — and, a few bytes later, the variant discriminant — in the address lets any caller determine both "is this a native token" and "which variant is it" with a pure address check and no RPC round-trip, the same property BSC's own existing precompiles already have by virtue of occupying low, fixed addresses. `isNativeToken` deliberately checks only the marker, not the discriminant, so tooling built against this version of the standard keeps recognizing native-token addresses even after a future BEP adds a variant it doesn't yet know about. +**Memo as a separate event.** Reconciliation references are the one piece of payment metadata that has no home in BEP-20, and the cost of not having it is an entire off-chain matching layer. Widening `Transfer` to carry it would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. -**A dedicated role for rescaling, not folded into `ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit or policy update — so it is gated by its own role ([§3.12](#312-asset-variant-multiplier)) rather than requiring full administrative authority. +**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a B-20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. + +**A dedicated role for rescaling and disclosure, not folded into `DEFAULT_ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit — so it and `announce` are gated by their own `OPERATOR_ROLE` ([§3.12](#312-asset-variant-extensions)). Keeping it distinct from `MINT_ROLE` matters for a second reason: restating value and issuing new units are the two operations that can dilute existing holders, and an issuer should be able to separate and audit them independently. ## 5. Backward Compatibility -This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-native-token-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-b-20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. -Native token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with native tokens without changes. One observable difference integrators MUST account for: `EXTCODESIZE`/`EXTCODEHASH` on a native token address returns `0`/the empty-code hash, since no bytecode is stored there — identical to the existing behavior of BSC's other precompiled addresses. Contracts that use a nonzero code size as an "is this a contract" heuristic (rather than, e.g., `isNativeToken()`) will misclassify native token addresses as externally-owned accounts. +B-20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B-20 tokens without changes. One observable difference integrators MUST account for: `EXTCODESIZE`/`EXTCODEHASH` on a B-20 token address returns `0`/the empty-code hash, since no bytecode is stored there — identical to the existing behavior of BSC's other precompiled addresses. Contracts that use a nonzero code size as an "is this a contract" heuristic (rather than, e.g., `isB20()`) will misclassify B-20 token addresses as externally-owned accounts. ## 6. Security Considerations -- **Precompile address collisions.** The addresses chosen for the Token Registry and Transfer List Registry, and the reserved native-token address-space prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status, to avoid a collision at implementation time. -- **No external calls from native token logic.** Because token operations execute as native code rather than delegated bytecode, they perform no external calls and expose no hooks (no `ERC-777`-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. -- **Irreversibility of `renounceAdmin`.** As described in [§3.7](#37-roles-and-access-control), once a token's admin count reaches zero, admin-gated operations (policy updates, supply-cap changes, role management) are permanently uncallable. This is by design, but issuers MUST treat it as one-way and irreversible. -- **`EXTCODESIZE` heuristic breakage.** See [§5](#5-backward-compatibility). Auditors of contracts that will hold or route native tokens should specifically check for this pattern. -- **Multiplier extremes and precision loss.** `toScaledBalance`/`toRawBalance` compute a product-then-divide over `1e18`; the implementation of this standard MUST use an overflow-safe wide intermediate (e.g., 512-bit `mulDiv`) so that a large raw balance combined with a large multiplier cannot overflow or silently truncate the view functions. An extreme multiplier (very large or very small) can still produce a scaled value that is either out of any meaningful range or rounds down to zero for small holders; issuers SHOULD bound the multiplier values they are willing to set at the application layer, since the protocol itself only rejects zero. -- **Multiplier-update centralization.** `REBASE_ROLE` can materially change what every holder believes their Asset-variant holding is worth, in display terms, without moving a single raw token. Issuers SHOULD use a multisig or a timelocked process for this role rather than a single hot key, given how directly it affects user-facing balances. -- **Consensus-level blast radius.** Because this logic runs as native client code rather than an isolated smart contract, a single implementation bug can affect every native token at once — every token of the affected variant at minimum, and every native token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher bar of testing (fuzzing, differential testing against a BEP-20 reference contract) than a typical single-contract deployment before mainnet activation. -- **Transfer List Registry admin key compromise.** Compromise of a list's admin key affects every token that references that list's ID in a policy scope; issuers sharing a list across multiple tokens should weigh this concentration of risk against the convenience of reuse. +### 6.1 Consensus-Level Blast Radius + +This logic runs as native client code, not as an isolated contract. A single implementation bug can affect every B-20 token at once — every token of the affected variant, or every B-20 token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher testing bar than a single-contract deployment: differential testing against a BEP-20 reference implementation, and fuzzing of the storage layout and gas accounting, MUST precede mainnet activation. + +### 6.2 Activation Authority + +The activation admin ([§3.15](#315-feature-activation)) can halt new token and list creation network-wide, and deliberately cannot touch tokens that already exist. The worst outcome of a compromised or misused activation key is therefore that issuance halts, or that a feature opens earlier than intended — not that balances are frozen or moved. Vesting the key in the timelock keeps the "opened too early" direction under the same review as any other governance action, and `setActivationAdmin` lets a suspected compromise be remediated without a hard fork. + +### 6.3 Identity-Fingerprint Strength + +A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. + +72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-b-20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. + +### 6.4 Privileged Keys + +Three authorities carry risk that no protocol rule can bound: + +- **`OPERATOR_ROLE`** changes what every holder of an Asset-variant token believes their holding is worth, in display terms, without moving a single raw token, and `announce` can execute an arbitrary bundle of the token's own methods under the announcer's authority. Issuers SHOULD hold it in a multisig or timelocked process rather than a single hot key. +- **Policy admin.** Compromise of a policy's admin key affects every token whose scopes reference that policy ID — the direct cost of the sharing that makes the registry worthwhile. Issuers reusing a policy across tokens should weigh that concentration against the convenience, and the two-step admin handover ([§3.8](#38-transfer-policies)) means a transfer cannot silently land on an unreachable address. +- **`renounceLastAdmin`** is irreversible ([§3.7](#37-roles-and-access-control)): once a token's admin count reaches zero, policy updates, supply-cap changes, and role management are permanently uncallable — and no path, including the factory bootstrap, can restore them. This is by design, but issuers MUST treat it as one-way. The same terminal state is reachable at birth by passing a zero `initialAdmin`. + +### 6.5 Multiplier Bounds and Precision + +Overflow in `toScaledBalance`/`toRawBalance` is prevented structurally rather than arithmetically: the supply cap ([§3.10](#310-supply-cap)) and the multiplier ([§3.12](#312-asset-variant-extensions)) are each bounded by `type(uint128).max`, so the intermediate product cannot exceed `uint256` and the quotient cannot either. Implementations MUST enforce both bounds at write time; relaxing either would require reintroducing a wide intermediate *and* defining what a scaled read returns when the result itself exceeds `uint256` — a case the bounds make unreachable. + +What the bounds do not remove is precision loss. Every multiplier-derived read floors, so an extreme multiplier can still round a small holder's scaled balance down to zero, and a raw→scaled→raw round trip can lose up to one unit. Issuers SHOULD bound the multiplier range they are willing to set at the application layer, and any accounting that must be exact SHOULD treat the raw balance as authoritative. + +### 6.6 State Growth + +Pricing an operation at the cost of the state accesses it performs ([§3.14](#314-gas-accounting)) removes the bytecode-interpretation overhead that a BEP-20 transfer incidentally pays. That overhead was never a state-growth control, but it did act as one: with it gone, the same amount of gas buys more permanent storage slots than before. The per-slot price is unchanged — what changes is the total number of slots a block can create. This is inherent to the efficiency this standard sets out to deliver, not a defect, but it MUST be quantified and accepted deliberately against the acceptance criteria in [§3.14](#314-gas-accounting) rather than discovered after activation. + +### 6.7 Integration Assumptions + +- **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. +- **`EXTCODESIZE` heuristics break.** See [§5](#5-backward-compatibility). Audits of contracts that will hold or route B-20 tokens should check specifically for this pattern. +- **Precompile address allocation.** The B20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. ## 7. License From 7bc5741ca3425c4039fd22361d8659678ff72b5e Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 15:01:26 +0800 Subject: [PATCH 03/41] BEP-702: require the 0xEF account sentinel The BEP mandated that no bytecode ever be stored at a B-20 address. That is precisely the condition EIP-161 reaps: an account with zero nonce, zero balance and no code is deleted at the end of the block it was touched in, and its storage goes with it. A B-20 token keeps every balance, allowance, role and policy binding in exactly such an account and touches it on every transfer, so an implementation following the text as written would lose whole tokens. Emptiness does not consider storage, so no amount of care in the storage layer avoids it; the account itself has to be non-empty. Adds section 3.16 requiring a one-byte 0xEF stub on every account holding B-20 state, written at creation, before the initial storage. 0xEF is the EIP-3541 reserved prefix, so it cannot be produced by CREATE/CREATE2 and remains an unforgeable protocol marker even if the reserved-address-space restriction is later weakened. A nonce bump would satisfy EIP-161 equally but is indistinguishable from ordinary account state. Consequences: - 3.3: existence is the presence of the sentinel, replacing the previous rule that it be read from registry state and never inferred from code. Balance still carries no information; creation at a prefunded address succeeds. - 3.4: sentinel write is an ordered step of createB20. - 5: EXTCODESIZE returns 1 and EXTCODEHASH keccak256(0xEF), so the is-this-a-contract heuristic now works and the previous warning was inverted. Replaced with the two assumptions that genuinely break: EXTCODEHASH identity comparison, and treating a one-byte account as self-destructed. - 6.7: coverage of the sentinel replaces the EXTCODESIZE entry, with the requirement that the invariant be enforced structurally rather than per call site. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index e6d2fcdb..51f77198 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -28,6 +28,7 @@ - [3.13 Stablecoin Variant Extension](#313-stablecoin-variant-extension) - [3.14 Gas Accounting](#314-gas-accounting) - [3.15 Feature Activation](#315-feature-activation) + - [3.16 Account Sentinel](#316-account-sentinel) - [4. Rationale](#4-rationale) - [5. Backward Compatibility](#5-backward-compatibility) - [6. Security Considerations](#6-security-considerations) @@ -87,7 +88,7 @@ Authorization is layered, and each layer answers a different question: A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. -Every B-20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b-20-address-space)). No bytecode is ever stored there. Instead, the EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B-20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the two registries use: one piece of code serving every caller, differentiated only by address. +Every B-20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b-20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B-20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and lists, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). @@ -153,7 +154,9 @@ Lying inside the reserved space is not the same as existing. An address may matc | Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | | Marker does not match | Ordinary account, unchanged | -Existence MUST be determined solely from the Registry's own record, and MUST NOT be inferred from the target's code, nonce, or balance: no bytecode is ever stored at a B-20 token address, and any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. +Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. + +Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createB20` SHOULD reject a derived address in that condition. @@ -204,7 +207,9 @@ Creation parameters are variant-specific: | `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | | `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | -`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b-20-address-space)); validate `params`; reject a derived address that already holds a token (`TokenAlreadyExists` — retry with a different `salt`); write the initial storage and the factory-created marker that `isB20Initialized` reads; execute `initCalls`; emit `B20Created`. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. +`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b-20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. + +The sentinel MUST be written **before** the initial storage, not after. Writing storage to an account that is still EIP-161-empty and only rescuing it later leaves a window in which an intervening state-clearing pass would discard it. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. **The bootstrap window.** `initCalls` are executed by the factory against the new token as *privileged* calls, in order, atomically with creation. This exists because a token is born with no role holders at all: without a privileged window, granting the first `MINT_ROLE` would already require an authority that does not yet exist. Inside the window the factory skips the role gate and the transfer-side policy gates. Three checks are **never** skipped, on any path: @@ -621,6 +626,24 @@ Deactivating a feature therefore stops new issuance and nothing else: tokens alr **Authority.** `activationAdmin` MUST be a governance-controlled address — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from the moment this standard activates, so authority can move without a further hard fork. Its initial value is set in chain configuration; a zero address means no feature can be activated on that network. Because `activate`/`deactivate`/`setActivationAdmin` mutate state, they reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)). Resolving a feature flag reads consensus state and MUST be charged as a storage access ([§3.14](#314-gas-accounting)). +### 3.16 Account Sentinel + +Every account that holds B-20 state — each token, and each singleton registry ([§3.1](#31-architecture-overview)) — MUST carry a one-byte code stub: + +``` +0xEF +``` + +It is never executed. Call dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. + +**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A B-20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. + +**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-b-20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. + +The sentinel is written once, at creation, and never removed. Implementations MUST NOT plant it on an account that already carries code, and the write MUST be charged the same state-creation cost as writing account code by any other means ([§3.14](#314-gas-accounting)). + +**Retrofitting is expensive — get it right at the fork.** An implementation that ships without the sentinel and discovers the loss afterwards has to enumerate every affected address to repair it. That is tractable for a fixed singleton but not for the token family, whose addresses are unbounded and derived from caller-supplied salts. There is no second chance for a token whose storage was already reaped. + ## 4. Rationale **Two singleton precompiles instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. @@ -643,7 +666,14 @@ What makes it acceptable is that the window is not a general privilege escalatio This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-b-20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. -B-20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B-20 tokens without changes. One observable difference integrators MUST account for: `EXTCODESIZE`/`EXTCODEHASH` on a B-20 token address returns `0`/the empty-code hash, since no bytecode is stored there — identical to the existing behavior of BSC's other precompiled addresses. Contracts that use a nonzero code size as an "is this a contract" heuristic (rather than, e.g., `isB20()`) will misclassify B-20 token addresses as externally-owned accounts. +B-20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B-20 tokens without changes. + +Because every B-20 account carries the sentinel ([§3.16](#316-account-sentinel)), `EXTCODESIZE` returns `1` and `EXTCODEHASH` returns `keccak256(0xEF)`. The widespread "non-zero code size means this is a contract" heuristic therefore classifies B-20 tokens correctly, and Solidity's own `EXTCODESIZE` check before an external call is satisfied. Two narrower assumptions do not survive, and integrators relying on either MUST use `isB20()` instead: + +- Code that compares `EXTCODEHASH` against a known deployment hash to identify a specific implementation will not match; every B-20 account shares one hash, which identifies the *class* rather than any particular token. +- Code that treats a one-byte or otherwise "unexecutable" code size as a self-destructed or malformed contract will misread a live token. + +Tooling that pre-warms precompile bytecode in a fork (some local test harnesses do this so Solidity's code-size check passes) will find the sentinel already present and MUST NOT treat that as evidence that a token exists. ## 6. Security Considerations @@ -682,7 +712,8 @@ Pricing an operation at the cost of the state accesses it performs ([§3.14](#31 ### 6.7 Integration Assumptions - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. -- **`EXTCODESIZE` heuristics break.** See [§5](#5-backward-compatibility). Audits of contracts that will hold or route B-20 tokens should check specifically for this pattern. +- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B-20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. +- **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B-20 account shares one code hash, so it identifies the class, not the token. - **Precompile address allocation.** The B20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. ## 7. License From 7080b06b5c4b63208c647a7c12b375b8b9e86d9c Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 15:27:18 +0800 Subject: [PATCH 04/41] BEP-702: derive all gas from existing EVM cost functions Section 3.14 previously described a fixed per-entry-point dispatch cost plus dynamic costs, and deferred the numbers to "a final schedule" to be tuned before Draft exit. That framing implied this standard owns a gas schedule. It should not. Both prior implementations of a native token precompile price their operations purely by applying the chain's existing cost functions to the work performed, with no per-selector table anywhere. States plainly that the standard introduces no gas parameters of its own, and gives the reason: a flat per-entry-point price is wrong in both directions for entry points whose work scales with input, and a published table diverges from the surrounding schedule the moment either moves. The consequence worth having is decoupling. Every row of the work-to-cost table resolves to whatever the active fork's cost function returns, so a later change to the gas schedule propagates automatically. In particular, if BSC adopts a separate state-gas dimension (EIP-8037 / EIP-8038, the shape the existing StateGas field already assumes), B-20 inherits it with no amendment here. Also: - 3.2: RequiredGas cannot price a stateful precompile, since cost depends on state not yet read (cold vs warm, creation vs rewrite). It reports no charge; metering happens in RunWithState. Implementations must not use it to bound cost. - 3.14: adds cross-account access and names the sentinel as what the code-writing row pays for. Replaces the schedule-tuning paragraph with the bounded-input requirement and two measurable properties: parity against a BEP-20 reference, and worst-case wall-clock cost. - 6.6: state growth is bounded by parity rather than open-ended, since B-20 charges the same per-slot function; the residual is the difference in non-state overhead. Raising state-creation price is explicitly out of scope as a chain-wide parameter, not something a token standard sets. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 51f77198..adfd329d 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -115,6 +115,8 @@ type StatefulPrecompiledContract interface { The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. +Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a B-20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. + Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the B20Factory, the PolicyRegistry, and a B-20 token observes the following rules: | Call form | Behavior | @@ -553,22 +555,32 @@ The variant is deliberately narrow: no multiplier, no announcements, no batch mi ### 3.14 Gas Accounting -Each entry point charges a **fixed dispatch cost** via `RequiredGas`, plus **dynamic, per-operation costs** metered as the operation executes. A single fixed price per entry point is not sufficient, because several do work proportional to their input (`updateMembers` over an address array, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), and a flat number would either overcharge small inputs or underprice large ones. +**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a B-20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. + +That is a deliberate choice rather than an omission. A fixed price per entry point would be wrong in both directions: several entry points do work proportional to their input (`updateMembers` and `batchMint` over arrays, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), so one flat number would overcharge small inputs and underprice large ones — and underpricing large ones is precisely the state-growth gap this section exists to close. A published table would also have to be revised every time the surrounding gas schedule moved, and would silently diverge from it in between. -Because B-20 token operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B-20 token operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. Specifically: +Because B-20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B-20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. | Work performed | Charged as | |---|---| | Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm base cost always, plus the cold surcharge on first access within the transaction | | Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the EIP-2929 cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds (including refund reversal on revert) | +| Cross-account read | The cold-account-access cost on first touch of another B-20 account or a registry within the transaction, then the warm cost | | Log emission | Log base cost plus per-topic and per-byte costs | | Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | -| Calldata | Per-word cost, charged once per dispatch | -| Writing account code | The equivalent contract-creation costs | +| Calldata | The existing per-word cost, charged once per dispatch, covering the ABI decode the EVM would otherwise have charged for as bytecode | +| Writing account code | The existing contract-creation costs — the fixed creation cost plus the per-byte deposit cost. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | + +Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to B-20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — B-20 inherits it without any amendment to this standard, precisely because it never restated the numbers. **Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B-20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B-20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. -Exact values are a matter of implementation and benchmarking and are expected to be tuned before this BEP leaves Draft status. Two acceptance criteria SHOULD be recorded alongside the final schedule: the worst-case number of new storage slots a single block can create, compared against the equivalent BEP-20 path; and the worst-case wall-clock execution time of each entry point, since a chain targeting sub-second blocks is bounded by execution time as well as by gas. Every entry point whose work scales with its input MUST additionally carry a protocol-level bound on that input. +**Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound on that input — the 64-address batch limit of [§3.8](#38-transfer-policies) is one such bound, and `batchMint`, `announce`'s call bundle, and every dynamic `string`/`bytes` argument require equivalents. + +Because no schedule is published, verification is behavioural rather than a matter of reviewing numbers. Two properties SHOULD be measured before mainnet activation: + +- **Parity.** For each entry point, the gas charged equals the gas an equivalent BEP-20 implementation would pay for the same state accesses, and never less. Differential testing against a reference contract establishes this directly, and it is the property that makes the whole approach safe: correctness reduces to "did we account for every access", not "is this number right". +- **Wall-clock cost.** The worst-case execution time of each entry point, since a chain targeting sub-second blocks is bounded by execution time as well as by gas, and gas parity says nothing about that. ### 3.15 Feature Activation @@ -707,7 +719,11 @@ What the bounds do not remove is precision loss. Every multiplier-derived read f ### 6.6 State Growth -Pricing an operation at the cost of the state accesses it performs ([§3.14](#314-gas-accounting)) removes the bytecode-interpretation overhead that a BEP-20 transfer incidentally pays. That overhead was never a state-growth control, but it did act as one: with it gone, the same amount of gas buys more permanent storage slots than before. The per-slot price is unchanged — what changes is the total number of slots a block can create. This is inherent to the efficiency this standard sets out to deliver, not a defect, but it MUST be quantified and accepted deliberately against the acceptance criteria in [§3.14](#314-gas-accounting) rather than discovered after activation. +Pricing an operation at the cost of the state accesses it performs ([§3.14](#314-gas-accounting)) removes the bytecode-interpretation overhead that a BEP-20 transfer incidentally pays. That overhead was never a state-growth control, but it did act as one: with it gone, the same amount of gas buys more permanent storage slots than before. The per-slot price is unchanged — what changes is the total number of slots a block can create. + +Deriving cost from existing cost functions bounds this but does not eliminate it. B-20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. + +Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, B-20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). ### 6.7 Integration Assumptions From cd6ab91a65eff4ae31065e822a17e656c1aacb8b Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 15:48:02 +0800 Subject: [PATCH 05/41] BEP-702: complete the gas derivation rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes an error introduced two commits ago and fills in the parts of the model the derivation principle alone does not pin down. Correction: the work-to-cost table claimed a cross-account read owes the cold-account-access surcharge. It does not. A storage read is charged the storage-access cost regardless of which account owns the slot; the account-access cost applies only to reading an account's balance, nonce or code. The two are now separate rows. That distinction deserved more than a table row, because it is the one place the model has no bytecode equivalent rather than a cheaper one: bytecode cannot read another account's storage at all and must CALL the owner, paying cold-account access, call machinery and a second interpretation frame. Added as a normative paragraph, with the constraint that implementations neither synthesize an account-access charge for a foreign storage read nor expose general foreign-storage reads — the only permitted cross-account reads are a token consulting the two registries for its own gating. Adds what is deliberately not charged: per-opcode execution, memory expansion, call machinery, and stack/jump/arithmetic. This is the entire source of the efficiency claim and was nowhere stated. Made exhaustive and normative in both directions: implementations must not add a synthetic overhead charge to approximate an interpreter's cost, because the never-cheaper-than-bytecode rule concerns state access, which is charged identically, and a synthetic surcharge would be unfalsifiable. Also notes that omitting these reduces gas without reducing the work a node performs, which is why wall-clock measurement is a separate requirement. Pins the calldata rule to input words at the keccak per-word rate, charged once per dispatch, and says what it substitutes for. Records that the code-creation cost is owed at a prefunded address too: such an address neither blocks creation nor discounts it. Section 5 gains gas estimation: cost depends on state not yet read, so no per-selector constant is correct, eth_estimateGas must resolve it by execution, a hardcoded transfer figure underestimates a first-time recipient, and out-of-gas can surface mid-operation. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index adfd329d..9b0d171c 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -563,16 +563,31 @@ Because B-20 operations bypass the EVM's opcode dispatch, the implementation MUS | Work performed | Charged as | |---|---| -| Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm base cost always, plus the cold surcharge on first access within the transaction | +| Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm base cost always, plus the cold-storage surcharge on first access within the transaction. Charged identically whichever account's storage is read — see below | | Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the EIP-2929 cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds (including refund reversal on revert) | -| Cross-account read | The cold-account-access cost on first touch of another B-20 account or a registry within the transaction, then the warm cost | +| Reading an account's balance, nonce, or code | The EIP-2929 account-access cost: warm base cost always, plus the cold-account surcharge on first touch | | Log emission | Log base cost plus per-topic and per-byte costs | | Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | -| Calldata | The existing per-word cost, charged once per dispatch, covering the ABI decode the EVM would otherwise have charged for as bytecode | -| Writing account code | The existing contract-creation costs — the fixed creation cost plus the per-byte deposit cost. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | +| Calldata | Input calldata is charged per 32-byte word, at the existing keccak per-word rate, once per dispatch. This substitutes for the ABI-decoding opcodes a bytecode implementation would have executed and paid for | +| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-b-20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | + +**Storage reads are account-agnostic.** A B-20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to B-20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — B-20 inherits it without any amendment to this standard, precisely because it never restated the numbers. +**What is deliberately not charged.** The table above is exhaustive. Four categories of cost that a bytecode implementation pays have no counterpart here, and their absence is the entire source of this standard's efficiency claim: + +| Not charged | Why it has no counterpart | +|---|---| +| Per-opcode execution | There are no opcodes; the operation is native code | +| Memory expansion | Arguments and return data are handled natively, so no EVM memory is allocated and the quadratic expansion cost never arises | +| Call machinery | Consulting a registry is a storage read, not a `CALL`: no call cost, no argument or return-data memory, and no second interpretation frame | +| Stack, jump, and arithmetic | Same reason as per-opcode execution | + +An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a B-20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. + +Note that these omissions reduce gas but do not reduce the work a node performs. Gas parity with bytecode on state access says nothing about execution time, which is why the wall-clock measurement below is a separate requirement and not a formality. + **Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B-20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B-20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. **Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound on that input — the 64-address batch limit of [§3.8](#38-transfer-policies) is one such bound, and `batchMint`, `announce`'s call bundle, and every dynamic `string`/`bytes` argument require equivalents. @@ -687,6 +702,8 @@ Because every B-20 account carries the sentinel ([§3.16](#316-account-sentinel) Tooling that pre-warms precompile bytecode in a fork (some local test harnesses do this so Solidity's code-size check passes) will find the sentinel already present and MUST NOT treat that as evidence that a token exists. +Gas estimation must execute rather than predict. A B-20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. + ## 6. Security Considerations ### 6.1 Consensus-Level Blast Radius From a5986a5d6eaabf33369f14afba06495eb3157ad3 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 15:58:29 +0800 Subject: [PATCH 06/41] BEP-702: rename B-20 to BNB20 Placeholder name pending a final decision. Applied throughout: prose, interface names (IBNB20, IBNB20Asset, IBNB20Roles, ...), the factory (BNB20Factory, createBNB20, getBNB20Address, isBNB20, isBNB20Initialized, BNB20Created), creation-parameter structs, activation feature identifiers (bsc.bnb20_asset, bsc.bnb20_stablecoin), section titles and the table of contents. Addresses are unchanged. The factory keeps 0xB20F..., whose hex mnemonic still reads inside the new name, and the reserved prefix stays 0xb2. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 178 ++++++++++++++++++++++++------------------------ 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 9b0d171c..fdd17edd 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -15,8 +15,8 @@ - [3. Specification](#3-specification) - [3.1 Architecture Overview](#31-architecture-overview) - [3.2 Stateful Precompiled Contracts](#32-stateful-precompiled-contracts) - - [3.3 B-20 Address Space](#33-b-20-address-space) - - [3.4 B20Factory](#34-b20factory) + - [3.3 BNB20 Address Space](#33-bnb20-address-space) + - [3.4 BNB20Factory](#34-bnb20factory) - [3.5 Variants](#35-variants) - [3.6 Shared Token Interface](#36-shared-token-interface) - [3.7 Roles and Access Control](#37-roles-and-access-control) @@ -64,19 +64,19 @@ A protocol-native token module addresses all of these at once: behavior is fixed Four new pieces of node logic are introduced: 1. A new class of **stateful precompiled contract** ([§3.2](#32-stateful-precompiled-contracts)), extending BSC's existing (stateless) precompile mechanism with StateDB access, caller/value context, and log emission. -2. A singleton **B20Factory** ([§3.4](#34-b20factory)), the sole entry point for creating a B-20 token, in one of two variants ([§3.5](#35-variants)). -3. A singleton **PolicyRegistry** ([§3.8](#38-transfer-policies)), holding reusable allowlists and blocklists that B-20 tokens reference for transfer-compliance checks. +2. A singleton **BNB20Factory** ([§3.4](#34-bnb20factory)), the sole entry point for creating a BNB20 token, in one of two variants ([§3.5](#35-variants)). +3. A singleton **PolicyRegistry** ([§3.8](#38-transfer-policies)), holding reusable allowlists and blocklists that BNB20 tokens reference for transfer-compliance checks. 4. A singleton **ActivationRegistry** ([§3.15](#315-feature-activation)), a per-feature governance switch controlling what may be created on a given network. The three singletons occupy fixed addresses, identical on every network: | Contract | Address | |---|---| -| B20Factory | `0xB20F000000000000000000000000000000000000` | +| BNB20Factory | `0xB20F000000000000000000000000000000000000` | | ActivationRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | | PolicyRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | -B-20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b-20-address-space)). +BNB20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-bnb20-address-space)). Authorization is layered, and each layer answers a different question: @@ -86,9 +86,9 @@ Authorization is layered, and each layer answers a different question: | RBAC ([§3.7](#37-roles-and-access-control)) | Is this caller allowed to perform this operation? | The token's issuer | | PolicyRegistry ([§3.8](#38-transfer-policies)) | Is this address allowed to be operated on? | The policy's admin | -A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. +A `createBNB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. -Every B-20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b-20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B-20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. +Every BNB20 token created through the BNB20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-bnb20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared BNB20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and lists, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). @@ -104,7 +104,7 @@ type PrecompiledContract interface { } ``` -This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A B-20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: +This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A BNB20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: ```go type StatefulPrecompiledContract interface { @@ -115,9 +115,9 @@ type StatefulPrecompiledContract interface { The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. -Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a B-20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. +Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a BNB20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. -Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the B20Factory, the PolicyRegistry, and a B-20 token observes the following rules: +Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the BNB20Factory, the PolicyRegistry, and a BNB20 token observes the following rules: | Call form | Behavior | |---|---| @@ -128,77 +128,77 @@ Because a stateful precompile can mutate state, the call modes it accepts and it All state mutations, logs, and any attached value transfer are reverted together with the enclosing call frame when an entry point fails. -### 3.3 B-20 Address Space +### 3.3 BNB20 Address Space -A B-20 token address is 20 bytes: +A BNB20 token address is 20 bytes: | Bytes | Length | Content | Meaning | |---|---|---|---| -| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a B-20 token | +| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a BNB20 token | | `[1:10]` | 9 | `0x00` × 9 | Namespace padding — together with `[0]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | | `[10]` | 1 (the 11th byte) | Variant discriminant | `0x00` = Asset, `0x01` = Stablecoin ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | -| `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the B20Factory and `salt` is caller-supplied entropy | +| `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the BNB20Factory and `salt` is caller-supplied entropy | Three helpers follow from the layout alone, none requiring a storage read: -- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a B-20 address by existing tooling. +- `isBNB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a BNB20 address by existing tooling. - `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). -- `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. +- `getBNB20Address(variant, creator, salt) -> address`, a view method on the BNB20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. -The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a B-20 token address. +The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a BNB20 token address. -Lying inside the reserved space is not the same as existing. An address may match the marker while no `createB20` call has ever produced it, and the two cases must be distinguished: +Lying inside the reserved space is not the same as existing. An address may match the marker while no `createBNB20` call has ever produced it, and the two cases must be distinguished: | Target address | Behavior | |---|---| -| Marker matches, `isB20Initialized` true, variant recognized | Routed to the B-20 token handler bound to that address | -| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | -| Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | +| Marker matches, `isBNB20Initialized` true, variant recognized | Routed to the BNB20 token handler bound to that address | +| Marker matches, `isBNB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | +| Marker matches, `isBNB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | | Marker does not match | Ordinary account, unchanged | -Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. +Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isBNB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createBNB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. -Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. +Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createBNB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createB20` SHOULD reject a derived address in that condition. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createBNB20` SHOULD reject a derived address in that condition. -### 3.4 B20Factory +### 3.4 BNB20Factory -The B20Factory is the singleton entry point for token creation. Its interface: +The BNB20Factory is the singleton entry point for token creation. Its interface: ```solidity -interface IB20Factory { +interface IBNB20Factory { enum Variant { ASSET, STABLECOIN } - event B20Created( + event BNB20Created( address indexed token, address indexed creator, Variant variant, string name, string symbol, bytes variantEventParams ); - /// @notice Creates a B-20 token and runs its full initialization in one transaction. + /// @notice Creates a BNB20 token and runs its full initialization in one transaction. /// @param variant ASSET or STABLECOIN; permanently fixed in byte[10] of the token's address. /// @param salt Caller-chosen entropy; combined with msg.sender to derive the token's address. - /// @param params ABI-encoded B20AssetCreateParams or B20StablecoinCreateParams (see below). + /// @param params ABI-encoded BNB20AssetCreateParams or BNB20StablecoinCreateParams (see below). /// @param initCalls Calls executed against the new token inside the bootstrap window. - function createB20( + function createBNB20( Variant variant, bytes32 salt, bytes calldata params, bytes[] calldata initCalls ) external returns (address token); - /// @notice Predicts the address `createB20` would produce, without creating anything. - function getB20Address(Variant variant, address creator, bytes32 salt) external view returns (address); + /// @notice Predicts the address `createBNB20` would produce, without creating anything. + function getBNB20Address(Variant variant, address creator, bytes32 salt) external view returns (address); - /// @notice True if `account` lies in the reserved B-20 address space (see 3.3). - /// Does not imply a token exists there; see `isB20Initialized`. - function isB20(address account) external pure returns (bool); + /// @notice True if `account` lies in the reserved BNB20 address space (see 3.3). + /// Does not imply a token exists there; see `isBNB20Initialized`. + function isBNB20(address account) external pure returns (bool); /// @notice Decodes the variant discriminant from the address. Pure — no storage read. function variantOf(address token) external pure returns (Variant); - /// @notice True exactly once `createB20` has completed for this address. - function isB20Initialized(address token) external view returns (bool); + /// @notice True exactly once `createBNB20` has completed for this address. + function isBNB20Initialized(address token) external view returns (bool); } ``` @@ -209,7 +209,7 @@ Creation parameters are variant-specific: | `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | | `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | -`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b-20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. +`createBNB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-bnb20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `BNB20Created`. The sentinel MUST be written **before** the initial storage, not after. Writing storage to an account that is still EIP-161-empty and only rescuing it later leaves a window in which an intervening state-clearing pass would discard it. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. @@ -227,7 +227,7 @@ Any failing `initCall` reverts the entire creation. A call shorter than four byt ### 3.5 Variants -Every B-20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-b-20-address-space)): +Every BNB20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-bnb20-address-space)): - **Asset** (`variant = ASSET`, byte `0x00`) — the shared surface of [§3.6](#36-shared-token-interface) through [§3.11](#311-permit-eip-2612), plus the extensions of [§3.12](#312-asset-variant-extensions): a protocol-uniform multiplier, on-chain announcements, batch minting, and free-form extra metadata. `decimals` is chosen at creation in the inclusive range `[6, 18]`. This suits tokenized real-world assets — funds, equities, bonds — whose per-unit value is periodically redenominated independently of any transfer activity. - **Stablecoin** (`variant = STABLECOIN`, byte `0x01`) — the shared surface plus exactly one extension, an immutable `currency()` code ([§3.13](#313-stablecoin-variant-extension)). `decimals` is fixed at `6` and is not stored. The variant is deliberately narrow: a unit is expected to hold a constant meaning over time, so nothing that could rescale or restate it is exposed. @@ -236,10 +236,10 @@ Both variants share identical roles, transfer-policy scopes, pause features, sup ### 3.6 Shared Token Interface -Every B-20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: +Every BNB20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: ```solidity -interface IB20 { +interface IBNB20 { event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); event Memo(address indexed caller, bytes32 indexed memo); @@ -307,7 +307,7 @@ Every failure mode in this interface reverts with a specific error rather than r Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `DEFAULT_ADMIN_ROLE`) authorized to grant or revoke it. ```solidity -interface IB20Roles { +interface IBNB20Roles { event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); @@ -348,17 +348,17 @@ Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` val - Neither `revokeRole` nor `renounceRole` can strip the last remaining holder; both fail with `LastAdminCannotRenounce`, so admin control cannot be lost by accident. - Giving it up deliberately goes through a distinct entry point, `renounceLastAdmin()`, callable only by the sole holder (`NotSoleAdmin` otherwise). "We lost access by mistake" and "we chose to make this token immutable" are never the same code path — the dangerous action has to be named explicitly. -- Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-b20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. +- Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-bnb20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. `renounceRole` requires `callerConfirmation == msg.sender` (`AccessControlBadConfirmation` otherwise), guarding against a mis-signed or replayed calldata; renouncing a role the caller does not hold succeeds silently, matching `AccessControl` semantics. -The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-b20factory)). +The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-bnb20factory)). Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. ### 3.8 Transfer Policies -Every B-20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: +Every BNB20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: | Scope | Checked against | Applies to | |---|---|---| @@ -368,7 +368,7 @@ Every B-20 token holds **four independent policy slots**, each referencing a pol | `MINT_RECEIVER_POLICY` | the `to` | `mint`, `mintWithMemo`, `batchMint` — including inside the factory bootstrap window | ```solidity -interface IB20Policy { +interface IBNB20Policy { event PolicyUpdated(bytes32 indexed scope, uint64 policyId); function policyId(bytes32 scope) external view returns (uint64); @@ -431,7 +431,7 @@ Two sentinel IDs therefore exist without any `createPolicy` call, and both follo ### 3.9 Pause ```solidity -interface IB20Pausable { +interface IBNB20Pausable { enum Feature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask event Paused(Feature[] features, address account); @@ -451,7 +451,7 @@ Pause state is a bitmask over `Feature`, so the three categories are frozen and ### 3.10 Supply Cap ```solidity -interface IB20SupplyCap { +interface IBNB20SupplyCap { event SupplyCapUpdated(uint256 previousCap, uint256 newCap); function supplyCap() external view returns (uint256); @@ -459,16 +459,16 @@ interface IB20SupplyCap { } ``` -Passing `type(uint128).max` to `createB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +Passing `type(uint128).max` to `createBNB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. A cap above `type(uint128).max` is rejected with `SupplyCapTooLarge`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. ### 3.11 Permit (EIP-2612) -B-20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: +BNB20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: ```solidity -interface IB20Permit { +interface IBNB20Permit { event EIP712DomainChanged(); function permit( @@ -487,7 +487,7 @@ Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` tr An Asset-variant token stores one raw balance per account, exactly like the Stablecoin variant, and adds four things: a token-wide scaling factor, on-chain announcements, batch minting, and free-form extra metadata. ```solidity -interface IB20Asset { +interface IBNB20Asset { event MultiplierUpdated(uint256 multiplier); event Announcement(address indexed caller, uint256 indexed id, string description, string uri); event EndAnnouncement(uint256 indexed id); @@ -535,31 +535,31 @@ It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMu **The multiplier is not a yield mechanism.** It rescales every holder by the same factor, with no opt-in, no per-holder accounting, and no separately claimable asset. It therefore expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out and claimed. This standard defines no yield or reward-distribution primitive; an issuer needing one builds it at the application layer. -This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. +This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IBNB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. ### 3.13 Stablecoin Variant Extension The Stablecoin variant adds exactly one method to the shared surface: ```solidity -interface IB20Stablecoin { +interface IBNB20Stablecoin { function currency() external view returns (string memory); } ``` -`currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-b20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. +`currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-bnb20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. -The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `B20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. +The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `BNB20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. The variant is deliberately narrow: no multiplier, no announcements, no batch mint, no extra metadata, and `decimals` fixed at `6`. A unit of a payment token is expected to mean the same thing tomorrow as today, so nothing that could restate it is exposed. ### 3.14 Gas Accounting -**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a B-20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. +**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a BNB20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. That is a deliberate choice rather than an omission. A fixed price per entry point would be wrong in both directions: several entry points do work proportional to their input (`updateMembers` and `batchMint` over arrays, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), so one flat number would overcharge small inputs and underprice large ones — and underpricing large ones is precisely the state-growth gap this section exists to close. A published table would also have to be revised every time the surrounding gas schedule moved, and would silently diverge from it in between. -Because B-20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B-20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. +Because BNB20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A BNB20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. | Work performed | Charged as | |---|---| @@ -569,11 +569,11 @@ Because B-20 operations bypass the EVM's opcode dispatch, the implementation MUS | Log emission | Log base cost plus per-topic and per-byte costs | | Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | | Calldata | Input calldata is charged per 32-byte word, at the existing keccak per-word rate, once per dispatch. This substitutes for the ABI-decoding opcodes a bytecode implementation would have executed and paid for | -| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-b-20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | +| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-bnb20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | -**Storage reads are account-agnostic.** A B-20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. +**Storage reads are account-agnostic.** A BNB20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. -Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to B-20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — B-20 inherits it without any amendment to this standard, precisely because it never restated the numbers. +Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to BNB20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — BNB20 inherits it without any amendment to this standard, precisely because it never restated the numbers. **What is deliberately not charged.** The table above is exhaustive. Four categories of cost that a bytecode implementation pays have no counterpart here, and their absence is the entire source of this standard's efficiency claim: @@ -584,11 +584,11 @@ Because every row above resolves to whatever the active fork's cost function ret | Call machinery | Consulting a registry is a storage read, not a `CALL`: no call cost, no argument or return-data memory, and no second interpretation frame | | Stack, jump, and arithmetic | Same reason as per-opcode execution | -An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a B-20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. +An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a BNB20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. Note that these omissions reduce gas but do not reduce the work a node performs. Gas parity with bytecode on state access says nothing about execution time, which is why the wall-clock measurement below is a separate requirement and not a formality. -**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B-20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B-20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. +**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A BNB20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the BNB20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. **Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound on that input — the 64-address batch limit of [§3.8](#38-transfer-policies) is one such bound, and `batchMint`, `announce`'s call bundle, and every dynamic `string`/`bytes` argument require equivalents. @@ -633,17 +633,17 @@ A feature is identified by a `bytes32` value, defined as the keccak-256 hash of | Feature | Identifier | Gates | |---|---|---| -| Asset variant | `keccak256("bsc.b20_asset")` | `createB20` with `variant == ASSET` | -| Stablecoin variant | `keccak256("bsc.b20_stablecoin")` | `createB20` with `variant == STABLECOIN` | +| Asset variant | `keccak256("bsc.bnb20_asset")` | `createBNB20` with `variant == ASSET` | +| Stablecoin variant | `keccak256("bsc.bnb20_stablecoin")` | `createBNB20` with `variant == STABLECOIN` | | Policy registry | `keccak256("bsc.policy_registry")` | `createPolicy`, `createPolicyWithAccounts`, `updateAllowlist`, `updateBlocklist`, and the admin-lifecycle methods | Every feature starts deactivated, so the fork can ship without opening anything, and each network activates on its own schedule. -**What the switch does and does not reach.** There are exactly three call sites at which activation is checked — `createB20`, the PolicyRegistry's write methods, and `checkActivated` itself. Everything else is ungated: +**What the switch does and does not reach.** There are exactly three call sites at which activation is checked — `createBNB20`, the PolicyRegistry's write methods, and `checkActivated` itself. Everything else is ungated: | Operation | Gated | |---|---| -| `createB20`; PolicyRegistry write methods | Yes | +| `createBNB20`; PolicyRegistry write methods | Yes | | Every method on a token that already exists — transfers, approvals, mint, burn, `burnBlocked`, roles, pause, policy binding, permit, multiplier, announcements | **No** | | Every read, including `isAuthorized` and `policyExists` | **No** | @@ -655,7 +655,7 @@ Deactivating a feature therefore stops new issuance and nothing else: tokens alr ### 3.16 Account Sentinel -Every account that holds B-20 state — each token, and each singleton registry ([§3.1](#31-architecture-overview)) — MUST carry a one-byte code stub: +Every account that holds BNB20 state — each token, and each singleton registry ([§3.1](#31-architecture-overview)) — MUST carry a one-byte code stub: ``` 0xEF @@ -663,9 +663,9 @@ Every account that holds B-20 state — each token, and each singleton registry It is never executed. Call dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. -**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A B-20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. +**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A BNB20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. -**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-b-20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. +**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-bnb20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. The sentinel is written once, at creation, and never removed. Implementations MUST NOT plant it on an account that already carries code, and the write MUST be charged the same state-creation cost as writing account code by any other means ([§3.14](#314-gas-accounting)). @@ -673,42 +673,42 @@ The sentinel is written once, at creation, and never removed. Implementations MU ## 4. Rationale -**Two singleton precompiles instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. +**Two singleton precompiles instead of a wider set of registries.** The BNB20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. -**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the B20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. +**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the BNB20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. **A privileged bootstrap window instead of typed-only creation parameters.** A newly created token has no role holders, so the first `grantRole` cannot be authorized by anything. Two ways out exist: enumerate every possible initial setting as a typed factory parameter, or open a bounded privileged window in which the factory replays caller-supplied calls against the new token. This standard takes the second. The typed-parameter approach is simpler to audit, but it fixes the set of things that can be configured at birth, and every later extension of the standard would have to widen the factory signature. The window instead reuses the token's own methods, so it stays correct as the token surface grows. -What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. +What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-bnb20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. **Four policy scopes rather than one account flag.** Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers are open. Collapsing these onto a single per-account flag forces every issuer to write custom logic for the asymmetry — one of the duplications this standard exists to remove. Four independent slots ([§3.8](#38-transfer-policies)) express all of them by configuration, and the executor axis in particular has no BEP-20 equivalent. **Memo as a separate event.** Reconciliation references are the one piece of payment metadata that has no home in BEP-20, and the cost of not having it is an entire off-chain matching layer. Widening `Transfer` to carry it would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. -**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a B-20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. +**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a BNB20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. **A dedicated role for rescaling and disclosure, not folded into `DEFAULT_ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit — so it and `announce` are gated by their own `OPERATOR_ROLE` ([§3.12](#312-asset-variant-extensions)). Keeping it distinct from `MINT_ROLE` matters for a second reason: restating value and issuing new units are the two operations that can dilute existing holders, and an issuer should be able to separate and audit them independently. ## 5. Backward Compatibility -This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-b-20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-bnb20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. -B-20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B-20 tokens without changes. +BNB20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with BNB20 tokens without changes. -Because every B-20 account carries the sentinel ([§3.16](#316-account-sentinel)), `EXTCODESIZE` returns `1` and `EXTCODEHASH` returns `keccak256(0xEF)`. The widespread "non-zero code size means this is a contract" heuristic therefore classifies B-20 tokens correctly, and Solidity's own `EXTCODESIZE` check before an external call is satisfied. Two narrower assumptions do not survive, and integrators relying on either MUST use `isB20()` instead: +Because every BNB20 account carries the sentinel ([§3.16](#316-account-sentinel)), `EXTCODESIZE` returns `1` and `EXTCODEHASH` returns `keccak256(0xEF)`. The widespread "non-zero code size means this is a contract" heuristic therefore classifies BNB20 tokens correctly, and Solidity's own `EXTCODESIZE` check before an external call is satisfied. Two narrower assumptions do not survive, and integrators relying on either MUST use `isBNB20()` instead: -- Code that compares `EXTCODEHASH` against a known deployment hash to identify a specific implementation will not match; every B-20 account shares one hash, which identifies the *class* rather than any particular token. +- Code that compares `EXTCODEHASH` against a known deployment hash to identify a specific implementation will not match; every BNB20 account shares one hash, which identifies the *class* rather than any particular token. - Code that treats a one-byte or otherwise "unexecutable" code size as a self-destructed or malformed contract will misread a live token. Tooling that pre-warms precompile bytecode in a fork (some local test harnesses do this so Solidity's code-size check passes) will find the sentinel already present and MUST NOT treat that as evidence that a token exists. -Gas estimation must execute rather than predict. A B-20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. +Gas estimation must execute rather than predict. A BNB20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. ## 6. Security Considerations ### 6.1 Consensus-Level Blast Radius -This logic runs as native client code, not as an isolated contract. A single implementation bug can affect every B-20 token at once — every token of the affected variant, or every B-20 token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher testing bar than a single-contract deployment: differential testing against a BEP-20 reference implementation, and fuzzing of the storage layout and gas accounting, MUST precede mainnet activation. +This logic runs as native client code, not as an isolated contract. A single implementation bug can affect every BNB20 token at once — every token of the affected variant, or every BNB20 token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher testing bar than a single-contract deployment: differential testing against a BEP-20 reference implementation, and fuzzing of the storage layout and gas accounting, MUST precede mainnet activation. ### 6.2 Activation Authority @@ -716,9 +716,9 @@ The activation admin ([§3.15](#315-feature-activation)) can halt new token and ### 6.3 Identity-Fingerprint Strength -A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. +A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createBNB20` rejects an address that already exists. The attack that matters is targeted: `getBNB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-b-20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-bnb20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. ### 6.4 Privileged Keys @@ -738,16 +738,16 @@ What the bounds do not remove is precision loss. Every multiplier-derived read f Pricing an operation at the cost of the state accesses it performs ([§3.14](#314-gas-accounting)) removes the bytecode-interpretation overhead that a BEP-20 transfer incidentally pays. That overhead was never a state-growth control, but it did act as one: with it gone, the same amount of gas buys more permanent storage slots than before. The per-slot price is unchanged — what changes is the total number of slots a block can create. -Deriving cost from existing cost functions bounds this but does not eliminate it. B-20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. +Deriving cost from existing cost functions bounds this but does not eliminate it. BNB20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. -Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, B-20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). +Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, BNB20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). ### 6.7 Integration Assumptions - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. -- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B-20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. -- **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B-20 account shares one code hash, so it identifies the class, not the token. -- **Precompile address allocation.** The B20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. +- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps BNB20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. +- **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every BNB20 account shares one code hash, so it identifies the class, not the token. +- **Precompile address allocation.** The BNB20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. ## 7. License From 5d7941ca80f548c34a6e20a12596c3780b863b86 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 16:01:45 +0800 Subject: [PATCH 07/41] BEP-702: drop the native-BNB trust-asymmetry motivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet restated the behavioral-drift argument from a different angle rather than adding an independent one, and the terms it added were ones this BEP cannot support. "Cheap" invites a comparison the standard does not win: a native BNB transfer is 21,000 gas against roughly 41,000 for a BNB20 transfer, so nothing here narrows that gap — what narrows is the gap to BEP-20, and section 3.14 now bounds even that to the interpreter and call-machinery overhead. "Simple" is contradicted by the specification itself: eight roles, four policy scopes, announcements. The one claim that was both true and not already made — that the guarantee comes from client code rather than from a contract author — is already the substance of the first bullet and of the closing paragraph. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 1 - 1 file changed, 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index fdd17edd..f2e18f7d 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -53,7 +53,6 @@ BEP-20 defines an ABI convention, not a guarantee about behavior. Any address ca - **Duplicated compliance engineering.** Stablecoin and tokenized real-world-asset issuers on BSC each independently re-implement the same handful of primitives — role-gated mint/burn, seizure of blocked balances, granular pause, supply caps — with subtly different guarantees and different bug surfaces every time. - **Real-world assets need redenomination, not just transfers.** Tokenized real-world assets — equities, commodities, bonds — periodically need their per-unit value rescaled across every holder at once, independent of any transfer taking place. This is a distinct need from a payment-oriented stablecoin, and BEP-20 has no standard answer for it today; issuers either bolt on a custom rebasing mechanism or avoid the token model entirely. - **Execution overhead.** A BEP-20 transfer pays for two SLOADs, two SSTOREs, and full EVM bytecode interpretation and ABI decoding on top, even though the underlying operation (move a balance from A to B) is one of the simplest state transitions in the system. -- **Trust asymmetry with native BNB.** Native BNB transfers are cheap, simple, and behaviorally guaranteed by the protocol. Fungible tokens, which carry the overwhelming majority of value and volume on BSC, get none of that guarantee today. A protocol-native token module addresses all of these at once: behavior is fixed by client code that ships through the same review and hard-fork process as consensus changes, gas cost reflects the actual state-access cost of the operation rather than bytecode interpretation, and a standard set of compliance and (for asset-type tokens) redenomination primitives is available to every issuer without a bespoke implementation. From b1bf255b0ee717586ef5ca11969389b0870be94d Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 16:03:35 +0800 Subject: [PATCH 08/41] BEP-702: use B20 uniformly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the BNB20 placeholder. Also drops the hyphen the prose previously used, so the name is a single token everywhere — identifiers already spelled it B20 (B20Factory, IB20, createB20), while the prose said B-20, and the split served no purpose. Matches how the design document and Base's own documentation spell it. Activation feature identifiers return to bsc.b20_asset and bsc.b20_stablecoin. Addresses were never touched. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 178 ++++++++++++++++++++++++------------------------ 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index f2e18f7d..f6886712 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -15,8 +15,8 @@ - [3. Specification](#3-specification) - [3.1 Architecture Overview](#31-architecture-overview) - [3.2 Stateful Precompiled Contracts](#32-stateful-precompiled-contracts) - - [3.3 BNB20 Address Space](#33-bnb20-address-space) - - [3.4 BNB20Factory](#34-bnb20factory) + - [3.3 B20 Address Space](#33-b20-address-space) + - [3.4 B20Factory](#34-b20factory) - [3.5 Variants](#35-variants) - [3.6 Shared Token Interface](#36-shared-token-interface) - [3.7 Roles and Access Control](#37-roles-and-access-control) @@ -63,19 +63,19 @@ A protocol-native token module addresses all of these at once: behavior is fixed Four new pieces of node logic are introduced: 1. A new class of **stateful precompiled contract** ([§3.2](#32-stateful-precompiled-contracts)), extending BSC's existing (stateless) precompile mechanism with StateDB access, caller/value context, and log emission. -2. A singleton **BNB20Factory** ([§3.4](#34-bnb20factory)), the sole entry point for creating a BNB20 token, in one of two variants ([§3.5](#35-variants)). -3. A singleton **PolicyRegistry** ([§3.8](#38-transfer-policies)), holding reusable allowlists and blocklists that BNB20 tokens reference for transfer-compliance checks. +2. A singleton **B20Factory** ([§3.4](#34-b20factory)), the sole entry point for creating a B20 token, in one of two variants ([§3.5](#35-variants)). +3. A singleton **PolicyRegistry** ([§3.8](#38-transfer-policies)), holding reusable allowlists and blocklists that B20 tokens reference for transfer-compliance checks. 4. A singleton **ActivationRegistry** ([§3.15](#315-feature-activation)), a per-feature governance switch controlling what may be created on a given network. The three singletons occupy fixed addresses, identical on every network: | Contract | Address | |---|---| -| BNB20Factory | `0xB20F000000000000000000000000000000000000` | +| B20Factory | `0xB20F000000000000000000000000000000000000` | | ActivationRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | | PolicyRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | -BNB20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-bnb20-address-space)). +B20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b20-address-space)). Authorization is layered, and each layer answers a different question: @@ -85,9 +85,9 @@ Authorization is layered, and each layer answers a different question: | RBAC ([§3.7](#37-roles-and-access-control)) | Is this caller allowed to perform this operation? | The token's issuer | | PolicyRegistry ([§3.8](#38-transfer-policies)) | Is this address allowed to be operated on? | The policy's admin | -A `createBNB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. +A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. -Every BNB20 token created through the BNB20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-bnb20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared BNB20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. +Every B20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and lists, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). @@ -103,7 +103,7 @@ type PrecompiledContract interface { } ``` -This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A BNB20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: +This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A B20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: ```go type StatefulPrecompiledContract interface { @@ -114,9 +114,9 @@ type StatefulPrecompiledContract interface { The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. -Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a BNB20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. +Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a B20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. -Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the BNB20Factory, the PolicyRegistry, and a BNB20 token observes the following rules: +Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the B20Factory, the PolicyRegistry, and a B20 token observes the following rules: | Call form | Behavior | |---|---| @@ -127,77 +127,77 @@ Because a stateful precompile can mutate state, the call modes it accepts and it All state mutations, logs, and any attached value transfer are reverted together with the enclosing call frame when an entry point fails. -### 3.3 BNB20 Address Space +### 3.3 B20 Address Space -A BNB20 token address is 20 bytes: +A B20 token address is 20 bytes: | Bytes | Length | Content | Meaning | |---|---|---|---| -| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a BNB20 token | +| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a B20 token | | `[1:10]` | 9 | `0x00` × 9 | Namespace padding — together with `[0]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | | `[10]` | 1 (the 11th byte) | Variant discriminant | `0x00` = Asset, `0x01` = Stablecoin ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | -| `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the BNB20Factory and `salt` is caller-supplied entropy | +| `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the B20Factory and `salt` is caller-supplied entropy | Three helpers follow from the layout alone, none requiring a storage read: -- `isBNB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a BNB20 address by existing tooling. +- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a B20 address by existing tooling. - `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). -- `getBNB20Address(variant, creator, salt) -> address`, a view method on the BNB20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. +- `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. -The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a BNB20 token address. +The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a B20 token address. -Lying inside the reserved space is not the same as existing. An address may match the marker while no `createBNB20` call has ever produced it, and the two cases must be distinguished: +Lying inside the reserved space is not the same as existing. An address may match the marker while no `createB20` call has ever produced it, and the two cases must be distinguished: | Target address | Behavior | |---|---| -| Marker matches, `isBNB20Initialized` true, variant recognized | Routed to the BNB20 token handler bound to that address | -| Marker matches, `isBNB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | -| Marker matches, `isBNB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | +| Marker matches, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | +| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | +| Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | | Marker does not match | Ordinary account, unchanged | -Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isBNB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createBNB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. +Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. -Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createBNB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. +Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createBNB20` SHOULD reject a derived address in that condition. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createB20` SHOULD reject a derived address in that condition. -### 3.4 BNB20Factory +### 3.4 B20Factory -The BNB20Factory is the singleton entry point for token creation. Its interface: +The B20Factory is the singleton entry point for token creation. Its interface: ```solidity -interface IBNB20Factory { +interface IB20Factory { enum Variant { ASSET, STABLECOIN } - event BNB20Created( + event B20Created( address indexed token, address indexed creator, Variant variant, string name, string symbol, bytes variantEventParams ); - /// @notice Creates a BNB20 token and runs its full initialization in one transaction. + /// @notice Creates a B20 token and runs its full initialization in one transaction. /// @param variant ASSET or STABLECOIN; permanently fixed in byte[10] of the token's address. /// @param salt Caller-chosen entropy; combined with msg.sender to derive the token's address. - /// @param params ABI-encoded BNB20AssetCreateParams or BNB20StablecoinCreateParams (see below). + /// @param params ABI-encoded B20AssetCreateParams or B20StablecoinCreateParams (see below). /// @param initCalls Calls executed against the new token inside the bootstrap window. - function createBNB20( + function createB20( Variant variant, bytes32 salt, bytes calldata params, bytes[] calldata initCalls ) external returns (address token); - /// @notice Predicts the address `createBNB20` would produce, without creating anything. - function getBNB20Address(Variant variant, address creator, bytes32 salt) external view returns (address); + /// @notice Predicts the address `createB20` would produce, without creating anything. + function getB20Address(Variant variant, address creator, bytes32 salt) external view returns (address); - /// @notice True if `account` lies in the reserved BNB20 address space (see 3.3). - /// Does not imply a token exists there; see `isBNB20Initialized`. - function isBNB20(address account) external pure returns (bool); + /// @notice True if `account` lies in the reserved B20 address space (see 3.3). + /// Does not imply a token exists there; see `isB20Initialized`. + function isB20(address account) external pure returns (bool); /// @notice Decodes the variant discriminant from the address. Pure — no storage read. function variantOf(address token) external pure returns (Variant); - /// @notice True exactly once `createBNB20` has completed for this address. - function isBNB20Initialized(address token) external view returns (bool); + /// @notice True exactly once `createB20` has completed for this address. + function isB20Initialized(address token) external view returns (bool); } ``` @@ -208,7 +208,7 @@ Creation parameters are variant-specific: | `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | | `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | -`createBNB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-bnb20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `BNB20Created`. +`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. The sentinel MUST be written **before** the initial storage, not after. Writing storage to an account that is still EIP-161-empty and only rescuing it later leaves a window in which an intervening state-clearing pass would discard it. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. @@ -226,7 +226,7 @@ Any failing `initCall` reverts the entire creation. A call shorter than four byt ### 3.5 Variants -Every BNB20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-bnb20-address-space)): +Every B20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-b20-address-space)): - **Asset** (`variant = ASSET`, byte `0x00`) — the shared surface of [§3.6](#36-shared-token-interface) through [§3.11](#311-permit-eip-2612), plus the extensions of [§3.12](#312-asset-variant-extensions): a protocol-uniform multiplier, on-chain announcements, batch minting, and free-form extra metadata. `decimals` is chosen at creation in the inclusive range `[6, 18]`. This suits tokenized real-world assets — funds, equities, bonds — whose per-unit value is periodically redenominated independently of any transfer activity. - **Stablecoin** (`variant = STABLECOIN`, byte `0x01`) — the shared surface plus exactly one extension, an immutable `currency()` code ([§3.13](#313-stablecoin-variant-extension)). `decimals` is fixed at `6` and is not stored. The variant is deliberately narrow: a unit is expected to hold a constant meaning over time, so nothing that could rescale or restate it is exposed. @@ -235,10 +235,10 @@ Both variants share identical roles, transfer-policy scopes, pause features, sup ### 3.6 Shared Token Interface -Every BNB20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: +Every B20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: ```solidity -interface IBNB20 { +interface IB20 { event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); event Memo(address indexed caller, bytes32 indexed memo); @@ -306,7 +306,7 @@ Every failure mode in this interface reverts with a specific error rather than r Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `DEFAULT_ADMIN_ROLE`) authorized to grant or revoke it. ```solidity -interface IBNB20Roles { +interface IB20Roles { event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); @@ -347,17 +347,17 @@ Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` val - Neither `revokeRole` nor `renounceRole` can strip the last remaining holder; both fail with `LastAdminCannotRenounce`, so admin control cannot be lost by accident. - Giving it up deliberately goes through a distinct entry point, `renounceLastAdmin()`, callable only by the sole holder (`NotSoleAdmin` otherwise). "We lost access by mistake" and "we chose to make this token immutable" are never the same code path — the dangerous action has to be named explicitly. -- Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-bnb20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. +- Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-b20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. `renounceRole` requires `callerConfirmation == msg.sender` (`AccessControlBadConfirmation` otherwise), guarding against a mis-signed or replayed calldata; renouncing a role the caller does not hold succeeds silently, matching `AccessControl` semantics. -The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-bnb20factory)). +The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-b20factory)). Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. ### 3.8 Transfer Policies -Every BNB20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: +Every B20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: | Scope | Checked against | Applies to | |---|---|---| @@ -367,7 +367,7 @@ Every BNB20 token holds **four independent policy slots**, each referencing a po | `MINT_RECEIVER_POLICY` | the `to` | `mint`, `mintWithMemo`, `batchMint` — including inside the factory bootstrap window | ```solidity -interface IBNB20Policy { +interface IB20Policy { event PolicyUpdated(bytes32 indexed scope, uint64 policyId); function policyId(bytes32 scope) external view returns (uint64); @@ -430,7 +430,7 @@ Two sentinel IDs therefore exist without any `createPolicy` call, and both follo ### 3.9 Pause ```solidity -interface IBNB20Pausable { +interface IB20Pausable { enum Feature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask event Paused(Feature[] features, address account); @@ -450,7 +450,7 @@ Pause state is a bitmask over `Feature`, so the three categories are frozen and ### 3.10 Supply Cap ```solidity -interface IBNB20SupplyCap { +interface IB20SupplyCap { event SupplyCapUpdated(uint256 previousCap, uint256 newCap); function supplyCap() external view returns (uint256); @@ -458,16 +458,16 @@ interface IBNB20SupplyCap { } ``` -Passing `type(uint128).max` to `createBNB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +Passing `type(uint128).max` to `createB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. A cap above `type(uint128).max` is rejected with `SupplyCapTooLarge`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. ### 3.11 Permit (EIP-2612) -BNB20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: +B20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: ```solidity -interface IBNB20Permit { +interface IB20Permit { event EIP712DomainChanged(); function permit( @@ -486,7 +486,7 @@ Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` tr An Asset-variant token stores one raw balance per account, exactly like the Stablecoin variant, and adds four things: a token-wide scaling factor, on-chain announcements, batch minting, and free-form extra metadata. ```solidity -interface IBNB20Asset { +interface IB20Asset { event MultiplierUpdated(uint256 multiplier); event Announcement(address indexed caller, uint256 indexed id, string description, string uri); event EndAnnouncement(uint256 indexed id); @@ -534,31 +534,31 @@ It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMu **The multiplier is not a yield mechanism.** It rescales every holder by the same factor, with no opt-in, no per-holder accounting, and no separately claimable asset. It therefore expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out and claimed. This standard defines no yield or reward-distribution primitive; an issuer needing one builds it at the application layer. -This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IBNB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. +This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. ### 3.13 Stablecoin Variant Extension The Stablecoin variant adds exactly one method to the shared surface: ```solidity -interface IBNB20Stablecoin { +interface IB20Stablecoin { function currency() external view returns (string memory); } ``` -`currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-bnb20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. +`currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-b20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. -The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `BNB20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. +The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `B20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. The variant is deliberately narrow: no multiplier, no announcements, no batch mint, no extra metadata, and `decimals` fixed at `6`. A unit of a payment token is expected to mean the same thing tomorrow as today, so nothing that could restate it is exposed. ### 3.14 Gas Accounting -**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a BNB20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. +**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a B20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. That is a deliberate choice rather than an omission. A fixed price per entry point would be wrong in both directions: several entry points do work proportional to their input (`updateMembers` and `batchMint` over arrays, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), so one flat number would overcharge small inputs and underprice large ones — and underpricing large ones is precisely the state-growth gap this section exists to close. A published table would also have to be revised every time the surrounding gas schedule moved, and would silently diverge from it in between. -Because BNB20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A BNB20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. +Because B20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. | Work performed | Charged as | |---|---| @@ -568,11 +568,11 @@ Because BNB20 operations bypass the EVM's opcode dispatch, the implementation MU | Log emission | Log base cost plus per-topic and per-byte costs | | Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | | Calldata | Input calldata is charged per 32-byte word, at the existing keccak per-word rate, once per dispatch. This substitutes for the ABI-decoding opcodes a bytecode implementation would have executed and paid for | -| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-bnb20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | +| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-b20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | -**Storage reads are account-agnostic.** A BNB20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. +**Storage reads are account-agnostic.** A B20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. -Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to BNB20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — BNB20 inherits it without any amendment to this standard, precisely because it never restated the numbers. +Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to B20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — B20 inherits it without any amendment to this standard, precisely because it never restated the numbers. **What is deliberately not charged.** The table above is exhaustive. Four categories of cost that a bytecode implementation pays have no counterpart here, and their absence is the entire source of this standard's efficiency claim: @@ -583,11 +583,11 @@ Because every row above resolves to whatever the active fork's cost function ret | Call machinery | Consulting a registry is a storage read, not a `CALL`: no call cost, no argument or return-data memory, and no second interpretation frame | | Stack, jump, and arithmetic | Same reason as per-opcode execution | -An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a BNB20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. +An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a B20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. Note that these omissions reduce gas but do not reduce the work a node performs. Gas parity with bytecode on state access says nothing about execution time, which is why the wall-clock measurement below is a separate requirement and not a formality. -**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A BNB20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the BNB20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. +**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. **Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound on that input — the 64-address batch limit of [§3.8](#38-transfer-policies) is one such bound, and `batchMint`, `announce`'s call bundle, and every dynamic `string`/`bytes` argument require equivalents. @@ -632,17 +632,17 @@ A feature is identified by a `bytes32` value, defined as the keccak-256 hash of | Feature | Identifier | Gates | |---|---|---| -| Asset variant | `keccak256("bsc.bnb20_asset")` | `createBNB20` with `variant == ASSET` | -| Stablecoin variant | `keccak256("bsc.bnb20_stablecoin")` | `createBNB20` with `variant == STABLECOIN` | +| Asset variant | `keccak256("bsc.bnb20_asset")` | `createB20` with `variant == ASSET` | +| Stablecoin variant | `keccak256("bsc.bnb20_stablecoin")` | `createB20` with `variant == STABLECOIN` | | Policy registry | `keccak256("bsc.policy_registry")` | `createPolicy`, `createPolicyWithAccounts`, `updateAllowlist`, `updateBlocklist`, and the admin-lifecycle methods | Every feature starts deactivated, so the fork can ship without opening anything, and each network activates on its own schedule. -**What the switch does and does not reach.** There are exactly three call sites at which activation is checked — `createBNB20`, the PolicyRegistry's write methods, and `checkActivated` itself. Everything else is ungated: +**What the switch does and does not reach.** There are exactly three call sites at which activation is checked — `createB20`, the PolicyRegistry's write methods, and `checkActivated` itself. Everything else is ungated: | Operation | Gated | |---|---| -| `createBNB20`; PolicyRegistry write methods | Yes | +| `createB20`; PolicyRegistry write methods | Yes | | Every method on a token that already exists — transfers, approvals, mint, burn, `burnBlocked`, roles, pause, policy binding, permit, multiplier, announcements | **No** | | Every read, including `isAuthorized` and `policyExists` | **No** | @@ -654,7 +654,7 @@ Deactivating a feature therefore stops new issuance and nothing else: tokens alr ### 3.16 Account Sentinel -Every account that holds BNB20 state — each token, and each singleton registry ([§3.1](#31-architecture-overview)) — MUST carry a one-byte code stub: +Every account that holds B20 state — each token, and each singleton registry ([§3.1](#31-architecture-overview)) — MUST carry a one-byte code stub: ``` 0xEF @@ -662,9 +662,9 @@ Every account that holds BNB20 state — each token, and each singleton registry It is never executed. Call dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. -**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A BNB20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. +**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A B20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. -**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-bnb20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. +**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-b20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. The sentinel is written once, at creation, and never removed. Implementations MUST NOT plant it on an account that already carries code, and the write MUST be charged the same state-creation cost as writing account code by any other means ([§3.14](#314-gas-accounting)). @@ -672,42 +672,42 @@ The sentinel is written once, at creation, and never removed. Implementations MU ## 4. Rationale -**Two singleton precompiles instead of a wider set of registries.** The BNB20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. +**Two singleton precompiles instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. -**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the BNB20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. +**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the B20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. **A privileged bootstrap window instead of typed-only creation parameters.** A newly created token has no role holders, so the first `grantRole` cannot be authorized by anything. Two ways out exist: enumerate every possible initial setting as a typed factory parameter, or open a bounded privileged window in which the factory replays caller-supplied calls against the new token. This standard takes the second. The typed-parameter approach is simpler to audit, but it fixes the set of things that can be configured at birth, and every later extension of the standard would have to widen the factory signature. The window instead reuses the token's own methods, so it stays correct as the token surface grows. -What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-bnb20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. +What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. **Four policy scopes rather than one account flag.** Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers are open. Collapsing these onto a single per-account flag forces every issuer to write custom logic for the asymmetry — one of the duplications this standard exists to remove. Four independent slots ([§3.8](#38-transfer-policies)) express all of them by configuration, and the executor axis in particular has no BEP-20 equivalent. **Memo as a separate event.** Reconciliation references are the one piece of payment metadata that has no home in BEP-20, and the cost of not having it is an entire off-chain matching layer. Widening `Transfer` to carry it would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. -**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a BNB20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. +**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. **A dedicated role for rescaling and disclosure, not folded into `DEFAULT_ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit — so it and `announce` are gated by their own `OPERATOR_ROLE` ([§3.12](#312-asset-variant-extensions)). Keeping it distinct from `MINT_ROLE` matters for a second reason: restating value and issuing new units are the two operations that can dilute existing holders, and an issuer should be able to separate and audit them independently. ## 5. Backward Compatibility -This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-bnb20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-b20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. -BNB20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with BNB20 tokens without changes. +B20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B20 tokens without changes. -Because every BNB20 account carries the sentinel ([§3.16](#316-account-sentinel)), `EXTCODESIZE` returns `1` and `EXTCODEHASH` returns `keccak256(0xEF)`. The widespread "non-zero code size means this is a contract" heuristic therefore classifies BNB20 tokens correctly, and Solidity's own `EXTCODESIZE` check before an external call is satisfied. Two narrower assumptions do not survive, and integrators relying on either MUST use `isBNB20()` instead: +Because every B20 account carries the sentinel ([§3.16](#316-account-sentinel)), `EXTCODESIZE` returns `1` and `EXTCODEHASH` returns `keccak256(0xEF)`. The widespread "non-zero code size means this is a contract" heuristic therefore classifies B20 tokens correctly, and Solidity's own `EXTCODESIZE` check before an external call is satisfied. Two narrower assumptions do not survive, and integrators relying on either MUST use `isB20()` instead: -- Code that compares `EXTCODEHASH` against a known deployment hash to identify a specific implementation will not match; every BNB20 account shares one hash, which identifies the *class* rather than any particular token. +- Code that compares `EXTCODEHASH` against a known deployment hash to identify a specific implementation will not match; every B20 account shares one hash, which identifies the *class* rather than any particular token. - Code that treats a one-byte or otherwise "unexecutable" code size as a self-destructed or malformed contract will misread a live token. Tooling that pre-warms precompile bytecode in a fork (some local test harnesses do this so Solidity's code-size check passes) will find the sentinel already present and MUST NOT treat that as evidence that a token exists. -Gas estimation must execute rather than predict. A BNB20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. +Gas estimation must execute rather than predict. A B20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. ## 6. Security Considerations ### 6.1 Consensus-Level Blast Radius -This logic runs as native client code, not as an isolated contract. A single implementation bug can affect every BNB20 token at once — every token of the affected variant, or every BNB20 token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher testing bar than a single-contract deployment: differential testing against a BEP-20 reference implementation, and fuzzing of the storage layout and gas accounting, MUST precede mainnet activation. +This logic runs as native client code, not as an isolated contract. A single implementation bug can affect every B20 token at once — every token of the affected variant, or every B20 token of either variant if the defect sits in shared logic such as roles, policies, or pause — and can only be fixed by a further hard fork. This warrants a higher testing bar than a single-contract deployment: differential testing against a BEP-20 reference implementation, and fuzzing of the storage layout and gas accounting, MUST precede mainnet activation. ### 6.2 Activation Authority @@ -715,9 +715,9 @@ The activation admin ([§3.15](#315-feature-activation)) can halt new token and ### 6.3 Identity-Fingerprint Strength -A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createBNB20` rejects an address that already exists. The attack that matters is targeted: `getBNB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. +A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-bnb20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-b20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. ### 6.4 Privileged Keys @@ -737,16 +737,16 @@ What the bounds do not remove is precision loss. Every multiplier-derived read f Pricing an operation at the cost of the state accesses it performs ([§3.14](#314-gas-accounting)) removes the bytecode-interpretation overhead that a BEP-20 transfer incidentally pays. That overhead was never a state-growth control, but it did act as one: with it gone, the same amount of gas buys more permanent storage slots than before. The per-slot price is unchanged — what changes is the total number of slots a block can create. -Deriving cost from existing cost functions bounds this but does not eliminate it. BNB20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. +Deriving cost from existing cost functions bounds this but does not eliminate it. B20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. -Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, BNB20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). +Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, B20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). ### 6.7 Integration Assumptions - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. -- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps BNB20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. -- **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every BNB20 account shares one code hash, so it identifies the class, not the token. -- **Precompile address allocation.** The BNB20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. +- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. +- **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B20 account shares one code hash, so it identifies the class, not the token. +- **Precompile address allocation.** The B20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. ## 7. License From 2ba7f6cddcf3f0e71e632ff84a46cf976304e7fa Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 17:22:05 +0800 Subject: [PATCH 09/41] BEP-702: allocate the 0x20B address namespace Fills the two addresses that were left to be allocated and moves the whole family into one namespace: B20Factory 0x20BF... PolicyRegistry 0x20BC... ActivationRegistry 0x20BA... token space 0x20B0 + 8 zero bytes The token marker becomes the two bytes 0x20B0, replacing the single byte 0xb2, with one byte moved out of the padding to pay for it. The layout is otherwise unchanged: a ten-byte marker region, the variant at byte [10], and a nine-byte fingerprint. Padding at eight bytes still puts a pre-existing or ground account in the reserved space out of reach. The second byte is what separates the family members, and the token space pins it to 0xB0, so no singleton can be mistaken for a token or collide with one. A later BEP extending this one takes another second byte rather than opening an unrelated namespace. This also drops the previous 0xb2 prefix, which was identical to Base's. That mattered: the fingerprint preimage is keccak256(creator, salt) with no chain identifier, so a shared prefix would have given the same (creator, salt) a byte-identical token address on both chains and made cross-chain impersonation possible. A distinct prefix removes it. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index f6886712..8da9fd7a 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -71,9 +71,11 @@ The three singletons occupy fixed addresses, identical on every network: | Contract | Address | |---|---| -| B20Factory | `0xB20F000000000000000000000000000000000000` | -| ActivationRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | -| PolicyRegistry | *(to be allocated — see [§6.7](#67-integration-assumptions))* | +| B20Factory | `0x20BF000000000000000000000000000000000000` | +| PolicyRegistry | `0x20BC000000000000000000000000000000000000` | +| ActivationRegistry | `0x20BA000000000000000000000000000000000000` | + +All three share the `0x20B` nibble prefix with the token address space, and are distinguished from it — and from each other — by the second byte: `0xBF` for the factory, `0xBC` for compliance, `0xBA` for activation. Because the token space requires that second byte to be exactly `0xB0` ([§3.3](#33-b20-address-space)), no singleton can ever be mistaken for a token or collide with one. B20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b20-address-space)). @@ -133,8 +135,8 @@ A B20 token address is 20 bytes: | Bytes | Length | Content | Meaning | |---|---|---|---| -| `[0]` | 1 | `0xb2` | Fixed marker identifying the address as a B20 token | -| `[1:10]` | 9 | `0x00` × 9 | Namespace padding — together with `[0]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | +| `[0:2]` | 2 | `0x20B0` | Fixed marker identifying the address as a B20 token | +| `[2:10]` | 8 | `0x00` × 8 | Namespace padding — together with `[0:2]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | | `[10]` | 1 (the 11th byte) | Variant discriminant | `0x00` = Asset, `0x01` = Stablecoin ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | | `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the B20Factory and `salt` is caller-supplied entropy | @@ -746,7 +748,7 @@ Raising the price of state creation is out of scope for this standard, and delib - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. - **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. - **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B20 account shares one code hash, so it identifies the class, not the token. -- **Precompile address allocation.** The B20Factory and PolicyRegistry addresses, and the reserved native-token prefix, MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. +- **Precompile address allocation.** The whole `0x20B…` namespace — the three singleton addresses and the `0x20B0` token prefix ([§3.1](#31-architecture-overview), [§3.3](#33-b20-address-space)) — MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. A later BEP extending this one SHOULD take a further second byte from the same namespace rather than opening an unrelated one, so that the family stays recognizable from the address. ## 7. License From 0a492d98189c28714964980308f7db91ccbfe2f5 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 17:23:25 +0800 Subject: [PATCH 10/41] BEP-702: move the registries to 0x7020...0001 and ...0002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the allocation across two namespaces by what each address has to do, rather than keeping all four in 0x20B: token space 0x20B0 + 8 zero bytes B20Factory 0x20BF... PolicyRegistry 0x7020...0001 ActivationRegistry 0x7020...0002 The token prefix is load-bearing — call routing reads it on every call, so it must be recognizable from the address alone — and the factory sits beside it in the same namespace, separated by the second byte the token space pins to 0xB0. The registries carry no such structural requirement, since they are reached through a fixed constant, so they take sequential slots under this BEP's own number. Section 6.7 follows: both namespaces need the allocation check, the 0x20B0 prefix needs the wider one because it reserves a range rather than a slot, and a later BEP must not introduce a second token prefix — routing knows exactly one, and a second would have to be threaded through every caller that tests for a B20 address. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 8da9fd7a..c1df6a96 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -72,10 +72,10 @@ The three singletons occupy fixed addresses, identical on every network: | Contract | Address | |---|---| | B20Factory | `0x20BF000000000000000000000000000000000000` | -| PolicyRegistry | `0x20BC000000000000000000000000000000000000` | -| ActivationRegistry | `0x20BA000000000000000000000000000000000000` | +| PolicyRegistry | `0x7020000000000000000000000000000000000001` | +| ActivationRegistry | `0x7020000000000000000000000000000000000002` | -All three share the `0x20B` nibble prefix with the token address space, and are distinguished from it — and from each other — by the second byte: `0xBF` for the factory, `0xBC` for compliance, `0xBA` for activation. Because the token space requires that second byte to be exactly `0xB0` ([§3.3](#33-b20-address-space)), no singleton can ever be mistaken for a token or collide with one. +Two namespaces are in use, and the split follows what each address has to do. `0x20B…` carries the token space and its factory, because the token prefix does routing work on every call ([§3.3](#33-b20-address-space)) and so must be recognizable from the address alone; the factory sits beside it, separated by the second byte, and since the token space pins that byte to `0xB0` the two can never be confused or collide. The registries need no such structural property — they are reached through a fixed constant — so they take sequential slots in `0x7020…`, this BEP's own number. B20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b20-address-space)). @@ -748,7 +748,7 @@ Raising the price of state creation is out of scope for this standard, and delib - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. - **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. - **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B20 account shares one code hash, so it identifies the class, not the token. -- **Precompile address allocation.** The whole `0x20B…` namespace — the three singleton addresses and the `0x20B0` token prefix ([§3.1](#31-architecture-overview), [§3.3](#33-b20-address-space)) — MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. A later BEP extending this one SHOULD take a further second byte from the same namespace rather than opening an unrelated one, so that the family stays recognizable from the address. +- **Precompile address allocation.** Both namespaces — `0x20B…`, holding the `0x20B0` token prefix and the factory, and `0x7020…`, holding the registries ([§3.1](#31-architecture-overview)) — MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. The `0x20B0` prefix needs the wider check of the two, since it reserves an entire address range rather than a single slot. A later BEP extending this standard SHOULD add singletons under its own number rather than in `0x7020…`, and MUST NOT introduce a second token prefix: routing recognizes exactly one, and a second would have to be added to every caller that checks for a B20 address. ## 7. License From 70e35013390b8dff1c242c0fb69a7d02a8ec16eb Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 17:40:53 +0800 Subject: [PATCH 11/41] BEP-702: record the reserved-space verification and how to do it The pre-activation check was a SHOULD with no method attached. It is now a MUST, with the failure mode it invites spelled out, because the obvious way to perform it silently reports success. The state trie is keyed by keccak256(address) and cannot be enumerated by address prefix, so recovering addresses needs the preimage table, which a node only retains if it synced with preimages enabled. A node without them finds no matches because it can recover no addresses at all, and that is indistinguishable from a clean result. Implementations must confirm the scanned address set was non-empty before calling the outcome a pass. Also states the consequence that motivates the check: an account already in the reserved space at fork time becomes unreachable, since calls route to the token handler and its own code never runs. Records the result for the part that can be checked directly. The three singletons are unoccupied on mainnet at block 113,754,318 and on Chapel at 122,896,297. The token range cannot be enumerated, so the argument there is structural: the ten-byte marker puts accidental collision around 10^-15 against BSC's account count and grinding at 2^80 hashes. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index c1df6a96..aaa32df8 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -161,7 +161,11 @@ Existence is determined by the presence of the account sentinel ([§3.16](#316-a Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an implementation SHOULD verify before activation that no account already in the reserved space carries a non-zero nonce, non-empty code, or non-empty storage, and `createB20` SHOULD reject a derived address in that condition. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying the reserved space at that point would become unreachable — calls to it would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. + +That verification is not a range scan. The state trie is keyed by `keccak256(address)` and so cannot be enumerated by address prefix; recovering addresses requires the preimage table, which a node retains only if it was synced with preimages enabled. A node without them yields no matches because it can recover no addresses at all — a result indistinguishable from a clean one. An implementation MUST therefore confirm that the address set it scanned was non-empty before treating the outcome as a pass. Where preimages are unavailable, the address set can be reconstructed from chain history instead. + +The three singleton addresses admit a direct check, and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and on the Chapel testnet at block 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte marker leaves occupation implausible rather than merely unlikely: accidental collision sits on the order of 10^-15 against BSC's account count, and deliberately grinding an address into the range costs 2^80 hash operations. ### 3.4 B20Factory From 31b736671c240242011a5a69bac5a7242be72769 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 17:45:07 +0800 Subject: [PATCH 12/41] BEP-702: fix eight statements left stale by earlier revisions A full read turned up eight places where text contradicted another section after the design-doc alignment, the sentinel change, or the rename: - 3.3 still resolved a marker-matching-but-absent address by "reading Registry state ... charged as a storage access". Existence has been the sentinel's code hash since it was introduced, and 3.14 prices a code read as an account access, so the row was wrong twice over. - 3.10 carried an ADMIN_ROLE comment the rename to DEFAULT_ADMIN_ROLE missed, because it was bare rather than backticked. - 3.10 described supplyCap as a createB20 parameter with a default. It has not been one since creation parameters became variant-specific; the factory initializes the cap and an issuer changes it in the bootstrap window. - Section 4 said "two singleton precompiles". There are three. - Section 4 justified the activation flag by "holding it in the B20Factory's own storage keeps the component count unchanged". It lives in its own registry, which raises the count. Replaced with the reason that actually holds: the flag answers to the chain operator while everything in a token or the factory answers to an issuer, so the two should not share storage. - 3.1 and 6.2 still spoke of creating "lists", vocabulary left from the Transfer List Registry. - 6.3 read as an open TODO ("until the fingerprint length is settled") and left the tension with 3.3 unstated. Now names the trade-off in both directions and records why the split favours the marker: grinding is only exploitable before the fork, a fingerprint collision forever. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index aaa32df8..c1c0c8bf 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -91,7 +91,7 @@ A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. Every B20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. -Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and lists, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). +Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and policies, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). ### 3.2 Stateful Precompiled Contracts @@ -153,7 +153,7 @@ Lying inside the reserved space is not the same as existing. An address may matc | Target address | Behavior | |---|---| | Marker matches, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | -| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case requires reading Registry state, and that read MUST be charged as a storage access like any other | +| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | | Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | | Marker does not match | Ordinary account, unchanged | @@ -460,11 +460,11 @@ interface IB20SupplyCap { event SupplyCapUpdated(uint256 previousCap, uint256 newCap); function supplyCap() external view returns (uint256); - function updateSupplyCap(uint256 newCap) external; // ADMIN_ROLE-gated + function updateSupplyCap(uint256 newCap) external; // DEFAULT_ADMIN_ROLE-gated } ``` -Passing `type(uint128).max` to `createB20` — the default if the caller supplies no lower value — means the token carries no cap at all. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. A cap above `type(uint128).max` is rejected with `SupplyCapTooLarge`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. @@ -678,9 +678,9 @@ The sentinel is written once, at creation, and never removed. Implementations MU ## 4. Rationale -**Two singleton precompiles instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. +**Three singletons instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token; the ActivationRegistry is chain-level rather than token-level state and belongs to the operator, not to any issuer. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. -**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. Holding the flag in the B20Factory's own storage keeps the component count unchanged, and vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. +**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. The flag lives in a registry of its own rather than in the factory's storage, because its controller is the chain operator while everything in a token or the factory answers to an issuer; keeping the two apart means the activation key is never adjacent to issuer state. Vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. **A privileged bootstrap window instead of typed-only creation parameters.** A newly created token has no role holders, so the first `grantRole` cannot be authorized by anything. Two ways out exist: enumerate every possible initial setting as a typed factory parameter, or open a bounded privileged window in which the factory replays caller-supplied calls against the new token. This standard takes the second. The typed-parameter approach is simpler to audit, but it fixes the set of things that can be configured at birth, and every later extension of the standard would have to widen the factory signature. The window instead reuses the token's own methods, so it stays correct as the token surface grows. @@ -717,13 +717,13 @@ This logic runs as native client code, not as an isolated contract. A single imp ### 6.2 Activation Authority -The activation admin ([§3.15](#315-feature-activation)) can halt new token and list creation network-wide, and deliberately cannot touch tokens that already exist. The worst outcome of a compromised or misused activation key is therefore that issuance halts, or that a feature opens earlier than intended — not that balances are frozen or moved. Vesting the key in the timelock keeps the "opened too early" direction under the same review as any other governance action, and `setActivationAdmin` lets a suspected compromise be remediated without a hard fork. +The activation admin ([§3.15](#315-feature-activation)) can halt new token and policy creation network-wide, and deliberately cannot touch tokens that already exist. The worst outcome of a compromised or misused activation key is therefore that issuance halts, or that a feature opens earlier than intended — not that balances are frozen or moved. Vesting the key in the timelock keeps the "opened too early" direction under the same review as any other governance action, and `setActivationAdmin` lets a suspected compromise be remediated without a hard fork. ### 6.3 Identity-Fingerprint Strength A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems, and the reserved padding ([§3.3](#33-b20-address-space)) offers a cheap remedy: moving bytes from the padding into the fingerprint raises the targeted-collision cost substantially while still leaving a marker long enough that a pre-existing account in the reserved space remains very unlikely. Until the fingerprint length is settled, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space before activation. The current split favours the marker, on the grounds that grinding is only exploitable in the window before the fork while a fingerprint collision stays exploitable forever; an implementation that revisits it SHOULD keep the marker at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. ### 6.4 Privileged Keys From 085679a7e7ae272712664315db3fd9685268ae31 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Mon, 3 Aug 2026 19:20:42 +0800 Subject: [PATCH 13/41] BEP-702: move rationale out of the spec sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 4 is now numbered subsections, several of which restate reasoning the specification sections had inline: 4.8 duplicated the no-gas-schedule argument in 3.14, 4.9 the 0xEF argument in 3.16, 4.10 the decimals bounds in 3.4, 4.5 the role splits in 3.7, 4.4 the executor scope in 3.8. Each is now stated once in section 4 and referenced from the specification, which is the split the house format assumes: what belongs in Specification, why in Rationale. 3.7 and 4.5 also disagreed on the count — "three splits" against "four" — which the move resolves. Compressed 3.14, 3.12, 3.8, 3.16, 3.3, 3.1 and 3.15 without dropping a normative statement: long explanatory paragraphs became single sentences, duplicated clauses were deleted, and the memo section's separate-event justification now points at 4.6 rather than repeating it. Marks batchMint and updateExtraMetadata in 3.7's role table as Asset-variant only. The table is introduced as roles shared by both variants and listed two methods that exist on one. Net 84.8 KB to 80.6 KB with the interface blocks, which are about a third of the file, untouched. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 145 ++++++++++++++++++++++++++++-------------------- 1 file changed, 84 insertions(+), 61 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index c1c0c8bf..c07a5fd4 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -30,6 +30,16 @@ - [3.15 Feature Activation](#315-feature-activation) - [3.16 Account Sentinel](#316-account-sentinel) - [4. Rationale](#4-rationale) + - [4.1 Three Singletons](#41-three-singletons) + - [4.2 A Governance Switch on Top of the Fork](#42-a-governance-switch-on-top-of-the-fork) + - [4.3 A Privileged Bootstrap Window](#43-a-privileged-bootstrap-window) + - [4.4 Four Policy Scopes Rather Than One Account Flag](#44-four-policy-scopes-rather-than-one-account-flag) + - [4.5 Separate Roles for Mint, Burn, Seizure, and Rescaling](#45-separate-roles-for-mint-burn-seizure-and-rescaling) + - [4.6 Memo as a Separate Event](#46-memo-as-a-separate-event) + - [4.7 Prefix-Recognizable Addressing](#47-prefix-recognizable-addressing) + - [4.8 No Gas Schedule](#48-no-gas-schedule) + - [4.9 `0xEF` as the Account Sentinel](#49-0xef-as-the-account-sentinel) + - [4.10 Decimals Bounds](#410-decimals-bounds) - [5. Backward Compatibility](#5-backward-compatibility) - [6. Security Considerations](#6-security-considerations) - [6.1 Consensus-Level Blast Radius](#61-consensus-level-blast-radius) @@ -89,7 +99,7 @@ Authorization is layered, and each layer answers a different question: A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. -Every B20 token created through the B20Factory is assigned a deterministic 20-byte address in a reserved address space ([§3.3](#33-b20-address-space)). No *executable* bytecode is stored there — only a one-byte sentinel ([§3.16](#316-account-sentinel)) that is never run. The EVM's call-dispatch logic is extended to recognize addresses in the reserved space and route calls to a shared B20 token handler parameterized by the target address: the variant discriminant read from the address decides which method set is exposed — [§3.6](#36-shared-token-interface) alone for a Stablecoin-variant address, or that surface plus [§3.12](#312-asset-variant-extensions) for an Asset-variant one — and the address alone determines which token's state is read and written. This is the same singleton pattern the registries use: one piece of code serving every caller, differentiated only by address. +Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. It is the singleton pattern the registries already use — one piece of code serving every caller, differentiated only by address. Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and policies, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). @@ -163,9 +173,9 @@ Existence MUST NOT be inferred from nonce or balance. Any address — reserved o Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying the reserved space at that point would become unreachable — calls to it would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. -That verification is not a range scan. The state trie is keyed by `keccak256(address)` and so cannot be enumerated by address prefix; recovering addresses requires the preimage table, which a node retains only if it was synced with preimages enabled. A node without them yields no matches because it can recover no addresses at all — a result indistinguishable from a clean one. An implementation MUST therefore confirm that the address set it scanned was non-empty before treating the outcome as a pass. Where preimages are unavailable, the address set can be reconstructed from chain history instead. +That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without them yields no matches because it can recover no addresses at all — indistinguishable from a clean result. An implementation MUST therefore confirm the scanned address set was non-empty before treating the outcome as a pass, or reconstruct the set from chain history instead. -The three singleton addresses admit a direct check, and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and on the Chapel testnet at block 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte marker leaves occupation implausible rather than merely unlikely: accidental collision sits on the order of 10^-15 against BSC's account count, and deliberately grinding an address into the range costs 2^80 hash operations. +The three singletons admit a direct check and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and Chapel at 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte marker makes occupation implausible rather than merely unlikely: accidental collision is on the order of 10^-15 against BSC's account count, and grinding into the range costs 2^80 hashes. ### 3.4 B20Factory @@ -228,7 +238,7 @@ Any failing `initCall` reverts the entire creation. A call shorter than four byt **Admin-less tokens.** `initialAdmin` MAY be the zero address. Combined with `initCalls`, this is how an issuer creates a permanently immutable token: roles, policies, supply cap, and initial distribution are all configured inside the bootstrap window, after which no admin exists and no role assignment can ever change ([§3.7](#37-roles-and-access-control)). An issuer that wants an admin during setup and immutability afterwards instead passes a real `initialAdmin` and calls `renounceLastAdmin()` when finished. Both routes reach the same terminal state; they differ only in whether an admin key ever existed. -`decimals` is fixed at creation and never changes. For the Asset variant, the lower bound of `6` exists because less precision cannot represent the sub-unit amounts that asset use cases routinely need, and `18` is the widest precision the surrounding ecosystem handles uniformly. Issuers SHOULD prefer `18` where the multiplier will be used: every multiplier-derived read floors ([§3.12](#312-asset-variant-extensions)), so at low precision a large reverse rescaling can leave rounding dust that is economically visible. +`decimals` is fixed at creation and never changes ([§4.10](#410-decimals-bounds)). Asset-variant issuers SHOULD prefer `18` where the multiplier will be used: every multiplier-derived read floors ([§3.12](#312-asset-variant-extensions)), so at low precision a large reverse rescaling can leave rounding dust that is economically visible. ### 3.5 Variants @@ -286,9 +296,7 @@ interface IB20 { - `burnBlocked` is scoped entirely to accounts already denied under `TRANSFER_SENDER_POLICY` ([§3.8](#38-transfer-policies)). Against an account in good standing it fails with `AccountNotBlocked`. This makes the enforcement sequence structural rather than procedural: an address MUST be frozen by policy before its balance can be swept, so a seizure can never be the first action taken against it. It emits `BurnedBlocked` in addition to the `Transfer` to the zero address, giving indexers a distinguishable enforcement record. - `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. -**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`. Behavior is identical to the base method, plus one `Memo` event emitted immediately after the operation's own event. A memo of zero is permitted. The memo is a reconciliation reference — invoice number, customer ID, cost centre, purchase order — mirroring the structured reference that accompanies a payment in traditional systems. Both fields are indexed so an indexer can filter by payer or by reference without scanning. Data exceeding 32 bytes, or carrying personal information, SHOULD be committed by hash with the payload held off-chain rather than expanded on-chain. - -Emitting the memo as a **separate event** rather than widening `Transfer` is deliberate: `Transfer`'s signature is the single most depended-upon ABI in the ecosystem, and altering it would break every existing indexer. A separate event costs one additional log and leaves the compatibility surface untouched. +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. Zero is permitted. The memo is a reconciliation reference — invoice number, customer ID, cost centre, purchase order — and both its fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. @@ -336,18 +344,18 @@ Built-in roles shared by both variants: | Role | ID | Gates | |---|---|---| | `DEFAULT_ADMIN_ROLE` | `bytes32(0)` | Role grants/revocations, `setRoleAdmin`, `updatePolicy`, `updateSupplyCap` | -| `MINT_ROLE` | `keccak256("MINT_ROLE")` | `mint`, `mintWithMemo`, `batchMint` | +| `MINT_ROLE` | `keccak256("MINT_ROLE")` | `mint`, `mintWithMemo`, and `batchMint` on the Asset variant | | `BURN_ROLE` | `keccak256("BURN_ROLE")` | `burn`, `burnWithMemo` (self-burn only) | | `BURN_BLOCKED_ROLE` | `keccak256("BURN_BLOCKED_ROLE")` | `burnBlocked` | | `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")` | `pause` | | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")` | `unpause` | -| `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, `updateExtraMetadata` | +| `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata` on the Asset variant | The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). -Three splits are deliberate. **Mint and burn** are separate keys so the two can be issued, rotated, and revoked independently: a compromised mint key and a compromised burn key are different incidents with different blast radii. **Pause and unpause** are separate so a single leaked key can only ever push the token in one direction — an attacker holding one cannot complete a stop-then-resume cycle. **`BURN_BLOCKED_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `burnBlocked` destroys funds it does not own, and only once policy has already denied the target. Collapsing them would let one signature authorize both. +The splits between mint and burn, pause and unpause, `BURN_ROLE` and `BURN_BLOCKED_ROLE`, and `OPERATOR_ROLE` and the rest are each deliberate; see [§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling). -Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. +Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any operation; it is an on-chain membership record for the issuer or third-party contracts to query, or an intermediate tier in a `setRoleAdmin` hierarchy. **The last admin.** Each token maintains a count of `DEFAULT_ADMIN_ROLE` holders — the one piece of state this model adds over the conventional `AccessControl` layout — and three protections follow from it: @@ -381,9 +389,9 @@ interface IB20Policy { } ``` -`TRANSFER_EXECUTOR_POLICY` is the axis that has no equivalent in a plain BEP-20 blocklist: it constrains *who may move someone else's balance*, independently of whether either party is itself authorized. A regulated asset can require that delegated transfers be executed only by licensed brokers, while holders remain under a separate investor allowlist. It is checked only for genuine third-party transfers — a `transferFrom` where the spender is the owner is not a delegated transfer and is not subject to it. +`TRANSFER_EXECUTOR_POLICY` constrains *who may move someone else's balance*, independently of whether either party is authorized ([§4.4](#44-four-policy-scopes-rather-than-one-account-flag)). It applies only to genuine third-party transfers — a `transferFrom` where the spender is the owner is not delegated and is not subject to it. -Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a freshly created token is completely open and compliance is opt-in. The gate applies only to operations that move a balance, never to `approve`: an account denied under any scope can still set or hold an allowance, since granting permission to move funds is not itself a movement of funds. `burn` (self-burn) and all reads are likewise ungated. +Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a new token is fully open and compliance is opt-in. Only balance-moving operations are gated: `approve`, self-`burn`, and all reads are not, since granting permission to move funds is not itself a movement of funds. The PolicyRegistry is a separate singleton precompiled contract, independent of any specific token, so many tokens can share one list — the central reason for hoisting compliance state out of the token: @@ -416,11 +424,11 @@ interface IPolicyRegistry { A `BLOCKLIST` authorizes everyone except the accounts added to it; an `ALLOWLIST` authorizes no one except the accounts added. Membership updates are type-checked: calling `updateAllowlist` on a `BLOCKLIST` fails with `IncompatiblePolicyType`. Each batch is limited to **64 addresses** (`BatchSizeTooLarge`), which bounds the work a single call can do — a requirement of [§3.14](#314-gas-accounting) for any entry point whose cost scales with its input. Adding an existing member or removing an absent one is idempotent. -**Admin lifecycle.** Admin transfer is two-step: the incumbent calls `stageUpdateAdmin` to nominate (passing the zero address cancels), and the nominee must call `finalizeUpdateAdmin` themselves to take over. A single-step transfer to a mistyped address would hand control of a live compliance list to an unreachable account; requiring the nominee to act proves the destination is controlled. `renounceAdmin` permanently freezes a policy: its membership can never change again, while all reads keep working. A frozen policy is a product in its own right — a genesis allowlist that is guaranteed never to expand. The registry distinguishes a frozen policy (exists, judged against its final membership) from one that was never created (judged as the empty set). +**Admin lifecycle.** Transfer is two-step: the incumbent nominates with `stageUpdateAdmin` (zero address cancels) and the nominee must call `finalizeUpdateAdmin` to take over, which proves the destination is controlled — a single-step transfer to a mistyped address would hand a live compliance list to an unreachable account. `renounceAdmin` permanently freezes a policy: membership can never change again while reads keep working, which makes a genesis allowlist guaranteed never to expand expressible. A frozen policy remains distinguishable from one never created: the first is judged against its final membership, the second as the empty set. A `policyId` is self-describing: its most significant byte encodes the `PolicyType` (`0x00` = `BLOCKLIST`, `0x01` = `ALLOWLIST`; any other value is not a valid type), and its low 56 bits are a counter. Any caller can determine a policy's type from the ID alone, with no storage read — the same principle as encoding the variant in the token address. -`isAuthorized` **never reverts**, because it sits on the path of every transfer: if it could revert, a single misconfigured policy would render a token permanently unusable. An unrecognized ID is treated as an **empty policy of the type its own most significant byte encodes**, and an ID whose type byte is not a valid `PolicyType` authorizes no one. The refusal itself is raised by the token, as `PolicyForbids(scope, policyId)`; the registry only ever answers true or false. +`isAuthorized` **never reverts** — it sits on the path of every transfer, and a revert would let one misconfigured policy render a token permanently unusable. An unrecognized ID is treated as an **empty policy of the type its own most significant byte encodes**; an invalid type byte authorizes no one. The refusal is raised by the token as `PolicyForbids(scope, policyId)`; the registry only answers true or false. Two sentinel IDs therefore exist without any `createPolicy` call, and both follow from the rule above rather than being special-cased: @@ -522,9 +530,9 @@ interface IB20Asset { } ``` -`balanceOf` (inherited from [§3.6](#36-shared-token-interface)) always returns the raw, unscaled balance, and `multiplier()` never changes what `transfer`, `transferFrom`, `mint`, or `burn` actually move — only the display-facing conversion functions are affected. `toScaledBalance`/`toRawBalance` apply the current multiplier in each direction and exist purely as a display convenience for wallets and explorers; `scaledBalanceOf(account)` is equivalent to calling `toScaledBalance(balanceOf(account))` in a single round trip. Because the conversion divides by `1e18`, converting a raw amount to its scaled form and back is not guaranteed to reproduce the original value exactly — callers that need precise accounting MUST treat the raw balance as authoritative and use the scaled value for display only. +`balanceOf` returns the raw, unscaled balance, and the multiplier never changes what `transfer`, `transferFrom`, `mint`, or `burn` move — only the three conversion views are affected. `scaledBalanceOf(account)` equals `toScaledBalance(balanceOf(account))` in one round trip. Because the conversion divides by `1e18`, a raw→scaled→raw round trip need not reproduce the original: callers requiring exact accounting MUST treat the raw balance as authoritative and the scaled value as display only. -`updateMultiplier` is gated by `OPERATOR_ROLE`, a role of its own rather than `DEFAULT_ADMIN_ROLE` or `METADATA_ROLE` ([§4](#4-rationale)). It MUST reject a `newMultiplier` of zero, which would make every account's scaled balance permanently zero with no way to recover a meaningful display value. +`updateMultiplier` is gated by `OPERATOR_ROLE` ([§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling)). It MUST reject a `newMultiplier` of zero, which would make every account's scaled balance permanently zero with no way to recover a meaningful display value. It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMultiplier`. This bound is the other half of the overflow guard described in [§3.10](#310-supply-cap): with both the supply cap and the multiplier bounded by `type(uint128).max`, the product `rawBalance * multiplier` never exceeds `uint256`, and dividing by `WAD` only shrinks it further. Two consequences are deliberate — an implementation needs no wide-arithmetic intermediate, and no scaled read can revert or truncate because of overflow. See [§6.5](#65-multiplier-bounds-and-precision) for the risks the bounds do not remove. @@ -536,11 +544,11 @@ It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMu **Extra metadata.** A free-form `string => string` map for issuer-defined attributes (`"isin"`, `"category"`, `"region"`) that institutional systems read. An unset key returns the empty string; writing the empty string deletes the entry. An empty key is rejected with `InvalidMetadataKey`. -**Relationship to [BEP-677](./BEP-677.md).** BEP-677 brings the same scaling concept to BEP-20 contracts via [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056), and additionally specifies *scheduled* multiplier changes that take effect at a future timestamp. Two differences are intentional. First, BEP-677 governs issuer-written code and can therefore only *recommend* multiplier bounds at the application layer; this standard governs protocol code and enforces them. Second, this standard defines an immediate multiplier only — an issuer needing scheduled changes today implements BEP-677 on a BEP-20 contract, and extending scheduling to the Asset variant is left to a future BEP, which would have to define how a pending change interacts with `pause` and with the supply cap. +**Relationship to [BEP-677](./BEP-677.md).** BEP-677 brings the same scaling concept to BEP-20 contracts via [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056), and adds *scheduled* changes taking effect at a future timestamp. Two differences are intentional: BEP-677 governs issuer-written code and can only *recommend* multiplier bounds, whereas this standard governs protocol code and enforces them; and this standard defines an immediate multiplier only. Scheduling on the Asset variant is left to a future BEP, which would have to define how a pending change interacts with `pause` and the supply cap. -**The multiplier is not a yield mechanism.** It rescales every holder by the same factor, with no opt-in, no per-holder accounting, and no separately claimable asset. It therefore expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out and claimed. This standard defines no yield or reward-distribution primitive; an issuer needing one builds it at the application layer. +**Not a yield mechanism.** The multiplier rescales every holder by the same factor, with no opt-in, no per-holder accounting, and nothing separately claimable. It expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out. This standard defines no yield primitive; an issuer needing one builds it at the application layer. -This section applies only to tokens created with `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset` at all: its dispatch handler ([§3.1](#31-architecture-overview)) never recognizes these selectors, so such a call reverts exactly as any other unrecognized selector would. +This section applies only to `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset`, so its dispatch handler never recognizes these selectors and such a call reverts as any unrecognized selector would. ### 3.13 Stablecoin Variant Extension @@ -560,47 +568,34 @@ The variant is deliberately narrow: no multiplier, no announcements, no batch mi ### 3.14 Gas Accounting -**This standard introduces no gas parameters of its own.** There is no per-selector price table and no new constant. Every charge a B20 operation incurs is produced by an existing EVM cost function, applied to the work the operation actually performs, and metered as it performs it. - -That is a deliberate choice rather than an omission. A fixed price per entry point would be wrong in both directions: several entry points do work proportional to their input (`updateMembers` and `batchMint` over arrays, `permit`'s ECDSA recovery, any dynamic `string`/`bytes` argument), so one flat number would overcharge small inputs and underprice large ones — and underpricing large ones is precisely the state-growth gap this section exists to close. A published table would also have to be revised every time the surrounding gas schedule moved, and would silently diverge from it in between. +**This standard introduces no gas parameters of its own** — no per-selector price table, no new constant. Every charge is produced by an existing EVM cost function, applied to the work performed and metered as it is performed. See [§4.8](#48-no-gas-schedule) for why no table is published. -Because B20 operations bypass the EVM's opcode dispatch, the implementation MUST replicate the EVM's own accounting rather than approximate it. A B20 operation MUST NOT be cheaper than performing the same state accesses through bytecode; otherwise the two paths are mispriced relative to each other and the cheaper one is arbitraged. +Because B20 operations bypass opcode dispatch, the implementation MUST replicate the EVM's accounting rather than approximate it, and a B20 operation MUST NOT be cheaper than the same state accesses performed through bytecode. | Work performed | Charged as | |---|---| -| Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm base cost always, plus the cold-storage surcharge on first access within the transaction. Charged identically whichever account's storage is read — see below | -| Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the EIP-2929 cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds (including refund reversal on revert) | -| Reading an account's balance, nonce, or code | The EIP-2929 account-access cost: warm base cost always, plus the cold-account surcharge on first touch | -| Log emission | Log base cost plus per-topic and per-byte costs | -| Hashing | Per-word keccak cost. Namespaced storage layouts derive mapping slots by hashing, so this is not incidental: leaving it unmetered donates hash computation | -| Calldata | Input calldata is charged per 32-byte word, at the existing keccak per-word rate, once per dispatch. This substitutes for the ABI-decoding opcodes a bytecode implementation would have executed and paid for | -| Writing account code | The existing contract-creation costs — the fixed creation cost, the per-byte code-deposit cost, and the keccak of the code. The creation cost is owed whenever the target had no code, **including at an address that already holds a balance**: a prefunded address does not block creation ([§3.3](#33-b20-address-space)) and does not discount it either. This is what the account sentinel ([§3.16](#316-account-sentinel)) pays for | +| Storage read | [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) warm cost always, plus the cold-storage surcharge on first access. Independent of which account owns the slot — see below | +| Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds including reversal on revert | +| Reading an account's balance, nonce, or code | EIP-2929 account-access cost: warm always, plus the cold-account surcharge on first touch | +| Log emission | Log base cost, per topic, per byte | +| Hashing | Per-word keccak cost. Mapping slots are derived by hashing, so leaving it unmetered would donate computation | +| Calldata | Input words at the keccak per-word rate, once per dispatch, substituting for the ABI-decoding opcodes bytecode would have paid for | +| Writing account code | The existing creation cost, per-byte deposit cost, and keccak of the code. The creation cost is owed whenever the target had no code, **including at a prefunded address** ([§3.3](#33-b20-address-space)). This is what the sentinel ([§3.16](#316-account-sentinel)) pays for | -**Storage reads are account-agnostic.** A B20 token reads a registry's storage directly, and this is charged as an ordinary storage access — the cold-storage surcharge, with no account-access surcharge on top, exactly as if the slot belonged to the token itself. This is the one place where the model has no bytecode equivalent rather than merely a cheaper one: bytecode cannot read another account's storage at all, and must `CALL` the owner, paying the cold-account cost, the call machinery, and the callee's own execution. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose a general foreign-storage read to callers: the only cross-account reads this standard permits are a token consulting the PolicyRegistry and the ActivationRegistry for its own gating decisions. +Every row resolves to whatever the active fork's cost function returns, so a later change to the gas schedule propagates automatically. Should BSC adopt a separate state-gas dimension ([EIP-8037](https://eips.ethereum.org/EIPS/eip-8037), [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038); the `StateGas` field already present in BSC's accounting is the shape they assume), B20 inherits it with no amendment here — precisely because it never restated the numbers. -Because every row above resolves to whatever the active fork's cost function returns, a later change to the surrounding gas schedule propagates to B20 automatically and by construction. If BSC subsequently adopts a separate state-gas dimension — [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) are the current proposals, and the `StateGas` field already present in BSC's gas accounting is the shape they assume — B20 inherits it without any amendment to this standard, precisely because it never restated the numbers. - -**What is deliberately not charged.** The table above is exhaustive. Four categories of cost that a bytecode implementation pays have no counterpart here, and their absence is the entire source of this standard's efficiency claim: - -| Not charged | Why it has no counterpart | -|---|---| -| Per-opcode execution | There are no opcodes; the operation is native code | -| Memory expansion | Arguments and return data are handled natively, so no EVM memory is allocated and the quadratic expansion cost never arises | -| Call machinery | Consulting a registry is a storage read, not a `CALL`: no call cost, no argument or return-data memory, and no second interpretation frame | -| Stack, jump, and arithmetic | Same reason as per-opcode execution | +**Storage reads are account-agnostic.** A token reads a registry's slot at ordinary storage cost, with no account-access surcharge, exactly as if the slot were its own. This is the one place the model has no bytecode equivalent rather than merely a cheaper one: bytecode must `CALL` the owner and pay the call machinery and a second interpretation frame. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose general foreign-storage reads — the only cross-account reads permitted are a token consulting the two registries for its own gating. -An implementation MUST NOT add a synthetic overhead charge to approximate any of these. The requirement that a B20 operation never be cheaper than the equivalent bytecode path is about **state access**, which is charged identically; it is not a licence to price the operation upward toward what an interpreter would have cost. A synthetic surcharge would also be unfalsifiable, since there is no reference against which to check it, and it would defeat the parity test below. +**Not charged.** The table is exhaustive. Per-opcode execution, memory expansion, call machinery, and stack/jump/arithmetic have no counterpart here, and their absence is the whole of this standard's efficiency claim. An implementation MUST NOT add a synthetic overhead charge to approximate them: the never-cheaper-than-bytecode rule concerns state access, which is charged identically, and a synthetic surcharge would be unfalsifiable. -Note that these omissions reduce gas but do not reduce the work a node performs. Gas parity with bytecode on state access says nothing about execution time, which is why the wall-clock measurement below is a separate requirement and not a formality. +**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas stipend, however cheap the write. That check, not the write's own cost, is what makes Solidity's `transfer()`/`send()` safe, since net metering prices a warm dirty rewrite at roughly a hundred gas. A B20 token writes state without executing `SSTORE`, so the implementation MUST apply the same check before any state write. Omitting it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. -**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas call stipend, however cheap that particular write would be. This is the guarantee Solidity's `transfer()`/`send()` rely on: forwarding only the stipend means the recipient cannot change state. Since net metering prices a warm, already-dirty rewrite at roughly a hundred gas, the sentry — not the write's own cost — is what upholds that guarantee. A B20 token writes state without executing the `SSTORE` opcode, so the opcode-level sentry never runs and the implementation MUST apply the same check before any state write. Omitting it would not merely misprice the B20 token; it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. +**Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound — the 64-address batch limit ([§3.8](#38-transfer-policies)) is one; `batchMint`, `announce`'s call bundle, and every dynamic argument need equivalents. -**Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound on that input — the 64-address batch limit of [§3.8](#38-transfer-policies) is one such bound, and `batchMint`, `announce`'s call bundle, and every dynamic `string`/`bytes` argument require equivalents. +Because no schedule is published, verification is behavioural. Two properties SHOULD be measured before mainnet activation: -Because no schedule is published, verification is behavioural rather than a matter of reviewing numbers. Two properties SHOULD be measured before mainnet activation: - -- **Parity.** For each entry point, the gas charged equals the gas an equivalent BEP-20 implementation would pay for the same state accesses, and never less. Differential testing against a reference contract establishes this directly, and it is the property that makes the whole approach safe: correctness reduces to "did we account for every access", not "is this number right". -- **Wall-clock cost.** The worst-case execution time of each entry point, since a chain targeting sub-second blocks is bounded by execution time as well as by gas, and gas parity says nothing about that. +- **Parity** — for each entry point, gas charged equals what an equivalent BEP-20 implementation would pay for the same state accesses, and never less. Differential testing against a reference contract establishes it directly, reducing correctness to "was every access accounted for" rather than "is this number right". +- **Wall-clock cost** — worst-case execution time per entry point. The omissions above reduce gas without reducing the work a node does, and a chain targeting sub-second blocks is bounded by time as well as by gas. ### 3.15 Feature Activation @@ -656,7 +651,7 @@ A token's dispatch entry point performs no activation check at all. This is a no Deactivating a feature therefore stops new issuance and nothing else: tokens already created keep working exactly as before. Freezing activity on a specific token remains the issuer's decision, exercised through that token's own `pause` ([§3.9](#39-pause)), not something a chain operator can do through this switch. Reads are never gated because `isAuthorized` sits on the path of every transfer, and a network-level switch must not be able to make transfers fail. -**Authority.** `activationAdmin` MUST be a governance-controlled address — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from the moment this standard activates, so authority can move without a further hard fork. Its initial value is set in chain configuration; a zero address means no feature can be activated on that network. Because `activate`/`deactivate`/`setActivationAdmin` mutate state, they reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)). Resolving a feature flag reads consensus state and MUST be charged as a storage access ([§3.14](#314-gas-accounting)). +**Authority.** `activationAdmin` MUST be governance-controlled — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from activation onward, so a compromised key needs no hard fork to replace. Its initial value comes from chain configuration; a zero address means nothing can be activated on that network. The three mutating methods reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)), and resolving a flag is charged as a storage read ([§3.14](#314-gas-accounting)). ### 3.16 Account Sentinel @@ -668,9 +663,9 @@ Every account that holds B20 state — each token, and each singleton registry ( It is never executed. Call dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. -**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an account that is *empty* — zero nonce, zero balance, no code — at the end of the block in which it was touched, **and deletes its storage with it**. Emptiness does not consider storage. A B20 token holds every balance, allowance, role, and policy binding in its account storage while having no code, no nonce, and (usually) no balance, so without a sentinel it is precisely an empty account that gets touched on every transfer. The consequence is not a stale flag: the first state-clearing pass after a transfer would erase the entire token, every holder's balance included. Writing state through a path that only mutates the storage trie is exactly what makes this reachable, so the fix cannot live in the storage layer — the account itself must be made non-empty. +**Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an *empty* account — zero nonce, zero balance, no code — at the end of the block it was touched in, **and deletes its storage with it**; emptiness does not consider storage. A B20 token holds every balance, allowance, role, and policy binding in account storage while having no code, no nonce, and usually no balance, so without a sentinel it is exactly such an account, touched on every transfer. The consequence is not a stale flag but total loss: the first clearing pass after a transfer erases the token and every holder's balance. Because the write path only mutates the storage trie, no fix in the storage layer reaches this — the account itself must be non-empty. -**Why `0xEF`.** It is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), which no `CREATE`/`CREATE2` deployment can ever produce. That makes it an unforgeable marker of a protocol-owned account, independent of the reserved-address-space restriction ([§3.3](#33-b20-address-space)) — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce bump would satisfy EIP-161 equally well but would carry none of this: nonces are ordinary account state, indistinguishable from a normal account's, and would leave `EXTCODESIZE` reporting zero. +`0xEF` is the prefix reserved by [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541), so no `CREATE`/`CREATE2` deployment can produce it; see [§4.9](#49-0xef-as-the-account-sentinel). The sentinel is written once, at creation, and never removed. Implementations MUST NOT plant it on an account that already carries code, and the write MUST be charged the same state-creation cost as writing account code by any other means ([§3.14](#314-gas-accounting)). @@ -678,21 +673,49 @@ The sentinel is written once, at creation, and never removed. Implementations MU ## 4. Rationale -**Three singletons instead of a wider set of registries.** The B20Factory only ever creates tokens; the PolicyRegistry is the one piece of shared, cross-token state (compliance lists) that benefits from living outside any single token; the ActivationRegistry is chain-level rather than token-level state and belongs to the operator, not to any issuer. Everything else a token needs — roles, policy references, pause bits, supply cap, and (for the Asset variant) multiplier — is local to that token's own address and requires no additional singleton. +### 4.1 Three Singletons + +The B20Factory only ever creates tokens. The PolicyRegistry is the one piece of cross-token state that benefits from living outside any single token. The ActivationRegistry is chain-level state belonging to the operator, not to an issuer, which is why it is separate from the factory rather than a field in it: the activation key should never sit adjacent to issuer state. Everything else a token needs — roles, policy references, pause bits, supply cap, multiplier — is local to its own address. + +### 4.2 A Governance Switch on Top of the Fork + +The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent issuer-created state may arrive later, may differ between testnet and mainnet, and may need withdrawing if problems surface. Without the flag the only instrument for any of that is another fork. + +The objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it reaches ([§3.15](#315-feature-activation)). It gates creation and nothing else, so a live token's behaviour is fixed by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. + +### 4.3 A Privileged Bootstrap Window + +A new token has no role holders, so its first `grantRole` cannot be authorized by anything. Either every initial setting becomes a typed factory parameter, or the factory replays caller-supplied calls in a bounded privileged window. Typed parameters audit more easily but fix what can be configured at birth, and every later extension would widen the factory signature; the window reuses the token's own methods and stays correct as the surface grows. + +It is not a general escalation: it skips the role gate and the transfer-side policy gates, and never `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys something typed parameters cannot express — a token immutable from birth, configured entirely inside the window with `initialAdmin` zero, so no admin key ever exists to be compromised or subpoenaed. + +### 4.4 Four Policy Scopes Rather Than One Account Flag + +Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers stay open. A single per-account flag forces every issuer to write custom logic for that asymmetry — one of the duplications this standard exists to remove. The executor scope in particular has no BEP-20 equivalent. + +### 4.5 Separate Roles for Mint, Burn, Seizure, and Rescaling + +Four splits, each for a distinct reason. **Mint and burn** are separate keys because a compromised mint key and a compromised burn key are different incidents with different blast radii, and should be rotatable independently. **Pause and unpause** are separate so a single leaked key can only push the token one way; an attacker holding one cannot complete a stop-then-resume cycle. **`BURN_BLOCKED_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `burnBlocked` destroys funds it does not, and only after policy has denied the target. **`OPERATOR_ROLE` is separate from `DEFAULT_ADMIN_ROLE` and `MINT_ROLE`** because `updateMultiplier` restates what every holder's balance is worth in one call; restating value and issuing units are the two ways to dilute existing holders, and an issuer should be able to separate and audit them. + +### 4.6 Memo as a Separate Event + +Reconciliation references have no home in BEP-20, and the cost of not having them is an entire off-chain matching layer. Widening `Transfer` would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. + +### 4.7 Prefix-Recognizable Addressing -**A governance switch on top of hard-fork gating.** The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent, issuer-created state is a narrower judgment that may arrive later, may differ between testnet and mainnet, and may need to be withdrawn if problems surface after a fork; without the flag, the only instrument for any of that is another hard fork. The obvious objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it can reach ([§3.15](#315-feature-activation)): it gates creation and nothing else, so a live token's behavior is determined by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. The flag lives in a registry of its own rather than in the factory's storage, because its controller is the chain operator while everything in a token or the factory answers to an issuer; keeping the two apart means the activation key is never adjacent to issuer state. Vesting it in the timelock reuses BSC's existing governance path instead of introducing a new privileged key. +A fixed marker plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant" from the address alone, with no RPC round-trip — the property BSC's existing precompiles have by occupying low fixed addresses. It is also what makes routing possible without a state read on every call ([§3.3](#33-b20-address-space)). -**A privileged bootstrap window instead of typed-only creation parameters.** A newly created token has no role holders, so the first `grantRole` cannot be authorized by anything. Two ways out exist: enumerate every possible initial setting as a typed factory parameter, or open a bounded privileged window in which the factory replays caller-supplied calls against the new token. This standard takes the second. The typed-parameter approach is simpler to audit, but it fixes the set of things that can be configured at birth, and every later extension of the standard would have to widen the factory signature. The window instead reuses the token's own methods, so it stays correct as the token surface grows. +### 4.8 No Gas Schedule -What makes it acceptable is that the window is not a general privilege escalation. It skips exactly two checks — the role gate and the transfer-side policy gates — and never skips `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys a capability the typed approach cannot express at all: a token that is immutable from birth, configured entirely inside the window with `initialAdmin` set to zero, so no admin key ever exists to be compromised or subpoenaed. +A flat price per entry point is wrong in both directions for entry points whose work scales with input, and underpricing large inputs is exactly the state-growth gap [§3.14](#314-gas-accounting) exists to close. A published table would also have to be revised whenever the surrounding schedule moved, and would silently diverge in between. Deriving every charge from an existing cost function instead means a later change to the gas schedule — including adoption of a separate state-gas dimension — propagates without amending this standard. -**Four policy scopes rather than one account flag.** Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers are open. Collapsing these onto a single per-account flag forces every issuer to write custom logic for the asymmetry — one of the duplications this standard exists to remove. Four independent slots ([§3.8](#38-transfer-policies)) express all of them by configuration, and the executor axis in particular has no BEP-20 equivalent. +### 4.9 `0xEF` as the Account Sentinel -**Memo as a separate event.** Reconciliation references are the one piece of payment metadata that has no home in BEP-20, and the cost of not having it is an entire off-chain matching layer. Widening `Transfer` to carry it would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. +A nonce bump would satisfy EIP-161 equally well, but `0xEF` is the prefix [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) reserves, so no `CREATE`/`CREATE2` deployment can produce it. That makes it an unforgeable marker of a protocol-owned account independently of the reserved-space restriction — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce carries none of this: it is ordinary account state, and would leave `EXTCODESIZE` reporting zero ([§5](#5-backward-compatibility)). -**Deterministic, prefix-recognizable addressing, with the variant encoded in the address.** A fixed marker plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant is it" from the address alone, with no RPC round-trip — the same property BSC's existing precompiles have by virtue of occupying low, fixed addresses. +### 4.10 Decimals Bounds -**A dedicated role for rescaling and disclosure, not folded into `DEFAULT_ADMIN_ROLE`.** `updateMultiplier` changes what every holder sees as their balance across an entire Asset-variant token in one call — a materially larger blast radius than an ordinary metadata edit — so it and `announce` are gated by their own `OPERATOR_ROLE` ([§3.12](#312-asset-variant-extensions)). Keeping it distinct from `MINT_ROLE` matters for a second reason: restating value and issuing new units are the two operations that can dilute existing holders, and an issuer should be able to separate and audit them independently. +Below `6`, a token cannot represent the sub-unit amounts payment and asset use cases routinely need; `18` is the widest precision the surrounding ecosystem handles uniformly. The Stablecoin variant is pinned to `6` rather than given the range because a payment unit should mean the same thing across issuers, and the choice is one fewer thing for an integrator to read. ## 5. Backward Compatibility From fe1948b4a4b24aa340e77145a9ac77ebc3abe78a Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 10:24:49 +0800 Subject: [PATCH 14/41] BEP-702: align the error surface with Base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against Base's shared ABI (crates/common/precompiles/src/common/abi). Eight names already matched; two diverged and nine rules named no error at all, which left an implementer to invent them and integrators unable to switch on them. Renamed to Base's names: - SupplyCapBelowCirculating and SupplyCapTooLarge both become InvalidSupplyCap(currentSupply, proposedCap). Base expresses both conditions with one error, and its parameters already distinguish them. Named where the rule was stated but the error was not: NonPayable, InvalidReceiver, InvalidSpender, InsufficientBalance, InsufficientAllowance, AccessControlUnauthorizedAccount(account, neededRole), ContractPaused(feature), ExpiredSignature(deadline), InvalidSigner(signer, owner), InvalidApprover. Adds the zero-address sender case, which Base rejects explicitly and the boundary table omitted. It is unreachable in practice — the zero address can neither sign nor hold an allowance — but rejecting it is cheaper than relying on that. Not adopted: a guard rejecting a transfer whose recipient is another B20 token address. Tempo has one; Base does not, and the decision here is to match Base. The exposure remains that a transfer into the reserved space has no withdrawal path (3.3). Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index c07a5fd4..77e6d17a 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -133,7 +133,7 @@ Because a stateful precompile can mutate state, the call modes it accepts and it | Call form | Behavior | |---|---| | `CALL` with zero value | Permitted; the normal path | -| `CALL` with non-zero value | Reverts. Every entry point is nonpayable. This MUST be enforced, not merely documented: the EVM performs the value transfer *before* dispatching, so an entry point that ignored a non-zero value would leave it permanently stranded at an address from which this standard provides no path of withdrawal | +| `CALL` with non-zero value | Reverts with `NonPayable`. Every entry point is nonpayable. This MUST be enforced, not merely documented: the EVM performs the value transfer *before* dispatching, so an entry point that ignored a non-zero value would leave it permanently stranded at an address from which this standard provides no path of withdrawal | | `STATICCALL` | Permitted for reads; any state-mutating entry point fails with a write-protection error | | `DELEGATECALL` / `CALLCODE` | Reverts. Under delegation the callee address no longer identifies whose state is being addressed, so the storage owner could not be determined unambiguously | @@ -304,14 +304,15 @@ Because these selectors are the compatibility surface every integrator relies on | Case | Required behavior | |---|---| -| `transfer`/`transferFrom`/`mint` to the zero address | Reverts. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would silently strand supply instead | +| `transfer`/`transferFrom`/`mint` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would silently strand supply instead | +| `transferFrom` where `from` is the zero address | Reverts with `InvalidSender`. Unreachable in practice, since the zero address can neither sign nor be granted an allowance, but rejected explicitly rather than relying on that | | Zero-value `transfer`/`transferFrom` | Succeeds and emits `Transfer`, per ERC-20 | | Zero-value `mint`/`burn` | Succeeds and emits the corresponding `Transfer`, leaving `totalSupply` unchanged | -| `approve` to the zero address | Reverts | +| `approve` to the zero address | Reverts with `InvalidSpender` | | An allowance of `type(uint256).max` | Treated as unlimited and never decremented | | Allowance decrement inside `transferFrom` | Does **not** emit an additional `Approval`; only `approve` and `permit` emit it | | `transfer`/`transferFrom` where `from == to` | Succeeds and emits `Transfer`, leaving the balance unchanged | -| Insufficient balance or allowance, or a supply-cap breach | Reverts with the corresponding error; no partial state change | +| Insufficient balance or allowance, or a supply-cap breach | Reverts with `InsufficientBalance`, `InsufficientAllowance`, or `SupplyCapExceeded`; no partial state change. Each carries the observed and required amounts so a caller need not re-read state to explain the failure | Every failure mode in this interface reverts with a specific error rather than returning `false`, so integrators never have to distinguish a rejected transfer from a successful one by inspecting the return value. @@ -351,7 +352,7 @@ Built-in roles shared by both variants: | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")` | `unpause` | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata` on the Asset variant | -The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). +The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). A call that fails a role gate reverts with `AccessControlUnauthorizedAccount(account, neededRole)`, naming the role that was missing so the failure is actionable without guesswork. The splits between mint and burn, pause and unpause, `BURN_ROLE` and `BURN_BLOCKED_ROLE`, and `OPERATOR_ROLE` and the rest are each deliberate; see [§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling). @@ -457,7 +458,7 @@ interface IB20Pausable { } ``` -Pause state is a bitmask over `Feature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An empty array is rejected with `EmptyFeatureSet`, since it can only be a caller error. `Feature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. +Pause state is a bitmask over `Feature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`, since it can only be a caller error. `Feature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. `burnBlocked` is deliberately outside the mask: `pause(BURN)` stops self-service `burn` but has no effect on a compliance seizure, which is exactly the action an issuer may need to keep exercising while ordinary activity is frozen during an incident. @@ -472,9 +473,9 @@ interface IB20SupplyCap { } ``` -The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `SupplyCapBelowCirculating` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `InvalidSupplyCap(currentSupply, proposedCap)` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. -A cap above `type(uint128).max` is rejected with `SupplyCapTooLarge`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. +A cap above `type(uint128).max` is rejected with the same `InvalidSupplyCap`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. ### 3.11 Permit (EIP-2612) @@ -493,7 +494,7 @@ interface IB20Permit { } ``` -Signature verification for `permit` is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. +`permit` reverts with `ExpiredSignature(deadline)` past the deadline, `InvalidSigner(signer, owner)` when recovery does not yield `owner`, and `InvalidApprover` for a zero `owner`. Signature verification is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. ### 3.12 Asset Variant Extensions From 2f9b568635b7e4931b3ca098f36da9fc8900dea6 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 10:39:35 +0800 Subject: [PATCH 15/41] BEP-702: one IB20 interface, and record what is consensus data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base has six interfaces: IB20 carrying the whole shared surface, plus IB20Asset, IB20Stablecoin, IB20Factory, IPolicyRegistry and IActivationRegistry. Those names already matched. What did not was that this BEP additionally split the shared surface into IB20Roles, IB20Policy, IB20Pausable, IB20SupplyCap and IB20Permit — five interfaces that do not exist in Base. An implementation following the text literally would have produced a different ABI surface from the reference. The five are now presented as labelled slices of IB20, with 3.6 stating that they MUST NOT be split. Also records two things the BEP never mentioned, both of which Base pins with a test: - The interface *name* is consensus data. Calldata shorter than a selector fails to decode and the resulting revert payload carries the interface name, so renaming IB20 after activation changes the payload of reverts older clients already produced. - PausableFeature ordinals are consensus data, since pause bits derive as 1 << ordinal. The BEP said the enum was append-only but not why. Renames Feature to PausableFeature to match, the ordinals being load-bearing, and aligns the Paused/Unpaused signatures with Base — indexed updater first, which the previous signature both reordered and left unindexed. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 93 ++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 77e6d17a..bf38b78c 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -251,6 +251,10 @@ Both variants share identical roles, transfer-policy scopes, pause features, sup ### 3.6 Shared Token Interface +A token exposes exactly **one** interface, named `IB20`, carrying the whole shared surface: the ERC-20 methods below plus roles ([§3.7](#37-roles-and-access-control)), policy slots ([§3.8](#38-transfer-policies)), pause ([§3.9](#39-pause)), supply cap ([§3.10](#310-supply-cap)), and permit ([§3.11](#311-permit-eip-2612)). Those sections show `IB20` in slices for readability; an implementation MUST NOT split them into separate interfaces. Variant extensions are separate — `IB20Asset` and `IB20Stablecoin` — as are `IB20Factory`, `IPolicyRegistry`, and `IActivationRegistry`, giving six interfaces in total. + +The interface **name** is consensus data, not documentation. When calldata is shorter than a selector, the decode failure carries the interface name in its revert payload, so renaming `IB20` after activation would change the payload of a revert that older clients already produced. The same applies to the ordinals of `PausableFeature` ([§3.9](#39-pause)), which derive pause storage bits as `1 << ordinal`. Both are fixed at activation. + Every B20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: ```solidity @@ -321,23 +325,22 @@ Every failure mode in this interface reverts with a specific error rather than r Access to privileged operations follows the same role/member/role-admin model widely used across BSC's own system contracts (`AccessControl`-style): each role is a `bytes32` ID, membership is a `(role, account) -> bool` mapping, and each role has an admin role (default: `DEFAULT_ADMIN_ROLE`) authorized to grant or revoke it. ```solidity -interface IB20Roles { - event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); - event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); - event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); - event LastAdminRenounced(address indexed previousAdmin); - - function hasRole(bytes32 role, address account) external view returns (bool); - function getRoleAdmin(bytes32 role) external view returns (bytes32); - function grantRole(bytes32 role, address account) external; - function revokeRole(bytes32 role, address account) external; - function renounceRole(bytes32 role, address callerConfirmation) external; - function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; - - /// @notice Irreversibly transitions the token to admin-less. Requires the caller to be - /// the sole remaining DEFAULT_ADMIN_ROLE holder. - function renounceLastAdmin() external; -} +// IB20 — roles and access control. Part of the same interface as [§3.6](#36-shared-token-interface). +event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); +event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); +event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); +event LastAdminRenounced(address indexed previousAdmin); + +function hasRole(bytes32 role, address account) external view returns (bool); +function getRoleAdmin(bytes32 role) external view returns (bytes32); +function grantRole(bytes32 role, address account) external; +function revokeRole(bytes32 role, address account) external; +function renounceRole(bytes32 role, address callerConfirmation) external; +function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; + +/// @notice Irreversibly transitions the token to admin-less. Requires the caller to be +/// the sole remaining DEFAULT_ADMIN_ROLE holder. +function renounceLastAdmin() external; ``` Built-in roles shared by both variants: @@ -382,12 +385,11 @@ Every B20 token holds **four independent policy slots**, each referencing a poli | `MINT_RECEIVER_POLICY` | the `to` | `mint`, `mintWithMemo`, `batchMint` — including inside the factory bootstrap window | ```solidity -interface IB20Policy { - event PolicyUpdated(bytes32 indexed scope, uint64 policyId); +// IB20 — transfer-policy slots. Part of the same interface as [§3.6](#36-shared-token-interface). +event PolicyUpdated(bytes32 indexed scope, uint64 policyId); - function policyId(bytes32 scope) external view returns (uint64); - function updatePolicy(bytes32 scope, uint64 policyId) external; // DEFAULT_ADMIN_ROLE-gated -} +function policyId(bytes32 scope) external view returns (uint64); +function updatePolicy(bytes32 scope, uint64 policyId) external; // DEFAULT_ADMIN_ROLE-gated ``` `TRANSFER_EXECUTOR_POLICY` constrains *who may move someone else's balance*, independently of whether either party is authorized ([§4.4](#44-four-policy-scopes-rather-than-one-account-flag)). It applies only to genuine third-party transfers — a `transferFrom` where the spender is the owner is not delegated and is not subject to it. @@ -445,32 +447,30 @@ Two sentinel IDs therefore exist without any `createPolicy` call, and both follo ### 3.9 Pause ```solidity -interface IB20Pausable { - enum Feature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask +// IB20 — pause. Part of the same interface as [§3.6](#36-shared-token-interface). +enum PausableFeature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask - event Paused(Feature[] features, address account); - event Unpaused(Feature[] features, address account); +event Paused(address indexed updater, PausableFeature[] features); +event Unpaused(address indexed updater, PausableFeature[] features); - function pause(Feature[] calldata features) external; // PAUSE_ROLE-gated - function unpause(Feature[] calldata features) external; // UNPAUSE_ROLE-gated - function isPaused(Feature feature) external view returns (bool); - function pausedFeatures() external view returns (Feature[] memory); -} +function pause(PausableFeature[] calldata features) external; // PAUSE_ROLE-gated +function unpause(PausableFeature[] calldata features) external; // UNPAUSE_ROLE-gated +function isPaused(PausableFeature feature) external view returns (bool); +function pausedFeatures() external view returns (PausableFeature[] memory); ``` -Pause state is a bitmask over `Feature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`, since it can only be a caller error. `Feature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. +Pause state is a bitmask over `PausableFeature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`, since it can only be a caller error. `PausableFeature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. `burnBlocked` is deliberately outside the mask: `pause(BURN)` stops self-service `burn` but has no effect on a compliance seizure, which is exactly the action an issuer may need to keep exercising while ordinary activity is frozen during an incident. ### 3.10 Supply Cap ```solidity -interface IB20SupplyCap { - event SupplyCapUpdated(uint256 previousCap, uint256 newCap); +// IB20 — supply cap. Part of the same interface as [§3.6](#36-shared-token-interface). +event SupplyCapUpdated(uint256 previousCap, uint256 newCap); - function supplyCap() external view returns (uint256); - function updateSupplyCap(uint256 newCap) external; // DEFAULT_ADMIN_ROLE-gated -} +function supplyCap() external view returns (uint256); +function updateSupplyCap(uint256 newCap) external; // DEFAULT_ADMIN_ROLE-gated ``` The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `InvalidSupplyCap(currentSupply, proposedCap)` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. @@ -482,16 +482,15 @@ A cap above `type(uint128).max` is rejected with the same `InvalidSupplyCap`. Th B20 tokens implement [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals over an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain of `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"`: ```solidity -interface IB20Permit { - event EIP712DomainChanged(); - - function permit( - address owner, address spender, uint256 value, - uint256 deadline, uint8 v, bytes32 r, bytes32 s - ) external; - function nonces(address owner) external view returns (uint256); - function DOMAIN_SEPARATOR() external view returns (bytes32); -} +// IB20 — permit. Part of the same interface as [§3.6](#36-shared-token-interface). +event EIP712DomainChanged(); + +function permit( + address owner, address spender, uint256 value, + uint256 deadline, uint8 v, bytes32 r, bytes32 s +) external; +function nonces(address owner) external view returns (uint256); +function DOMAIN_SEPARATOR() external view returns (bytes32); ``` `permit` reverts with `ExpiredSignature(deadline)` past the deadline, `InvalidSigner(signer, owner)` when recovery does not yield `owner`, and `InvalidApprover` for a zero `owner`. Signature verification is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. From 172918c43ebc9173aecfa6ce14885b88e70d5975 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 10:52:57 +0800 Subject: [PATCH 16/41] BEP-702: add the role getters and eip712Domain Reconciled against the B20 design document, which resolves the three items Base's current source raised, and not all three the same way: - seize is not adopted. The document has no seize operation, no SEIZE_ROLE, and states four compliance dimensions; only burnBlocked exists. Base grew seizeWithMemo, SEIZE_ROLE and two further policy scopes in its v2 surface, which the document does not follow. This BEP stays at four scopes. - The seven built-in role IDs are exposed as pure getters, as the document's RoleManaged trait does. Without them a calling contract has to hardcode keccak hashes. - eip712Domain() is added, as the document's Permittable trait has it: ERC-5267 domain introspection so a wallet discovers the signing domain rather than reconstructing it. The document also names the pause enum PausableFeature, which the previous commit had already aligned to. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index bf38b78c..efeaf8c0 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -326,6 +326,14 @@ Access to privileged operations follows the same role/member/role-admin model wi ```solidity // IB20 — roles and access control. Part of the same interface as [§3.6](#36-shared-token-interface). +function DEFAULT_ADMIN_ROLE() external pure returns (bytes32); +function MINT_ROLE() external pure returns (bytes32); +function BURN_ROLE() external pure returns (bytes32); +function BURN_BLOCKED_ROLE() external pure returns (bytes32); +function PAUSE_ROLE() external pure returns (bytes32); +function UNPAUSE_ROLE() external pure returns (bytes32); +function METADATA_ROLE() external pure returns (bytes32); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); event RoleAdminChanged(bytes32 indexed role, bytes32 previousAdminRole, bytes32 newAdminRole); @@ -355,7 +363,7 @@ Built-in roles shared by both variants: | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")` | `unpause` | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata` on the Asset variant | -The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). A call that fails a role gate reverts with `AccessControlUnauthorizedAccount(account, neededRole)`, naming the role that was missing so the failure is actionable without guesswork. +The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). Each built-in role ID is also exposed as a pure getter, so a calling contract references `MINT_ROLE()` rather than hardcoding a hash that a later revision could invalidate. A call that fails a role gate reverts with `AccessControlUnauthorizedAccount(account, neededRole)`, naming the role that was missing so the failure is actionable without guesswork. The splits between mint and burn, pause and unpause, `BURN_ROLE` and `BURN_BLOCKED_ROLE`, and `OPERATOR_ROLE` and the rest are each deliberate; see [§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling). @@ -491,6 +499,13 @@ function permit( ) external; function nonces(address owner) external view returns (uint256); function DOMAIN_SEPARATOR() external view returns (bytes32); + +/// @notice ERC-5267 domain introspection, so a wallet can discover the signing +/// domain rather than reconstructing it. `version` is always "1". +function eip712Domain() external view returns ( + bytes1 fields, string memory name, string memory version, + uint256 chainId, address verifyingContract, bytes32 salt, uint256[] memory extensions +); ``` `permit` reverts with `ExpiredSignature(deadline)` past the deadline, `InvalidSigner(signer, owner)` when recovery does not yield `owner`, and `InvalidApprover` for a zero `owner`. Signature verification is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. From 5488943021b08d17574b33329c7603d1772c9235 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:21:11 +0800 Subject: [PATCH 17/41] BEP-702: restructure Motivation after TIP-1034 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts three things from TIP-1034's Motivation, which states its case in five sentences: - Numbered items with bold labels rather than a bullet list, so the section is scannable. - Labels naming what the change delivers rather than what is broken — "Behaviour becomes a guarantee rather than a claim" instead of "Behavioral drift". The problem still has to be stated, but as support for the claim rather than as the heading. - No closing recap. The previous final paragraph restated all four items and added nothing; the framing sentence now carries that weight. Each item is cut to its essential claim, which halves the longest of them. 2.3 KB to 1.6 KB with no argument dropped. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index efeaf8c0..2856106e 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -57,14 +57,12 @@ This BEP introduces a protocol-native fungible token standard for BSC, in two va ## 2. Motivation -BEP-20 defines an ABI convention, not a guarantee about behavior. Any address can claim to implement it while running arbitrary bytecode behind that ABI, and in practice this has caused recurring, costly problems for the ecosystem: +BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. Moving the token itself into the protocol is motivated by four consequences of that gap. -- **Behavioral drift.** Fee-on-transfer logic, hidden mint backdoors, silent blacklists, or an upgradeable proxy whose implementation changes after audit can all sit behind a perfectly normal-looking BEP-20 interface. Wallets, bridges, and DeFi protocols have no way to distinguish an audited, well-behaved token from a malicious one without re-auditing every single contract they integrate. -- **Duplicated compliance engineering.** Stablecoin and tokenized real-world-asset issuers on BSC each independently re-implement the same handful of primitives — role-gated mint/burn, seizure of blocked balances, granular pause, supply caps — with subtly different guarantees and different bug surfaces every time. -- **Real-world assets need redenomination, not just transfers.** Tokenized real-world assets — equities, commodities, bonds — periodically need their per-unit value rescaled across every holder at once, independent of any transfer taking place. This is a distinct need from a payment-oriented stablecoin, and BEP-20 has no standard answer for it today; issuers either bolt on a custom rebasing mechanism or avoid the token model entirely. -- **Execution overhead.** A BEP-20 transfer pays for two SLOADs, two SSTOREs, and full EVM bytecode interpretation and ABI decoding on top, even though the underlying operation (move a balance from A to B) is one of the simplest state transitions in the system. - -A protocol-native token module addresses all of these at once: behavior is fixed by client code that ships through the same review and hard-fork process as consensus changes, gas cost reflects the actual state-access cost of the operation rather than bytecode interpretation, and a standard set of compliance and (for asset-type tokens) redenomination primitives is available to every issuer without a bespoke implementation. +1. **Behaviour becomes a guarantee rather than a claim.** Fee-on-transfer logic, hidden mint backdoors, silent blacklists, and proxies whose implementation changes after audit all sit comfortably behind a normal-looking BEP-20 interface, so a wallet or bridge cannot distinguish an audited token from a malicious one without re-auditing every contract it integrates. Native logic instead ships through the same review and hard-fork process as consensus changes. +2. **Compliance primitives stop being rebuilt per issuer.** Role-gated mint and burn, seizure of blocked balances, granular pause, and supply caps are re-implemented by every stablecoin and tokenized-asset issuer on BSC, each time with different guarantees and a fresh bug surface. One implementation, audited once, replaces all of them. +3. **Real-world assets get redenomination, not only transfers.** Equities, commodities, and bonds periodically need their per-unit value rescaled across every holder at once, independent of any transfer taking place. BEP-20 has no standard answer, so issuers either bolt on a custom rebasing mechanism or avoid the token model entirely. +4. **Cost reflects state access rather than interpretation.** A BEP-20 transfer pays for two SLOADs, two SSTOREs, and full bytecode interpretation and ABI decoding on top — for one of the simplest state transitions in the system. ## 3. Specification From 9c071e2627cf1dc44d1b3531084086f51e2e5067 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:24:22 +0800 Subject: [PATCH 18/41] BEP-702: cut Motivation to one sentence per item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The illustrative enumerations were carrying most of the length without carrying the argument: four named attack types where two make the point, three asset classes where none are needed, a spelled-out gas breakdown. Each item is now a single sentence. The one list kept is the compliance primitives in item 2, which is not decoration — it says what the standard actually bundles and maps onto the specification. 1.6 KB to 1.0 KB. Co-Authored-By: Claude Opus 5 --- BEPs/BEP-702.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 2856106e..f1146ec7 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -59,10 +59,10 @@ This BEP introduces a protocol-native fungible token standard for BSC, in two va BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. Moving the token itself into the protocol is motivated by four consequences of that gap. -1. **Behaviour becomes a guarantee rather than a claim.** Fee-on-transfer logic, hidden mint backdoors, silent blacklists, and proxies whose implementation changes after audit all sit comfortably behind a normal-looking BEP-20 interface, so a wallet or bridge cannot distinguish an audited token from a malicious one without re-auditing every contract it integrates. Native logic instead ships through the same review and hard-fork process as consensus changes. -2. **Compliance primitives stop being rebuilt per issuer.** Role-gated mint and burn, seizure of blocked balances, granular pause, and supply caps are re-implemented by every stablecoin and tokenized-asset issuer on BSC, each time with different guarantees and a fresh bug surface. One implementation, audited once, replaces all of them. -3. **Real-world assets get redenomination, not only transfers.** Equities, commodities, and bonds periodically need their per-unit value rescaled across every holder at once, independent of any transfer taking place. BEP-20 has no standard answer, so issuers either bolt on a custom rebasing mechanism or avoid the token model entirely. -4. **Cost reflects state access rather than interpretation.** A BEP-20 transfer pays for two SLOADs, two SSTOREs, and full bytecode interpretation and ABI decoding on top — for one of the simplest state transitions in the system. +1. **Behaviour becomes a guarantee rather than a claim.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch; native logic ships through the hard-fork process instead. +2. **Compliance primitives stop being rebuilt per issuer.** Role-gated mint and burn, seizure of blocked balances, granular pause and supply caps are re-implemented by every stablecoin and tokenized-asset issuer, each time with a fresh bug surface. +3. **Real-world assets get redenomination, not only transfers.** Rescaling per-unit value across every holder at once, independent of any transfer, has no standard answer in BEP-20. +4. **Cost reflects state access rather than interpretation.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs. ## 3. Specification From 267b453253f7d14e7eab52f16681e171267deec2 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:33:06 +0800 Subject: [PATCH 19/41] BEP-702: replace authorization-layer table with prose --- BEPs/BEP-702.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index f1146ec7..ba9b86ad 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -87,15 +87,7 @@ Two namespaces are in use, and the split follows what each address has to do. `0 B20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b20-address-space)). -Authorization is layered, and each layer answers a different question: - -| Layer | Question | Controlled by | -|---|---|---| -| ActivationRegistry | Is this feature open on this chain at all? | Chain governance | -| RBAC ([§3.7](#37-roles-and-access-control)) | Is this caller allowed to perform this operation? | The token's issuer | -| PolicyRegistry ([§3.8](#38-transfer-policies)) | Is this address allowed to be operated on? | The policy's admin | - -A `createB20` call must clear layer 1; a later `mint` must clear layers 2 and 3. No layer can substitute for another. +Three independent checks guard B20 operations, each with its own controller: the ActivationRegistry decides whether a feature is available on the network at all (chain governance), RBAC decides which callers may invoke an operation ([§3.7](#37-roles-and-access-control), the token's issuer), and the PolicyRegistry decides which addresses may be operated on ([§3.8](#38-transfer-policies), the policy's admin). `createB20` is subject to the first; `mint` to the second and third. Passing one check never implies another. Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. It is the singleton pattern the registries already use — one piece of code serving every caller, differentiated only by address. From a91299e41dba0894647a06488a8b6024f7e64957 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:36:31 +0800 Subject: [PATCH 20/41] BEP-702: drop the stateless-precompile code block from 3.2 --- BEPs/BEP-702.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index ba9b86ad..d003b53f 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -95,17 +95,7 @@ Activation happens in two steps. The hard fork in which this standard ships make ### 3.2 Stateful Precompiled Contracts -BSC's current precompiled-contract interface (`core/vm.PrecompiledContract`) is intentionally stateless: - -```go -type PrecompiledContract interface { - RequiredGas(input []byte) uint64 - Run(input []byte) ([]byte, error) - Name() string -} -``` - -This is sufficient for the existing precompiles (signature recovery, hashing, light-client proof verification), which are pure functions of their input. A B20 token needs to persist balances, allowances, roles, and policy references across calls, and needs to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible interface: +BSC's existing precompiles — signature recovery, hashing, light-client proof verification — are pure functions of their input, and the interface they implement (`core/vm.PrecompiledContract`) reflects that: `RequiredGas(input)` and `Run(input)`, with no access to state, caller, or logs. A B20 token needs to persist balances, allowances, roles, and policy references across calls, and to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible extension: ```go type StatefulPrecompiledContract interface { From c6d2379b280baeaa053cff495aa93953cee3ee7f Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:39:12 +0800 Subject: [PATCH 21/41] BEP-702: tighten 3.2 --- BEPs/BEP-702.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index d003b53f..4683d494 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -104,20 +104,20 @@ type StatefulPrecompiledContract interface { } ``` -The EVM's precompile dispatch is extended to type-assert each resolved precompile against `StatefulPrecompiledContract`; when the assertion succeeds, `RunWithState` is invoked with the running `*EVM` (giving access to `StateDB` for reads/writes and `AddLog` for event emission) instead of the stateless `Run`. Existing precompiles are untouched — they only implement `PrecompiledContract` and continue to dispatch through the existing path. +Dispatch invokes `RunWithState` — with state reads and writes, log emission, and caller/value context — whenever the resolved precompile implements this interface, and the stateless `Run` otherwise, leaving existing precompiles untouched. -Note that `RequiredGas` cannot price a stateful precompile: the cost depends on state the function has not read yet, such as whether a balance slot is cold or whether a write is a creation or a rewrite. For a B20 precompile `RequiredGas` therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on the return value of `RequiredGas` to bound a stateful precompile's cost. +`RequiredGas` cannot price a stateful precompile, because the cost depends on state not yet read: whether a slot is cold, whether a write creates or rewrites. It therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on it to bound a stateful precompile's cost. -Because a stateful precompile can mutate state, the call modes it accepts and its handling of attached value must be defined by this standard rather than left to the implementation. Every entry point of the B20Factory, the PolicyRegistry, and a B20 token observes the following rules: +Because a stateful precompile can mutate state, this standard rather than the implementation defines which call forms it accepts. The rules below hold for every entry point of the B20Factory, the registries, and a B20 token: | Call form | Behavior | |---|---| -| `CALL` with zero value | Permitted; the normal path | -| `CALL` with non-zero value | Reverts with `NonPayable`. Every entry point is nonpayable. This MUST be enforced, not merely documented: the EVM performs the value transfer *before* dispatching, so an entry point that ignored a non-zero value would leave it permanently stranded at an address from which this standard provides no path of withdrawal | -| `STATICCALL` | Permitted for reads; any state-mutating entry point fails with a write-protection error | -| `DELEGATECALL` / `CALLCODE` | Reverts. Under delegation the callee address no longer identifies whose state is being addressed, so the storage owner could not be determined unambiguously | +| `CALL`, zero value | Permitted; the normal path | +| `CALL`, non-zero value | Reverts with `NonPayable`. The EVM transfers value *before* dispatching, so an entry point that merely ignored it would leave it stranded at an address this standard gives no way to withdraw from | +| `STATICCALL` | Permitted for reads; a state-mutating entry point fails with a write-protection error | +| `DELEGATECALL` / `CALLCODE` | Reverts. Under delegation the callee address no longer identifies whose state is addressed, leaving the storage owner ambiguous | -All state mutations, logs, and any attached value transfer are reverted together with the enclosing call frame when an entry point fails. +All state mutations, logs, and attached value are reverted with the enclosing call frame when an entry point fails. ### 3.3 B20 Address Space From 7a6d4b980fc060f212cf89824efe740c7573e9b3 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 11:44:13 +0800 Subject: [PATCH 22/41] BEP-702: fold the call-form table into prose --- BEPs/BEP-702.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 4683d494..690f77c5 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -108,14 +108,7 @@ Dispatch invokes `RunWithState` — with state reads and writes, log emission, a `RequiredGas` cannot price a stateful precompile, because the cost depends on state not yet read: whether a slot is cold, whether a write creates or rewrites. It therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on it to bound a stateful precompile's cost. -Because a stateful precompile can mutate state, this standard rather than the implementation defines which call forms it accepts. The rules below hold for every entry point of the B20Factory, the registries, and a B20 token: - -| Call form | Behavior | -|---|---| -| `CALL`, zero value | Permitted; the normal path | -| `CALL`, non-zero value | Reverts with `NonPayable`. The EVM transfers value *before* dispatching, so an entry point that merely ignored it would leave it stranded at an address this standard gives no way to withdraw from | -| `STATICCALL` | Permitted for reads; a state-mutating entry point fails with a write-protection error | -| `DELEGATECALL` / `CALLCODE` | Reverts. Under delegation the callee address no longer identifies whose state is addressed, leaving the storage owner ambiguous | +Because a stateful precompile can mutate state, this standard rather than the implementation defines which call forms it accepts, for every entry point of the B20Factory, the registries, and a B20 token. `CALL` is the normal path, but a non-zero value MUST revert with `NonPayable`: the EVM transfers value *before* dispatching, so an entry point that merely ignored it would leave it stranded at an address this standard gives no way to withdraw from. Under `STATICCALL`, reads are permitted and a state-mutating entry point MUST fail with a write-protection error — a precompile writing through StateDB bypasses the interpreter's own write protection, so it has to enforce this itself. `DELEGATECALL` and `CALLCODE` MUST revert, because under delegation the callee address no longer identifies whose state is addressed, leaving the storage owner ambiguous. All state mutations, logs, and attached value are reverted with the enclosing call frame when an entry point fails. From e1bd0fdb1231020a57862e09e758d47ce7cf3dd4 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:05:55 +0800 Subject: [PATCH 23/41] BEP-702: say isB20 instead of overloading "marker" --- BEPs/BEP-702.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 690f77c5..acb320df 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -131,14 +131,14 @@ Three helpers follow from the layout alone, none requiring a storage read: The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a B20 token address. -Lying inside the reserved space is not the same as existing. An address may match the marker while no `createB20` call has ever produced it, and the two cases must be distinguished: +Lying inside the reserved space is not the same as existing. An address may satisfy `isB20` while no `createB20` call has ever produced it, and the two cases must be distinguished: | Target address | Behavior | |---|---| -| Marker matches, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | -| Marker matches, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | -| Marker matches, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | -| Marker does not match | Ordinary account, unchanged | +| `isB20` true, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | +| `isB20` true, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | +| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | +| `isB20` false | Ordinary account, unchanged | Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. @@ -148,7 +148,7 @@ Because the `CREATE`/`CREATE2` restriction only takes effect at the activating h That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without them yields no matches because it can recover no addresses at all — indistinguishable from a clean result. An implementation MUST therefore confirm the scanned address set was non-empty before treating the outcome as a pass, or reconstruct the set from chain history instead. -The three singletons admit a direct check and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and Chapel at 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte marker makes occupation implausible rather than merely unlikely: accidental collision is on the order of 10^-15 against BSC's account count, and grinding into the range costs 2^80 hashes. +The three singletons admit a direct check and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and Chapel at 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte reserved prefix makes occupation implausible rather than merely unlikely: accidental collision is on the order of 10^-15 against BSC's account count, and grinding into the range costs 2^80 hashes. ### 3.4 B20Factory @@ -691,7 +691,7 @@ Reconciliation references have no home in BEP-20, and the cost of not having the ### 4.7 Prefix-Recognizable Addressing -A fixed marker plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant" from the address alone, with no RPC round-trip — the property BSC's existing precompiles have by occupying low fixed addresses. It is also what makes routing possible without a state read on every call ([§3.3](#33-b20-address-space)). +A fixed prefix plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant" from the address alone, with no RPC round-trip — the property BSC's existing precompiles have by occupying low fixed addresses. It is also what makes routing possible without a state read on every call ([§3.3](#33-b20-address-space)). ### 4.8 No Gas Schedule @@ -734,7 +734,7 @@ The activation admin ([§3.15](#315-feature-activation)) can halt new token and A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space before activation. The current split favours the marker, on the grounds that grinding is only exploitable in the window before the fork while a fingerprint collision stays exploitable forever; an implementation that revisits it SHOULD keep the marker at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space before activation. The current split favours the prefix, on the grounds that grinding is only exploitable in the window before the fork while a fingerprint collision stays exploitable forever; an implementation that revisits it SHOULD keep the reserved prefix at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. ### 6.4 Privileged Keys From f6c30a5f5e7c5ac056fe676c97cc34eb0e9bb94e Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:10:11 +0800 Subject: [PATCH 24/41] BEP-702: state that the sentinel is not executed on the unknown-variant path --- BEPs/BEP-702.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index acb320df..87d06968 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -137,7 +137,7 @@ Lying inside the reserved space is not the same as existing. An address may sati |---|---| | `isB20` true, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | | `isB20` true, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | -| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Treated as an ordinary account with no code, so that a token created under a future variant is inert rather than misrouted on an older client | +| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Behaves as a call to a codeless account, so that a token created under a future variant is inert rather than misrouted. The account does carry the sentinel ([§3.16](#316-account-sentinel)); it MUST NOT be executed, because `0xEF` is not a valid opcode and executing it would consume all forwarded gas instead | | `isB20` false | Ordinary account, unchanged | Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. @@ -649,7 +649,7 @@ Every account that holds B20 state — each token, and each singleton registry ( 0xEF ``` -It is never executed. Call dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. +It is never executed: a call to a B20 address either resolves to native code before any bytecode would run ([§3.1](#31-architecture-overview)), or behaves as a call to a codeless account ([§3.3](#33-b20-address-space)). The byte's only job is to exist. **Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an *empty* account — zero nonce, zero balance, no code — at the end of the block it was touched in, **and deletes its storage with it**; emptiness does not consider storage. A B20 token holds every balance, allowance, role, and policy binding in account storage while having no code, no nonce, and usually no balance, so without a sentinel it is exactly such an account, touched on every transfer. The consequence is not a stale flag but total loss: the first clearing pass after a transfer erases the token and every holder's balance. Because the write path only mutates the storage trie, no fix in the storage layer reaches this — the account itself must be non-empty. From 466dfac27cc63d5931dc017e5928e1ead9d73789 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:14:19 +0800 Subject: [PATCH 25/41] BEP-702: correct why the unknown-variant path exists --- BEPs/BEP-702.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 87d06968..28332cff 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -125,7 +125,7 @@ A B20 token address is 20 bytes: Three helpers follow from the layout alone, none requiring a storage read: -- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a future variant this standard does not yet define is still recognized as a B20 address by existing tooling. +- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a token of a future variant is still recognized as a B20 address by code written before that variant existed. It is a syntactic check, not an existence check: it answers true for any address in the reserved space, including one no `createB20` call has produced. - `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). - `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. @@ -137,9 +137,11 @@ Lying inside the reserved space is not the same as existing. An address may sati |---|---| | `isB20` true, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | | `isB20` true, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | -| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Behaves as a call to a codeless account, so that a token created under a future variant is inert rather than misrouted. The account does carry the sentinel ([§3.16](#316-account-sentinel)); it MUST NOT be executed, because `0xEF` is not a valid opcode and executing it would consume all forwarded gas instead | +| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Behaves as a call to a codeless account: inert rather than misrouted. The sentinel ([§3.16](#316-account-sentinel)) MUST NOT be executed — `0xEF` is not a valid opcode, and executing it would consume all forwarded gas | | `isB20` false | Ordinary account, unchanged | +The unrecognized-variant row is unreachable on a chain a node is following: a variant only becomes creatable at the fork that defines it, so no token of that variant exists in any earlier block, and a node lacking the fork has already diverged. It is specified so that dispatch is total — an undefined variant has one defined outcome rather than an implementation-chosen one. + Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. From db4d185b0d49f78a4e5aac0bc51f79599aa678ab Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:21:08 +0800 Subject: [PATCH 26/41] BEP-702: spell out that value still reaches an uninitialized reserved address --- BEPs/BEP-702.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 28332cff..b43244fd 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -136,7 +136,7 @@ Lying inside the reserved space is not the same as existing. An address may sati | Target address | Behavior | |---|---| | `isB20` true, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | -| `isB20` true, `isB20Initialized` false | Treated as an ordinary account with no code: a call succeeds, transfers no balance beyond the attached value, and returns empty. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | +| `isB20` true, `isB20Initialized` false | Behaves as a call to a codeless account: it succeeds and returns empty, no B20 logic runs and no B20 state is touched, and an attached value is credited to the account exactly as in any ordinary transfer. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | | `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Behaves as a call to a codeless account: inert rather than misrouted. The sentinel ([§3.16](#316-account-sentinel)) MUST NOT be executed — `0xEF` is not a valid opcode, and executing it would consume all forwarded gas | | `isB20` false | Ordinary account, unchanged | From 6edabe3dad11187b845c8f5712d4d151c2d5b999 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:42:09 +0800 Subject: [PATCH 27/41] BEP-702: align reserved-address dispatch with Base --- BEPs/BEP-702.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index b43244fd..d66733df 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -108,7 +108,7 @@ Dispatch invokes `RunWithState` — with state reads and writes, log emission, a `RequiredGas` cannot price a stateful precompile, because the cost depends on state not yet read: whether a slot is cold, whether a write creates or rewrites. It therefore reports no charge, and all metering happens inside `RunWithState` as the work is performed ([§3.14](#314-gas-accounting)). An implementation MUST NOT rely on it to bound a stateful precompile's cost. -Because a stateful precompile can mutate state, this standard rather than the implementation defines which call forms it accepts, for every entry point of the B20Factory, the registries, and a B20 token. `CALL` is the normal path, but a non-zero value MUST revert with `NonPayable`: the EVM transfers value *before* dispatching, so an entry point that merely ignored it would leave it stranded at an address this standard gives no way to withdraw from. Under `STATICCALL`, reads are permitted and a state-mutating entry point MUST fail with a write-protection error — a precompile writing through StateDB bypasses the interpreter's own write protection, so it has to enforce this itself. `DELEGATECALL` and `CALLCODE` MUST revert, because under delegation the callee address no longer identifies whose state is addressed, leaving the storage owner ambiguous. +Because a stateful precompile can mutate state, this standard rather than the implementation defines which call forms it accepts, for every entry point of the B20Factory, the registries, and any address dispatch routes to a B20 token handler ([§3.3](#33-b20-address-space)). `CALL` is the normal path, but a non-zero value MUST revert with `NonPayable`: the EVM transfers value *before* dispatching, so an entry point that merely ignored it would leave it stranded at an address this standard gives no way to withdraw from. Under `STATICCALL`, reads are permitted and a state-mutating entry point MUST fail with a write-protection error — a precompile writing through StateDB bypasses the interpreter's own write protection, so it has to enforce this itself. `DELEGATECALL` and `CALLCODE` MUST revert, because under delegation the callee address no longer identifies whose state is addressed, leaving the storage owner ambiguous. All state mutations, logs, and attached value are reverted with the enclosing call frame when an entry point fails. @@ -133,18 +133,22 @@ The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a Lying inside the reserved space is not the same as existing. An address may satisfy `isB20` while no `createB20` call has ever produced it, and the two cases must be distinguished: +Dispatch keys on the address alone — `isB20` and a variant the active fork recognizes. Existence is *not* part of the routing decision; it is checked inside the handler, so a call to a recognized-variant address that holds no token still reaches native code and is rejected there: + | Target address | Behavior | |---|---| -| `isB20` true, `isB20Initialized` true, variant recognized | Routed to the B20 token handler bound to that address | -| `isB20` true, `isB20Initialized` false | Behaves as a call to a codeless account: it succeeds and returns empty, no B20 logic runs and no B20 state is touched, and an attached value is credited to the account exactly as in any ordinary transfer. Resolving this case reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | -| `isB20` true, `isB20Initialized` true, variant not recognized by the active fork | Behaves as a call to a codeless account: inert rather than misrouted. The sentinel ([§3.16](#316-account-sentinel)) MUST NOT be executed — `0xEF` is not a valid opcode, and executing it would consume all forwarded gas | +| `isB20` true, variant recognized, `isB20Initialized` true | Routed to the B20 token handler bound to that address | +| `isB20` true, variant recognized, `isB20Initialized` false | Also routed. The handler MUST reject a non-zero value with `NonPayable` before anything else, then MUST revert with empty returndata because no token exists. The existence check reads the target's code hash, which MUST be charged as an account access ([§3.14](#314-gas-accounting)) | +| `isB20` true, variant not recognized by the active fork | Not routed; the ordinary account path applies, so a token of a future variant is inert rather than misrouted | | `isB20` false | Ordinary account, unchanged | The unrecognized-variant row is unreachable on a chain a node is following: a variant only becomes creatable at the fork that defines it, so no token of that variant exists in any earlier block, and a node lacking the fork has already diverged. It is specified so that dispatch is total — an undefined variant has one defined outcome rather than an implementation-chosen one. -Existence is determined by the presence of the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr` carries a non-empty code hash. Because the sentinel is written by `createB20` and cannot be produced by any other means inside the reserved space, code presence and token existence are the same fact. +Because a value-bearing call is refused across the whole recognized-variant reserved space, whether or not a token exists there, an ordinary transfer cannot fund such an address. `SELFDESTRUCT` still can: it credits the beneficiary with no call and no code execution, so it cannot be refused. A reserved address may therefore hold BNB it never accepted. This standard gives that balance no meaning and no way out. + +Existence is determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than merely testing for non-empty code keeps the check sound even if the reserved-space restriction were defectively implemented. -Existence MUST NOT be inferred from nonce or balance. Any address — reserved or not — can be sent BNB by an ordinary transfer, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. This standard provides no path to withdraw BNB sent to a reserved address that holds no token. +Existence MUST NOT be inferred from nonce or balance. A reserved address can be funded without its consent as described above, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy instead, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying the reserved space at that point would become unreachable — calls to it would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. @@ -651,7 +655,7 @@ Every account that holds B20 state — each token, and each singleton registry ( 0xEF ``` -It is never executed: a call to a B20 address either resolves to native code before any bytecode would run ([§3.1](#31-architecture-overview)), or behaves as a call to a codeless account ([§3.3](#33-b20-address-space)). The byte's only job is to exist. +It is never executed. Dispatch resolves the address to native code before any bytecode would run ([§3.1](#31-architecture-overview)), so the byte's only job is to exist. **Why it is required.** [EIP-161](https://eips.ethereum.org/EIPS/eip-161) deletes an *empty* account — zero nonce, zero balance, no code — at the end of the block it was touched in, **and deletes its storage with it**; emptiness does not consider storage. A B20 token holds every balance, allowance, role, and policy binding in account storage while having no code, no nonce, and usually no balance, so without a sentinel it is exactly such an account, touched on every transfer. The consequence is not a stale flag but total loss: the first clearing pass after a transfer erases the token and every holder's balance. Because the write path only mutates the storage trie, no fix in the storage layer reaches this — the account itself must be non-empty. From 2665199e625c4318f452401958b22876d683d81a Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 15:55:32 +0800 Subject: [PATCH 28/41] BEP-702: condense the reserved-space discussion in 3.3 --- BEPs/BEP-702.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index d66733df..223b1394 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -142,17 +142,15 @@ Dispatch keys on the address alone — `isB20` and a variant the active fork rec | `isB20` true, variant not recognized by the active fork | Not routed; the ordinary account path applies, so a token of a future variant is inert rather than misrouted | | `isB20` false | Ordinary account, unchanged | -The unrecognized-variant row is unreachable on a chain a node is following: a variant only becomes creatable at the fork that defines it, so no token of that variant exists in any earlier block, and a node lacking the fork has already diverged. It is specified so that dispatch is total — an undefined variant has one defined outcome rather than an implementation-chosen one. +The unrecognized-variant row is unreachable on a chain a node is following — a variant only becomes creatable at the fork that defines it — and is specified so that dispatch is total. -Because a value-bearing call is refused across the whole recognized-variant reserved space, whether or not a token exists there, an ordinary transfer cannot fund such an address. `SELFDESTRUCT` still can: it credits the beneficiary with no call and no code execution, so it cannot be refused. A reserved address may therefore hold BNB it never accepted. This standard gives that balance no meaning and no way out. +Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. This standard gives such a balance no meaning and no way out. -Existence is determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than merely testing for non-empty code keeps the check sound even if the reserved-space restriction were defectively implemented. +Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than testing for non-empty code keeps the check sound even if the reserved-space restriction were defectively implemented. -Existence MUST NOT be inferred from nonce or balance. A reserved address can be funded without its consent as described above, so a non-zero balance carries no information about whether a token was created; `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy instead, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying a recognized-variant reserved address at that point would become unreachable: calls would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying the reserved space at that point would become unreachable — calls to it would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. - -That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without them yields no matches because it can recover no addresses at all — indistinguishable from a clean result. An implementation MUST therefore confirm the scanned address set was non-empty before treating the outcome as a pass, or reconstruct the set from chain history instead. +That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without it recovers no addresses at all, which is indistinguishable from a clean result — an implementation MUST therefore confirm the scanned set was non-empty before treating the outcome as a pass, or reconstruct it from chain history. The three singletons admit a direct check and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and Chapel at 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte reserved prefix makes occupation implausible rather than merely unlikely: accidental collision is on the order of 10^-15 against BSC's account count, and grinding into the range costs 2^80 hashes. From 2cb44b9e985484961f8d5dd675817a79c59144ca Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 16:00:23 +0800 Subject: [PATCH 29/41] BEP-702: make the createB20 occupancy check code-only, as in 3.4 --- BEPs/BEP-702.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 223b1394..b7d0db60 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -148,7 +148,7 @@ Because a value-bearing call is refused across the recognized-variant reserved s Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than testing for non-empty code keeps the check sound even if the reserved-space restriction were defectively implemented. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying a recognized-variant reserved address at that point would become unreachable: calls would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address carrying a non-zero nonce, non-empty code, or non-empty storage. +Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying a recognized-variant reserved address at that point would become unreachable: calls would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without it recovers no addresses at all, which is indistinguishable from a clean result — an implementation MUST therefore confirm the scanned set was non-empty before treating the outcome as a pass, or reconstruct it from chain history. From f2eacd91463fac95bbdab42ea272be5722eeaac4 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 16:21:29 +0800 Subject: [PATCH 30/41] BEP-702: drop the CREATE/CREATE2 reserved-space restriction --- BEPs/BEP-702.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index b7d0db60..ff9cc123 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -129,7 +129,7 @@ Three helpers follow from the layout alone, none requiring a storage read: - `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). - `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. -The protocol MUST reject any `CREATE`/`CREATE2` deployment that would produce a contract address within this reserved space (bytes `[0:10]` matching the fixed pattern above), so that no ordinary contract deployment can ever collide with, or be mistaken for, a B20 token address. +No restriction is placed on `CREATE`/`CREATE2` output addresses, and none is needed. Landing inside the reserved space at all means grinding the ten-byte prefix — on the order of 2^80 hashes — and it buys nothing: no code an external mechanism can install, whether deployed or installed as an [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) delegation, can satisfy the existence check ([§3.16](#316-account-sentinel)), so it cannot pass as a token. Meanwhile calls to that address route to the token handler and the squatter's own code never runs. The only account harmed is the squatter's own. Lying inside the reserved space is not the same as existing. An address may satisfy `isB20` while no `createB20` call has ever produced it, and the two cases must be distinguished: @@ -146,9 +146,9 @@ The unrecognized-variant row is unreachable on a chain a node is following — a Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. This standard gives such a balance no meaning and no way out. -Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than testing for non-empty code keeps the check sound even if the reserved-space restriction were defectively implemented. +Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than testing for non-empty code is what makes the reserved space safe without a deployment restriction: [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) forbids deploying code that begins with `0xEF`, so nothing an attacker can put at an address hashes to the sentinel, while `createB20` writes it directly. -Because the `CREATE`/`CREATE2` restriction only takes effect at the activating hard fork, an account already occupying a recognized-variant reserved address at that point would become unreachable: calls would route to the token handler and its own code would never run. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. +An account occupying a recognized-variant reserved address becomes unreachable at activation: calls route to the token handler and its own code never runs. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without it recovers no addresses at all, which is indistinguishable from a clean result — an implementation MUST therefore confirm the scanned set was non-empty before treating the outcome as a pass, or reconstruct it from chain history. @@ -703,7 +703,7 @@ A flat price per entry point is wrong in both directions for entry points whose ### 4.9 `0xEF` as the Account Sentinel -A nonce bump would satisfy EIP-161 equally well, but `0xEF` is the prefix [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) reserves, so no `CREATE`/`CREATE2` deployment can produce it. That makes it an unforgeable marker of a protocol-owned account independently of the reserved-space restriction — if that restriction were ever weakened or defectively implemented, the sentinel would still be impossible to counterfeit. A nonce carries none of this: it is ordinary account state, and would leave `EXTCODESIZE` reporting zero ([§5](#5-backward-compatibility)). +A nonce bump would satisfy EIP-161 equally well, but `0xEF` is the prefix [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) reserves, so no `CREATE`/`CREATE2` deployment can produce it. That is what lets this standard leave `CREATE`/`CREATE2` output addresses unrestricted ([§3.3](#33-b20-address-space)): the marker of a protocol-owned account is unforgeable by construction, so squatting in the reserved space cannot produce anything that passes as a token. Guarding the deployment path would protect the same property by a weaker means — it would depend on every implementation applying the guard correctly, where the marker depends on nothing. A nonce carries none of this: it is ordinary account state, and would leave `EXTCODESIZE` reporting zero ([§5](#5-backward-compatibility)). ### 4.10 Decimals Bounds @@ -711,7 +711,7 @@ Below `6`, a token cannot represent the sub-unit amounts payment and asset use c ## 5. Backward Compatibility -This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, a new stateful-precompile dispatch path, and a new restriction on `CREATE`/`CREATE2` output addresses (see [§3.3](#33-b20-address-space)). It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, and a new stateful-precompile dispatch path. It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. B20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B20 tokens without changes. @@ -738,7 +738,7 @@ The activation admin ([§3.15](#315-feature-activation)) can halt new token and A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space before activation. The current split favours the prefix, on the grounds that grinding is only exploitable in the window before the fork while a fingerprint collision stays exploitable forever; an implementation that revisits it SHOULD keep the reserved prefix at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space. The current split favours the prefix, on the grounds that a fingerprint collision lets an attacker seize an address an issuer has already published, whereas grinding into the space only bricks the squatter's own account ([§3.3](#33-b20-address-space)); an implementation that revisits it SHOULD keep the reserved prefix at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. ### 6.4 Privileged Keys From f35702778326cf7d1b72fed8adfb901c1ef93f99 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 17:15:39 +0800 Subject: [PATCH 31/41] BEP-702: condense 3.4 --- BEPs/BEP-702.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index ff9cc123..b8e886cc 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -201,21 +201,21 @@ Creation parameters are variant-specific: | `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | | `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | -`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b20-address-space)); validate `params`; reject a derived address that already carries a non-empty code hash (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. +`createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b20-address-space)); validate `params`; reject a derived address that already carries code (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. -The sentinel MUST be written **before** the initial storage, not after. Writing storage to an account that is still EIP-161-empty and only rescuing it later leaves a window in which an intervening state-clearing pass would discard it. The factory then has no further role — it holds no privileges over the token, and every later interaction goes directly to the token's own address. +The sentinel MUST be written **before** the initial storage: storage written to an account that is still EIP-161-empty can be discarded by an intervening clearing pass, and rescuing the account afterwards is too late ([§3.16](#316-account-sentinel)). -**The bootstrap window.** `initCalls` are executed by the factory against the new token as *privileged* calls, in order, atomically with creation. This exists because a token is born with no role holders at all: without a privileged window, granting the first `MINT_ROLE` would already require an authority that does not yet exist. Inside the window the factory skips the role gate and the transfer-side policy gates. Three checks are **never** skipped, on any path: +**The bootstrap window.** `initCalls` run as *privileged* calls against the new token, in order, atomically with creation — a token is born with no role holders, so the first `MINT_ROLE` grant would otherwise need an authority that does not yet exist. The window skips the role gate and the transfer-side policy gates, and nothing more. Three checks are **never** skipped, on any path: -- `MINT_RECEIVER` policy ([§3.8](#38-transfer-policies)) — the bootstrap may not mint to an address the token's own compliance configuration forbids. -- Pause state ([§3.9](#39-pause)) and the supply cap ([§3.10](#310-supply-cap)). -- The admin anti-resurrection guard ([§3.7](#37-roles-and-access-control)) — a token that has renounced its last admin can never regain one, and routing the grant through the bootstrap path does not change that. +- the `MINT_RECEIVER` policy ([§3.8](#38-transfer-policies)); +- pause state ([§3.9](#39-pause)) and the supply cap ([§3.10](#310-supply-cap)); +- the admin anti-resurrection guard ([§3.7](#37-roles-and-access-control)) — the bootstrap path is not a way back for a token that has renounced its last admin. -Any failing `initCall` reverts the entire creation. A call shorter than four bytes is rejected with `InternalCallMalformed`. +Any failing `initCall` reverts the entire creation, and a call shorter than four bytes fails with `InternalCallMalformed`. Once creation returns, the factory holds no privilege over the token and every later interaction addresses the token directly. -**Admin-less tokens.** `initialAdmin` MAY be the zero address. Combined with `initCalls`, this is how an issuer creates a permanently immutable token: roles, policies, supply cap, and initial distribution are all configured inside the bootstrap window, after which no admin exists and no role assignment can ever change ([§3.7](#37-roles-and-access-control)). An issuer that wants an admin during setup and immutability afterwards instead passes a real `initialAdmin` and calls `renounceLastAdmin()` when finished. Both routes reach the same terminal state; they differ only in whether an admin key ever existed. +**Admin-less tokens.** `initialAdmin` MAY be the zero address, which combined with `initCalls` yields a permanently immutable token: everything is configured inside the window, and afterwards no role assignment can ever change ([§3.7](#37-roles-and-access-control)). Passing a real `initialAdmin` and calling `renounceLastAdmin()` once setup is done reaches the same terminal state. -`decimals` is fixed at creation and never changes ([§4.10](#410-decimals-bounds)). Asset-variant issuers SHOULD prefer `18` where the multiplier will be used: every multiplier-derived read floors ([§3.12](#312-asset-variant-extensions)), so at low precision a large reverse rescaling can leave rounding dust that is economically visible. +`decimals` is fixed at creation and never changes ([§4.10](#410-decimals-bounds)). Asset-variant issuers SHOULD prefer `18` where the multiplier will be used: multiplier-derived reads floor ([§3.12](#312-asset-variant-extensions)), so at low precision a large reverse rescaling can leave visible rounding dust. ### 3.5 Variants From e7bcf0db4941d503a372e13bd0a4e619d81c6a12 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 17:20:31 +0800 Subject: [PATCH 32/41] BEP-702: condense 3.6 --- BEPs/BEP-702.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index b8e886cc..c01a204c 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -230,9 +230,9 @@ Both variants share identical roles, transfer-policy scopes, pause features, sup A token exposes exactly **one** interface, named `IB20`, carrying the whole shared surface: the ERC-20 methods below plus roles ([§3.7](#37-roles-and-access-control)), policy slots ([§3.8](#38-transfer-policies)), pause ([§3.9](#39-pause)), supply cap ([§3.10](#310-supply-cap)), and permit ([§3.11](#311-permit-eip-2612)). Those sections show `IB20` in slices for readability; an implementation MUST NOT split them into separate interfaces. Variant extensions are separate — `IB20Asset` and `IB20Stablecoin` — as are `IB20Factory`, `IPolicyRegistry`, and `IActivationRegistry`, giving six interfaces in total. -The interface **name** is consensus data, not documentation. When calldata is shorter than a selector, the decode failure carries the interface name in its revert payload, so renaming `IB20` after activation would change the payload of a revert that older clients already produced. The same applies to the ordinals of `PausableFeature` ([§3.9](#39-pause)), which derive pause storage bits as `1 << ordinal`. Both are fixed at activation. +The interface **name** is consensus data, not documentation: when calldata is shorter than a selector, the decode failure carries the interface name in its revert payload, so renaming `IB20` after activation would change a revert payload older clients already produced. The same applies to the ordinals of `PausableFeature` ([§3.9](#39-pause)), which derive pause storage bits as `1 << ordinal`. Both are fixed at activation. -Every B20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors, so existing wallets, explorers, routers, and bridges interoperate without modification: +Every B20 token, of either variant, exposes the full BEP-20/ERC-20 method and event surface with identical selectors: ```solidity interface IB20 { @@ -273,11 +273,11 @@ interface IB20 { ``` - `mint` increases `to`'s balance and `totalSupply` together, failing with `SupplyCapExceeded` if the result would exceed the configured cap. -- `burn` reduces only the caller's own balance; it has no path to anyone else's funds. It is nonetheless role-gated, so an issuer controls who may retire supply. -- `burnBlocked` is scoped entirely to accounts already denied under `TRANSFER_SENDER_POLICY` ([§3.8](#38-transfer-policies)). Against an account in good standing it fails with `AccountNotBlocked`. This makes the enforcement sequence structural rather than procedural: an address MUST be frozen by policy before its balance can be swept, so a seizure can never be the first action taken against it. It emits `BurnedBlocked` in addition to the `Transfer` to the zero address, giving indexers a distinguishable enforcement record. +- `burn` reduces only the caller's own balance, with no path to anyone else's funds. It is role-gated nonetheless, so an issuer controls who may retire supply. +- `burnBlocked` is scoped entirely to accounts already denied under `TRANSFER_SENDER_POLICY` ([§3.8](#38-transfer-policies)). Against an account in good standing it fails with `AccountNotBlocked`, so freezing is structurally prior to seizure rather than merely procedurally so. It emits `BurnedBlocked` alongside the `Transfer` to the zero address, giving indexers a distinguishable enforcement record. - `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. -**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. Zero is permitted. The memo is a reconciliation reference — invoice number, customer ID, cost centre, purchase order — and both its fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. Zero is permitted. The memo is a reconciliation reference, and both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. @@ -285,17 +285,17 @@ Because these selectors are the compatibility surface every integrator relies on | Case | Required behavior | |---|---| -| `transfer`/`transferFrom`/`mint` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would silently strand supply instead | -| `transferFrom` where `from` is the zero address | Reverts with `InvalidSender`. Unreachable in practice, since the zero address can neither sign nor be granted an allowance, but rejected explicitly rather than relying on that | +| `transfer`/`transferFrom`/`mint` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would strand supply silently | +| `transferFrom` where `from` is the zero address | Reverts with `InvalidSender` — unreachable in practice, but rejected explicitly rather than by assumption | | Zero-value `transfer`/`transferFrom` | Succeeds and emits `Transfer`, per ERC-20 | | Zero-value `mint`/`burn` | Succeeds and emits the corresponding `Transfer`, leaving `totalSupply` unchanged | | `approve` to the zero address | Reverts with `InvalidSpender` | | An allowance of `type(uint256).max` | Treated as unlimited and never decremented | | Allowance decrement inside `transferFrom` | Does **not** emit an additional `Approval`; only `approve` and `permit` emit it | | `transfer`/`transferFrom` where `from == to` | Succeeds and emits `Transfer`, leaving the balance unchanged | -| Insufficient balance or allowance, or a supply-cap breach | Reverts with `InsufficientBalance`, `InsufficientAllowance`, or `SupplyCapExceeded`; no partial state change. Each carries the observed and required amounts so a caller need not re-read state to explain the failure | +| Insufficient balance or allowance, or a supply-cap breach | Reverts with `InsufficientBalance`, `InsufficientAllowance`, or `SupplyCapExceeded`, with no partial state change. Each carries the observed and required amounts | -Every failure mode in this interface reverts with a specific error rather than returning `false`, so integrators never have to distinguish a rejected transfer from a successful one by inspecting the return value. +Every failure mode in this interface reverts with a specific error rather than returning `false`, so a rejected transfer can never be mistaken for a successful one. ### 3.7 Roles and Access Control From e5213ae9896055ec1753feed8b8f49bf0be9f225 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 17:28:10 +0800 Subject: [PATCH 33/41] BEP-702: fix duplicated paragraph and stale bnb20 feature ids --- BEPs/BEP-702.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index c01a204c..3d6437eb 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -356,8 +356,6 @@ Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` val The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-b20factory)). -Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any token operation; it is purely an on-chain membership record that the issuer or third-party contracts can query — for example as an intermediate tier in a `setRoleAdmin` hierarchy. - ### 3.8 Transfer Policies Every B20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: @@ -625,8 +623,8 @@ A feature is identified by a `bytes32` value, defined as the keccak-256 hash of | Feature | Identifier | Gates | |---|---|---| -| Asset variant | `keccak256("bsc.bnb20_asset")` | `createB20` with `variant == ASSET` | -| Stablecoin variant | `keccak256("bsc.bnb20_stablecoin")` | `createB20` with `variant == STABLECOIN` | +| Asset variant | `keccak256("bsc.b20_asset")` | `createB20` with `variant == ASSET` | +| Stablecoin variant | `keccak256("bsc.b20_stablecoin")` | `createB20` with `variant == STABLECOIN` | | Policy registry | `keccak256("bsc.policy_registry")` | `createPolicy`, `createPolicyWithAccounts`, `updateAllowlist`, `updateBlocklist`, and the admin-lifecycle methods | Every feature starts deactivated, so the fork can ship without opening anything, and each network activates on its own schedule. @@ -711,7 +709,7 @@ Below `6`, a token cannot represent the sub-unit amounts payment and asset use c ## 5. Backward Compatibility -This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses, and a new stateful-precompile dispatch path. It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. +This is a consensus-breaking change and requires a hard fork: it introduces new precompiled-contract addresses and a new stateful-precompile dispatch path. It does not modify any existing precompile, any existing system contract, or the BEP-20 standard itself; existing BEP-20 tokens are entirely unaffected and continue to operate exactly as before. B20 token addresses expose the standard BEP-20 selectors and events, so wallets, indexers, DEX routers, and bridges built against BEP-20 interoperate with B20 tokens without changes. From 9c05c49130fa0b9ea890c2db8181797aeb5fea95 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Tue, 4 Aug 2026 17:32:35 +0800 Subject: [PATCH 34/41] BEP-702: replace burnBlocked with seizeWithMemo, and correct what deactivation reaches --- BEPs/BEP-702.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 3d6437eb..ce112dd4 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -33,7 +33,7 @@ - [4.1 Three Singletons](#41-three-singletons) - [4.2 A Governance Switch on Top of the Fork](#42-a-governance-switch-on-top-of-the-fork) - [4.3 A Privileged Bootstrap Window](#43-a-privileged-bootstrap-window) - - [4.4 Four Policy Scopes Rather Than One Account Flag](#44-four-policy-scopes-rather-than-one-account-flag) + - [4.4 Six Policy Scopes Rather Than One Account Flag](#44-six-policy-scopes-rather-than-one-account-flag) - [4.5 Separate Roles for Mint, Burn, Seizure, and Rescaling](#45-separate-roles-for-mint-burn-seizure-and-rescaling) - [4.6 Memo as a Separate Event](#46-memo-as-a-separate-event) - [4.7 Prefix-Recognizable Addressing](#47-prefix-recognizable-addressing) @@ -242,7 +242,7 @@ interface IB20 { event NameUpdated(string newName); event SymbolUpdated(string newSymbol); event ContractURIUpdated(); - event BurnedBlocked(address indexed from, uint256 value); + event Seized(address indexed caller, address indexed from, address indexed to, uint256 value); function name() external view returns (string memory); function symbol() external view returns (string memory); @@ -264,7 +264,10 @@ interface IB20 { // Supply-affecting, each gated by its own role (see 3.7) function mint(address to, uint256 value) external; function burn(uint256 value) external; - function burnBlocked(address from, uint256 value) external; + + // Compliance seizure — reassigns a balance, leaving totalSupply unchanged + function seizeWithMemo(address from, address to, uint256 value, bytes32 memo) + external returns (bool); function updateName(string calldata newName) external; function updateSymbol(string calldata newSymbol) external; @@ -274,10 +277,10 @@ interface IB20 { - `mint` increases `to`'s balance and `totalSupply` together, failing with `SupplyCapExceeded` if the result would exceed the configured cap. - `burn` reduces only the caller's own balance, with no path to anyone else's funds. It is role-gated nonetheless, so an issuer controls who may retire supply. -- `burnBlocked` is scoped entirely to accounts already denied under `TRANSFER_SENDER_POLICY` ([§3.8](#38-transfer-policies)). Against an account in good standing it fails with `AccountNotBlocked`, so freezing is structurally prior to seizure rather than merely procedurally so. It emits `BurnedBlocked` alongside the `Transfer` to the zero address, giving indexers a distinguishable enforcement record. +- `seizeWithMemo` reassigns `from`'s balance to `to` without an allowance and without the transfer policies, leaving `totalSupply` unchanged. It is seizure by *transfer*, not by destruction, because a freeze order normally requires the balance to be handed over rather than erased. `from` is seizable only when `SEIZE_HOLDER_POLICY` does **not** authorize it (`AccountNotSeizable` otherwise), so freezing is structurally prior to seizure rather than merely procedurally so; `to` must be authorized by `SEIZE_RECEIVER_POLICY` ([§3.8](#38-transfer-policies)). It emits `Transfer`, `Memo`, and `Seized` in that order, giving indexers a distinguishable enforcement record. - `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. -**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. Zero is permitted. The memo is a reconciliation reference, and both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. `seizeWithMemo` is the exception in the other direction: it has no memo-less form, because a seizure should always carry its reference. Zero is permitted. The memo is a reconciliation reference, and both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. @@ -285,7 +288,7 @@ Because these selectors are the compatibility surface every integrator relies on | Case | Required behavior | |---|---| -| `transfer`/`transferFrom`/`mint` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`/`burnBlocked`, which adjust `totalSupply`; a transfer to the zero address would strand supply silently | +| `transfer`/`transferFrom`/`mint`/`seizeWithMemo` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`, which adjusts `totalSupply`; a transfer to the zero address would strand supply silently | | `transferFrom` where `from` is the zero address | Reverts with `InvalidSender` — unreachable in practice, but rejected explicitly rather than by assumption | | Zero-value `transfer`/`transferFrom` | Succeeds and emits `Transfer`, per ERC-20 | | Zero-value `mint`/`burn` | Succeeds and emits the corresponding `Transfer`, leaving `totalSupply` unchanged | @@ -306,7 +309,7 @@ Access to privileged operations follows the same role/member/role-admin model wi function DEFAULT_ADMIN_ROLE() external pure returns (bytes32); function MINT_ROLE() external pure returns (bytes32); function BURN_ROLE() external pure returns (bytes32); -function BURN_BLOCKED_ROLE() external pure returns (bytes32); +function SEIZE_ROLE() external pure returns (bytes32); function PAUSE_ROLE() external pure returns (bytes32); function UNPAUSE_ROLE() external pure returns (bytes32); function METADATA_ROLE() external pure returns (bytes32); @@ -335,14 +338,14 @@ Built-in roles shared by both variants: | `DEFAULT_ADMIN_ROLE` | `bytes32(0)` | Role grants/revocations, `setRoleAdmin`, `updatePolicy`, `updateSupplyCap` | | `MINT_ROLE` | `keccak256("MINT_ROLE")` | `mint`, `mintWithMemo`, and `batchMint` on the Asset variant | | `BURN_ROLE` | `keccak256("BURN_ROLE")` | `burn`, `burnWithMemo` (self-burn only) | -| `BURN_BLOCKED_ROLE` | `keccak256("BURN_BLOCKED_ROLE")` | `burnBlocked` | +| `SEIZE_ROLE` | `keccak256("SEIZE_ROLE")` | `seizeWithMemo` | | `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")` | `pause` | | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")` | `unpause` | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")` | `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata` on the Asset variant | The Asset variant defines one additional role, `OPERATOR_ROLE` (`keccak256("OPERATOR_ROLE")`), gating `updateMultiplier` and `announce` — see [§3.12](#312-asset-variant-extensions). Each built-in role ID is also exposed as a pure getter, so a calling contract references `MINT_ROLE()` rather than hardcoding a hash that a later revision could invalidate. A call that fails a role gate reverts with `AccessControlUnauthorizedAccount(account, neededRole)`, naming the role that was missing so the failure is actionable without guesswork. -The splits between mint and burn, pause and unpause, `BURN_ROLE` and `BURN_BLOCKED_ROLE`, and `OPERATOR_ROLE` and the rest are each deliberate; see [§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling). +The splits between mint and burn, pause and unpause, `BURN_ROLE` and `SEIZE_ROLE`, and `OPERATOR_ROLE` and the rest are each deliberate; see [§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling). Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` value. Holding one has no built-in effect on any operation; it is an on-chain membership record for the issuer or third-party contracts to query, or an intermediate tier in a `setRoleAdmin` hierarchy. @@ -358,7 +361,7 @@ The transition is one-way and freezes role *membership*, not the roles' effects. ### 3.8 Transfer Policies -Every B20 token holds **four independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Four separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: +Every B20 token holds **six independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: | Scope | Checked against | Applies to | |---|---|---| @@ -366,6 +369,8 @@ Every B20 token holds **four independent policy slots**, each referencing a poli | `TRANSFER_RECEIVER_POLICY` | the `to` | `transfer`, `transferFrom` | | `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transferFrom` only, and only when `msg.sender != from` | | `MINT_RECEIVER_POLICY` | the `to` | `mint`, `mintWithMemo`, `batchMint` — including inside the factory bootstrap window | +| `SEIZE_HOLDER_POLICY` | the `from` | `seizeWithMemo`, **inverted**: seizable only when *not* authorized | +| `SEIZE_RECEIVER_POLICY` | the `to` | `seizeWithMemo` | ```solidity // IB20 — transfer-policy slots. Part of the same interface as [§3.6](#36-shared-token-interface). @@ -375,9 +380,9 @@ function policyId(bytes32 scope) external view returns (uint64); function updatePolicy(bytes32 scope, uint64 policyId) external; // DEFAULT_ADMIN_ROLE-gated ``` -`TRANSFER_EXECUTOR_POLICY` constrains *who may move someone else's balance*, independently of whether either party is authorized ([§4.4](#44-four-policy-scopes-rather-than-one-account-flag)). It applies only to genuine third-party transfers — a `transferFrom` where the spender is the owner is not delegated and is not subject to it. +`TRANSFER_EXECUTOR_POLICY` constrains *who may move someone else's balance*, independently of whether either party is authorized ([§4.4](#44-six-policy-scopes-rather-than-one-account-flag)). It applies only to genuine third-party transfers — a `transferFrom` where the spender is the owner is not delegated and is not subject to it. -Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a new token is fully open and compliance is opt-in. Only balance-moving operations are gated: `approve`, self-`burn`, and all reads are not, since granting permission to move funds is not itself a movement of funds. +Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a new token is fully open and compliance is opt-in. `SEIZE_HOLDER_POLICY` is inverted, and the same default is therefore the safe one from the other direction: on an unconfigured token no account is seizable at all. `SEIZE_RECEIVER_POLICY` defaults open, so an issuer that has configured the holder side may seize to any destination without first allowlisting a treasury. Only balance-moving operations are gated: `approve`, self-`burn`, and all reads are not, since granting permission to move funds is not itself a movement of funds. The PolicyRegistry is a separate singleton precompiled contract, independent of any specific token, so many tokens can share one list — the central reason for hoisting compliance state out of the token: @@ -431,7 +436,7 @@ Two sentinel IDs therefore exist without any `createPolicy` call, and both follo ```solidity // IB20 — pause. Part of the same interface as [§3.6](#36-shared-token-interface). -enum PausableFeature { TRANSFER, MINT, BURN } // bit 0, 1, 2 of the pause mask +enum PausableFeature { TRANSFER, MINT, BURN, SEIZE } // bit 0, 1, 2, 3 of the pause mask event Paused(address indexed updater, PausableFeature[] features); event Unpaused(address indexed updater, PausableFeature[] features); @@ -444,7 +449,7 @@ function pausedFeatures() external view returns (PausableFeature[] memory); Pause state is a bitmask over `PausableFeature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`, since it can only be a caller error. `PausableFeature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. -`burnBlocked` is deliberately outside the mask: `pause(BURN)` stops self-service `burn` but has no effect on a compliance seizure, which is exactly the action an issuer may need to keep exercising while ordinary activity is frozen during an incident. +`SEIZE` is a category of its own rather than a part of `TRANSFER`, so an issuer freezing ordinary activity during an incident keeps the one action the incident may require: `pause(TRANSFER)` halts user transfers while leaving `seizeWithMemo` callable, and `pause(SEIZE)` withdraws the seizure power on its own. ### 3.10 Supply Cap @@ -634,12 +639,12 @@ Every feature starts deactivated, so the fork can ship without opening anything, | Operation | Gated | |---|---| | `createB20`; PolicyRegistry write methods | Yes | -| Every method on a token that already exists — transfers, approvals, mint, burn, `burnBlocked`, roles, pause, policy binding, permit, multiplier, announcements | **No** | +| Every method on a token that already exists — transfers, approvals, mint, burn, `seizeWithMemo`, roles, pause, policy binding, permit, multiplier, announcements | **No** | | Every read, including `isAuthorized` and `policyExists` | **No** | A token's dispatch entry point performs no activation check at all. This is a normative requirement, not an implementation detail: a conforming implementation MUST NOT consult the ActivationRegistry on the path of an existing token's operations. -Deactivating a feature therefore stops new issuance and nothing else: tokens already created keep working exactly as before. Freezing activity on a specific token remains the issuer's decision, exercised through that token's own `pause` ([§3.9](#39-pause)), not something a chain operator can do through this switch. Reads are never gated because `isAuthorized` sits on the path of every transfer, and a network-level switch must not be able to make transfers fail. +Deactivating a feature stops creation, and for the PolicyRegistry it also stops membership and admin updates on policies that already exist — every non-view method sits behind the gate. What it never reaches is a token: tokens already created keep working exactly as before, reads included. An issuer relying on a shared policy for live compliance should note that a deactivation freezes that list where it stands. Freezing activity on a specific token remains the issuer's decision, exercised through that token's own `pause` ([§3.9](#39-pause)), not something a chain operator can do through this switch. Reads are never gated because `isAuthorized` sits on the path of every transfer, and a network-level switch must not be able to make transfers fail. **Authority.** `activationAdmin` MUST be governance-controlled — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from activation onward, so a compromised key needs no hard fork to replace. Its initial value comes from chain configuration; a zero address means nothing can be activated on that network. The three mutating methods reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)), and resolving a flag is charged as a storage read ([§3.14](#314-gas-accounting)). @@ -679,13 +684,13 @@ A new token has no role holders, so its first `grantRole` cannot be authorized b It is not a general escalation: it skips the role gate and the transfer-side policy gates, and never `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys something typed parameters cannot express — a token immutable from birth, configured entirely inside the window with `initialAdmin` zero, so no admin key ever exists to be compromised or subpoenaed. -### 4.4 Four Policy Scopes Rather Than One Account Flag +### 4.4 Six Policy Scopes Rather Than One Account Flag Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers stay open. A single per-account flag forces every issuer to write custom logic for that asymmetry — one of the duplications this standard exists to remove. The executor scope in particular has no BEP-20 equivalent. ### 4.5 Separate Roles for Mint, Burn, Seizure, and Rescaling -Four splits, each for a distinct reason. **Mint and burn** are separate keys because a compromised mint key and a compromised burn key are different incidents with different blast radii, and should be rotatable independently. **Pause and unpause** are separate so a single leaked key can only push the token one way; an attacker holding one cannot complete a stop-then-resume cycle. **`BURN_BLOCKED_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `burnBlocked` destroys funds it does not, and only after policy has denied the target. **`OPERATOR_ROLE` is separate from `DEFAULT_ADMIN_ROLE` and `MINT_ROLE`** because `updateMultiplier` restates what every holder's balance is worth in one call; restating value and issuing units are the two ways to dilute existing holders, and an issuer should be able to separate and audit them. +Four splits, each for a distinct reason. **Mint and burn** are separate keys because a compromised mint key and a compromised burn key are different incidents with different blast radii, and should be rotatable independently. **Pause and unpause** are separate so a single leaked key can only push the token one way; an attacker holding one cannot complete a stop-then-resume cycle. **`SEIZE_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `seizeWithMemo` moves funds it does not own, and only after policy has denied the holder. **`OPERATOR_ROLE` is separate from `DEFAULT_ADMIN_ROLE` and `MINT_ROLE`** because `updateMultiplier` restates what every holder's balance is worth in one call; restating value and issuing units are the two ways to dilute existing holders, and an issuer should be able to separate and audit them. ### 4.6 Memo as a Separate Event From 19940eb7d1de8462ea9ec03af53ff71d4a081e03 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 14:42:35 +0800 Subject: [PATCH 35/41] BEP-702: cut cross-section repetition --- BEPs/BEP-702.md | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index ce112dd4..9ee4b3d9 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -91,7 +91,7 @@ Three independent checks guard B20 operations, each with its own controller: the Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. It is the singleton pattern the registries already use — one piece of code serving every caller, differentiated only by address. -Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only the creation of new tokens and policies, never the operation of tokens that already exist, so the two steps can never disagree about a live token ([§4](#4-rationale)). +Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only creation, never tokens that already exist ([§4.2](#42-a-governance-switch-on-top-of-the-fork)). ### 3.2 Stateful Precompiled Contracts @@ -146,14 +146,12 @@ The unrecognized-variant row is unreachable on a chain a node is following — a Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. This standard gives such a balance no meaning and no way out. -Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's. Comparing against that one hash rather than testing for non-empty code is what makes the reserved space safe without a deployment restriction: [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) forbids deploying code that begins with `0xEF`, so nothing an attacker can put at an address hashes to the sentinel, while `createB20` writes it directly. +Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's — an exact comparison, not a non-empty test, which is what the argument above rests on. An account occupying a recognized-variant reserved address becomes unreachable at activation: calls route to the token handler and its own code never runs. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without it recovers no addresses at all, which is indistinguishable from a clean result — an implementation MUST therefore confirm the scanned set was non-empty before treating the outcome as a pass, or reconstruct it from chain history. -The three singletons admit a direct check and are unoccupied — zero nonce, zero balance, no code — on BSC mainnet at block 113,754,318 and Chapel at 122,896,297. The token range spans 2^80 addresses and cannot be enumerated, but the ten-byte reserved prefix makes occupation implausible rather than merely unlikely: accidental collision is on the order of 10^-15 against BSC's account count, and grinding into the range costs 2^80 hashes. - ### 3.4 B20Factory The B20Factory is the singleton entry point for token creation. Its interface: @@ -670,47 +668,50 @@ The sentinel is written once, at creation, and never removed. Implementations MU ### 4.1 Three Singletons -The B20Factory only ever creates tokens. The PolicyRegistry is the one piece of cross-token state that benefits from living outside any single token. The ActivationRegistry is chain-level state belonging to the operator, not to an issuer, which is why it is separate from the factory rather than a field in it: the activation key should never sit adjacent to issuer state. Everything else a token needs — roles, policy references, pause bits, supply cap, multiplier — is local to its own address. +Each singleton has one state domain and authority: the B20Factory creates tokens, the PolicyRegistry holds reusable cross-token policy state, and the ActivationRegistry holds chain-governance state. Token-specific state remains at the token's own address ([§3.1](#31-architecture-overview)). ### 4.2 A Governance Switch on Top of the Fork -The flag separates "the code is deployed" from "issuers may start using it." A hard fork cannot be scheduled per network or reversed quickly, whereas readiness to accept permanent issuer-created state may arrive later, may differ between testnet and mainnet, and may need withdrawing if problems surface. Without the flag the only instrument for any of that is another fork. - -The objection — that an on-chain flag becomes a second source of truth for whether the feature is live — is answered by restricting what it reaches ([§3.15](#315-feature-activation)). It gates creation and nothing else, so a live token's behaviour is fixed by the fork alone and the two can never disagree about one. That restriction is also what makes withdrawal conservative rather than disruptive. +The fork makes the code available; the registry lets each network decide when to admit the gated state changes listed in [§3.15](#315-feature-activation). Existing-token dispatch never consults it, so activation policy cannot change a live token's execution semantics. ### 4.3 A Privileged Bootstrap Window -A new token has no role holders, so its first `grantRole` cannot be authorized by anything. Either every initial setting becomes a typed factory parameter, or the factory replays caller-supplied calls in a bounded privileged window. Typed parameters audit more easily but fix what can be configured at birth, and every later extension would widen the factory signature; the window reuses the token's own methods and stays correct as the surface grows. - -It is not a general escalation: it skips the role gate and the transfer-side policy gates, and never `MINT_RECEIVER` policy, pause, the supply cap, or the admin anti-resurrection guard ([§3.4](#34-b20factory)). It also buys something typed parameters cannot express — a token immutable from birth, configured entirely inside the window with `initialAdmin` zero, so no admin key ever exists to be compromised or subpoenaed. +A new token has no role holder able to authorize its first configuration. Replaying bounded `initCalls` reuses the token's own methods instead of expanding the factory parameters for every extension, while the limits in [§3.4](#34-b20factory) prevent general privilege escalation. It also permits a token configured and made immutable in the same transaction, without ever creating an admin key. ### 4.4 Six Policy Scopes Rather Than One Account Flag -Compliance regimes are asymmetric: an investor may hold but not receive, a broker may move others' funds while holding none, freshly minted supply may be restricted to custody accounts while secondary transfers stay open. A single per-account flag forces every issuer to write custom logic for that asymmetry — one of the duplications this standard exists to remove. The executor scope in particular has no BEP-20 equivalent. +Compliance rules distinguish senders, receivers, delegated executors, mint recipients, and the two sides of a seizure. The six scopes encode those asymmetric cases directly instead of forcing issuer-specific transfer logic ([§3.8](#38-transfer-policies)). ### 4.5 Separate Roles for Mint, Burn, Seizure, and Rescaling -Four splits, each for a distinct reason. **Mint and burn** are separate keys because a compromised mint key and a compromised burn key are different incidents with different blast radii, and should be rotatable independently. **Pause and unpause** are separate so a single leaked key can only push the token one way; an attacker holding one cannot complete a stop-then-resume cycle. **`SEIZE_ROLE` is separate from `BURN_ROLE`** because `burn` destroys funds the caller owns — a treasury operation — while `seizeWithMemo` moves funds it does not own, and only after policy has denied the holder. **`OPERATOR_ROLE` is separate from `DEFAULT_ADMIN_ROLE` and `MINT_ROLE`** because `updateMultiplier` restates what every holder's balance is worth in one call; restating value and issuing units are the two ways to dilute existing holders, and an issuer should be able to separate and audit them. +Each role split isolates a distinct authority and compromise domain: + +| Split | Reason | +|---|---| +| Mint / burn | Issuing supply and retiring the holder's own supply have different blast radii and rotation needs | +| Pause / unpause | A leaked key can move the token in only one direction, not complete a stop-and-resume cycle | +| Burn / seizure | Burning the caller's funds is distinct from moving a policy-denied holder's funds | +| Operator / admin / mint | Rescaling all displayed balances, administering the token, and issuing units are independently auditable powers | ### 4.6 Memo as a Separate Event -Reconciliation references have no home in BEP-20, and the cost of not having them is an entire off-chain matching layer. Widening `Transfer` would break every existing indexer; a distinct `Memo` event costs one additional log and nothing else. +A separate `Memo` adds reconciliation data without changing the BEP-20 `Transfer` signature or breaking existing indexers. ### 4.7 Prefix-Recognizable Addressing -A fixed prefix plus a variant discriminant lets any caller answer both "is this a B20 token" and "which variant" from the address alone, with no RPC round-trip — the property BSC's existing precompiles have by occupying low fixed addresses. It is also what makes routing possible without a state read on every call ([§3.3](#33-b20-address-space)). +The fixed prefix and variant byte make recognition, variant selection, and call routing possible from the address alone, without a storage read ([§3.3](#33-b20-address-space)). ### 4.8 No Gas Schedule -A flat price per entry point is wrong in both directions for entry points whose work scales with input, and underpricing large inputs is exactly the state-growth gap [§3.14](#314-gas-accounting) exists to close. A published table would also have to be revised whenever the surrounding schedule moved, and would silently diverge in between. Deriving every charge from an existing cost function instead means a later change to the gas schedule — including adoption of a separate state-gas dimension — propagates without amending this standard. +Charging the existing cost functions for work performed handles variable-size inputs and automatically follows later gas-schedule changes; a separate fixed table would either misprice such work or drift from the active fork ([§3.14](#314-gas-accounting)). ### 4.9 `0xEF` as the Account Sentinel -A nonce bump would satisfy EIP-161 equally well, but `0xEF` is the prefix [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) reserves, so no `CREATE`/`CREATE2` deployment can produce it. That is what lets this standard leave `CREATE`/`CREATE2` output addresses unrestricted ([§3.3](#33-b20-address-space)): the marker of a protocol-owned account is unforgeable by construction, so squatting in the reserved space cannot produce anything that passes as a token. Guarding the deployment path would protect the same property by a weaker means — it would depend on every implementation applying the guard correctly, where the marker depends on nothing. A nonce carries none of this: it is ordinary account state, and would leave `EXTCODESIZE` reporting zero ([§5](#5-backward-compatibility)). +Both a nonce and code would prevent EIP-161 clearing, but `0xEF` also gives every B20 account non-zero code size and an EIP-3541-protected identity that `CREATE`/`CREATE2` cannot forge. This avoids a deployment restriction while preserving common contract-detection behaviour ([§3.3](#33-b20-address-space), [§5](#5-backward-compatibility)). ### 4.10 Decimals Bounds -Below `6`, a token cannot represent the sub-unit amounts payment and asset use cases routinely need; `18` is the widest precision the surrounding ecosystem handles uniformly. The Stablecoin variant is pinned to `6` rather than given the range because a payment unit should mean the same thing across issuers, and the choice is one fewer thing for an integrator to read. +The `[6, 18]` Asset range covers common sub-unit precision while remaining within ecosystem conventions; Stablecoin fixes `6` so payment-token integrations need not discover an issuer-specific choice. ## 5. Backward Compatibility @@ -725,7 +726,7 @@ Because every B20 account carries the sentinel ([§3.16](#316-account-sentinel)) Tooling that pre-warms precompile bytecode in a fork (some local test harnesses do this so Solidity's code-size check passes) will find the sentinel already present and MUST NOT treat that as evidence that a token exists. -Gas estimation must execute rather than predict. A B20 operation's cost depends on state it has not read when the call begins — whether a slot is cold, whether a balance write creates or rewrites, whether a policy is configured at all ([§3.14](#314-gas-accounting)) — so no per-selector constant is correct, and `eth_estimateGas` MUST resolve it by execution as it already does for contract calls. Two consequences for integrators: a hardcoded gas figure for `transfer` will underestimate a first-time recipient by roughly the difference between a storage creation and a rewrite, and an out-of-gas condition can surface partway through an operation rather than at a predictable opcode boundary. +Gas estimation must execute rather than predict: a B20 operation's cost depends on state it has not read when the call begins ([§3.2](#32-stateful-precompiled-contracts)), so no per-selector constant is correct and `eth_estimateGas` MUST resolve it by execution, as it already does for contract calls. A hardcoded gas figure for `transfer` will underestimate a first-time recipient, and out-of-gas can surface partway through an operation rather than at a predictable opcode boundary. ## 6. Security Considerations @@ -735,7 +736,7 @@ This logic runs as native client code, not as an isolated contract. A single imp ### 6.2 Activation Authority -The activation admin ([§3.15](#315-feature-activation)) can halt new token and policy creation network-wide, and deliberately cannot touch tokens that already exist. The worst outcome of a compromised or misused activation key is therefore that issuance halts, or that a feature opens earlier than intended — not that balances are frozen or moved. Vesting the key in the timelock keeps the "opened too early" direction under the same review as any other governance action, and `setActivationAdmin` lets a suspected compromise be remediated without a hard fork. +The worst outcome of a compromised or misused activation key is bounded by what the switch reaches ([§3.15](#315-feature-activation)): issuance halts, or a feature opens earlier than intended — never a frozen or moved balance. Vesting the key in the timelock keeps the "opened too early" direction under ordinary governance review. ### 6.3 Identity-Fingerprint Strength @@ -749,7 +750,7 @@ Three authorities carry risk that no protocol rule can bound: - **`OPERATOR_ROLE`** changes what every holder of an Asset-variant token believes their holding is worth, in display terms, without moving a single raw token, and `announce` can execute an arbitrary bundle of the token's own methods under the announcer's authority. Issuers SHOULD hold it in a multisig or timelocked process rather than a single hot key. - **Policy admin.** Compromise of a policy's admin key affects every token whose scopes reference that policy ID — the direct cost of the sharing that makes the registry worthwhile. Issuers reusing a policy across tokens should weigh that concentration against the convenience, and the two-step admin handover ([§3.8](#38-transfer-policies)) means a transfer cannot silently land on an unreachable address. -- **`renounceLastAdmin`** is irreversible ([§3.7](#37-roles-and-access-control)): once a token's admin count reaches zero, policy updates, supply-cap changes, and role management are permanently uncallable — and no path, including the factory bootstrap, can restore them. This is by design, but issuers MUST treat it as one-way. The same terminal state is reachable at birth by passing a zero `initialAdmin`. +- **`renounceLastAdmin`** is irreversible ([§3.7](#37-roles-and-access-control)) — by design, but issuers MUST treat it as one-way. The same terminal state is reachable at birth by passing a zero `initialAdmin`. ### 6.5 Multiplier Bounds and Precision @@ -763,12 +764,12 @@ Pricing an operation at the cost of the state accesses it performs ([§3.14](#31 Deriving cost from existing cost functions bounds this but does not eliminate it. B20 cannot underprice a slot relative to bytecode, because it charges the same function; what it can do is let a block reach the slot-creation ceiling with less non-state work along the way. The residual is therefore the difference between the two paths' non-state overhead, not an open-ended discount, and it MUST be quantified against the parity measurement in [§3.14](#314-gas-accounting) rather than discovered after activation. -Raising the price of state creation is out of scope for this standard, and deliberately so: it is a chain-wide parameter that applies equally to BEP-20 tokens and to every other contract, so it belongs in a proposal of its own rather than being set by a token standard. Should BSC adopt one, B20 inherits it with no amendment here ([§3.14](#314-gas-accounting)). +Raising the price of state creation is out of scope: it is a chain-wide parameter that applies equally to BEP-20 tokens and every other contract, so it belongs in a proposal of its own — and would propagate here automatically ([§3.14](#314-gas-accounting)). ### 6.7 Integration Assumptions - **No external calls.** Token operations execute as native code rather than delegated bytecode, so they perform no external calls and expose no hooks (no ERC-777-style pre/post-transfer callbacks). This removes an entire class of reentrancy vectors that issuer-written BEP-20 contracts are otherwise exposed to. The reentrancy sentry of [§3.14](#314-gas-accounting) preserves the assumption in the opposite direction, for already-audited contracts that forward only the 2300-gas stipend. -- **Account sentinel coverage.** The sentinel ([§3.16](#316-account-sentinel)) is what keeps B20 state from being reaped by EIP-161, and the guarantee is only as good as its coverage. Any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at the moment it is created. The failure mode is silent and total: state written to an account that is still empty is discarded at the end of the block, with no revert and no event. An implementation SHOULD enforce the invariant structurally, so that a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. +- **Account sentinel coverage.** The EIP-161 guarantee ([§3.16](#316-account-sentinel)) is only as good as its coverage: any account this standard — or a later one extending it — causes to hold storage MUST receive the sentinel at creation. An implementation SHOULD enforce that structurally, so a storage-bearing account cannot be introduced without one, rather than relying on each new call site to remember. - **`EXTCODEHASH` identity checks.** See [§5](#5-backward-compatibility). Every B20 account shares one code hash, so it identifies the class, not the token. - **Precompile address allocation.** Both namespaces — `0x20B…`, holding the `0x20B0` token prefix and the factory, and `0x7020…`, holding the registries ([§3.1](#31-architecture-overview)) — MUST be checked against every other in-flight BEP proposing new precompiles before this BEP leaves Draft status. The `0x20B0` prefix needs the wider check of the two, since it reserves an entire address range rather than a single slot. A later BEP extending this standard SHOULD add singletons under its own number rather than in `0x7020…`, and MUST NOT introduce a second token prefix: routing recognizes exactly one, and a second would have to be added to every caller that checks for a B20 address. From cc200723f48fd8edcfa46066a10c475bfa4fa9b1 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 15:23:26 +0800 Subject: [PATCH 36/41] BEP-702: shape Motivation labels and closer after TIP-1034 --- BEPs/BEP-702.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 9ee4b3d9..1c79d24d 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -57,12 +57,14 @@ This BEP introduces a protocol-native fungible token standard for BSC, in two va ## 2. Motivation -BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. Moving the token itself into the protocol is motivated by four consequences of that gap. +BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. Moving the token itself into the protocol is motivated by four protocol-level goals: -1. **Behaviour becomes a guarantee rather than a claim.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch; native logic ships through the hard-fork process instead. -2. **Compliance primitives stop being rebuilt per issuer.** Role-gated mint and burn, seizure of blocked balances, granular pause and supply caps are re-implemented by every stablecoin and tokenized-asset issuer, each time with a fresh bug surface. -3. **Real-world assets get redenomination, not only transfers.** Rescaling per-unit value across every holder at once, independent of any transfer, has no standard answer in BEP-20. -4. **Cost reflects state access rather than interpretation.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs. +1. **Guaranteed behaviour.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch; native logic ships through the hard-fork process instead. +2. **Built-in compliance primitives.** Role-gated mint and burn, seizure of frozen balances, granular pause, and supply caps are re-implemented by every stablecoin and tokenized-asset issuer, each time with a fresh bug surface. +3. **Redenomination for real-world assets.** Rescaling per-unit value across every holder at once, independent of any transfer, has no standard answer in BEP-20. +4. **State-access pricing.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs. + +All of it sits behind the unchanged BEP-20 selector surface, so existing wallets, indexers, and routers integrate without modification ([§5](#5-backward-compatibility)). ## 3. Specification From 0d908b92d306e4baf6a93167dfd559d3e12c5c86 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 15:52:25 +0800 Subject: [PATCH 37/41] BEP-702: rewrite Motivation as one argument with corollaries --- BEPs/BEP-702.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index 1c79d24d..e98a61b1 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -57,12 +57,12 @@ This BEP introduces a protocol-native fungible token standard for BSC, in two va ## 2. Motivation -BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. Moving the token itself into the protocol is motivated by four protocol-level goals: +BEP-20 defines an ABI convention, not a guarantee about behaviour: any address may claim it while running arbitrary bytecode behind it. For the stablecoins and tokenized real-world assets this standard targets, that gap is what every audit and every integration review exists to bridge. Moving the token itself into the protocol closes it at the source: -1. **Guaranteed behaviour.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch; native logic ships through the hard-fork process instead. -2. **Built-in compliance primitives.** Role-gated mint and burn, seizure of frozen balances, granular pause, and supply caps are re-implemented by every stablecoin and tokenized-asset issuer, each time with a fresh bug surface. -3. **Redenomination for real-world assets.** Rescaling per-unit value across every holder at once, independent of any transfer, has no standard answer in BEP-20. -4. **State-access pricing.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs. +1. **Guaranteed behaviour.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch. Native logic ships through the hard-fork process: what an address claims and what it executes are the same fact. +2. **Compliance primitives, verified once.** Role-gated mint and burn, seizure of frozen balances, granular pause, and supply caps exist in audited libraries — but nothing proves a deployed token runs them unmodified, so the verification cost recurs per token. Built into the protocol, they inherit the guarantee above. +3. **Redenomination for real-world assets.** Rescaling per-unit value across every holder at once, independent of any transfer, has an interface convention ([BEP-677](./BEP-677.md)) but no enforceable semantics — the same claim-versus-guarantee gap again ([§3.12](#312-asset-variant-extensions)). +4. **State-access pricing.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs; native execution charges the state accesses alone ([§3.14](#314-gas-accounting)). All of it sits behind the unchanged BEP-20 selector surface, so existing wallets, indexers, and routers integrate without modification ([§5](#5-backward-compatibility)). From 3dcac14f774390e9df650e9857f2efd9e346f4fe Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 16:01:33 +0800 Subject: [PATCH 38/41] BEP-702: strip inline rationale from the specification --- BEPs/BEP-702.md | 96 ++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index e98a61b1..ab2df14f 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -85,19 +85,19 @@ The three singletons occupy fixed addresses, identical on every network: | PolicyRegistry | `0x7020000000000000000000000000000000000001` | | ActivationRegistry | `0x7020000000000000000000000000000000000002` | -Two namespaces are in use, and the split follows what each address has to do. `0x20B…` carries the token space and its factory, because the token prefix does routing work on every call ([§3.3](#33-b20-address-space)) and so must be recognizable from the address alone; the factory sits beside it, separated by the second byte, and since the token space pins that byte to `0xB0` the two can never be confused or collide. The registries need no such structural property — they are reached through a fixed constant — so they take sequential slots in `0x7020…`, this BEP's own number. +The token space and its factory share the `0x20B…` namespace, distinguished by the second byte (`0xB0` for tokens, `0xBF` for the factory); the registries take sequential slots under `0x7020…`, this BEP's number. B20 tokens themselves occupy no fixed address: they form a **dynamic family** resolved from the address prefix at call time ([§3.3](#33-b20-address-space)). Three independent checks guard B20 operations, each with its own controller: the ActivationRegistry decides whether a feature is available on the network at all (chain governance), RBAC decides which callers may invoke an operation ([§3.7](#37-roles-and-access-control), the token's issuer), and the PolicyRegistry decides which addresses may be operated on ([§3.8](#38-transfer-policies), the policy's admin). `createB20` is subject to the first; `mint` to the second and third. Passing one check never implies another. -Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. It is the singleton pattern the registries already use — one piece of code serving every caller, differentiated only by address. +Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only creation, never tokens that already exist ([§4.2](#42-a-governance-switch-on-top-of-the-fork)). ### 3.2 Stateful Precompiled Contracts -BSC's existing precompiles — signature recovery, hashing, light-client proof verification — are pure functions of their input, and the interface they implement (`core/vm.PrecompiledContract`) reflects that: `RequiredGas(input)` and `Run(input)`, with no access to state, caller, or logs. A B20 token needs to persist balances, allowances, roles, and policy references across calls, and to emit `Transfer`/`Approval`-shaped logs that existing indexers already know how to consume. This BEP defines an additive, backward-compatible extension: +BSC's existing precompile interface (`core/vm.PrecompiledContract`) is stateless — `RequiredGas(input)` and `Run(input)`, with no access to state, caller, or logs. This BEP defines an additive, backward-compatible extension: ```go type StatefulPrecompiledContract interface { @@ -121,19 +121,17 @@ A B20 token address is 20 bytes: | Bytes | Length | Content | Meaning | |---|---|---|---| | `[0:2]` | 2 | `0x20B0` | Fixed marker identifying the address as a B20 token | -| `[2:10]` | 8 | `0x00` × 8 | Namespace padding — together with `[0:2]` forms the reserved space, making accidental collision with an ordinary account or `CREATE`/`CREATE2` contract vanishingly unlikely | +| `[2:10]` | 8 | `0x00` × 8 | Namespace padding — together with `[0:2]` forms the ten-byte reserved prefix | | `[10]` | 1 (the 11th byte) | Variant discriminant | `0x00` = Asset, `0x01` = Stablecoin ([§3.5](#35-variants)); `0x02` and above are reserved for future variants | | `[11:20]` | 9 | `keccak256(creator ++ salt)[0:9]` | Identity fingerprint, where `creator` is the account that called the B20Factory and `salt` is caller-supplied entropy | Three helpers follow from the layout alone, none requiring a storage read: -- `isB20(address) -> bool` checks only bytes `[0:10]`. It deliberately ignores the variant byte, so a token of a future variant is still recognized as a B20 address by code written before that variant existed. It is a syntactic check, not an existence check: it answers true for any address in the reserved space, including one no `createB20` call has produced. +- `isB20(address) -> bool` checks only bytes `[0:10]`. It ignores the variant byte, so a future variant is still recognized. It is a syntactic check, not an existence check: it answers true for addresses no `createB20` call has produced. - `variantOf(address) -> Variant` reads byte `[10]` alone — the same byte the call-dispatch handler consults to decide which method set an address exposes ([§3.1](#31-architecture-overview)). - `getB20Address(variant, creator, salt) -> address`, a view method on the B20Factory, predicts a token's address before creation, exactly as `CREATE2` allows for ordinary contracts. -No restriction is placed on `CREATE`/`CREATE2` output addresses, and none is needed. Landing inside the reserved space at all means grinding the ten-byte prefix — on the order of 2^80 hashes — and it buys nothing: no code an external mechanism can install, whether deployed or installed as an [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) delegation, can satisfy the existence check ([§3.16](#316-account-sentinel)), so it cannot pass as a token. Meanwhile calls to that address route to the token handler and the squatter's own code never runs. The only account harmed is the squatter's own. - -Lying inside the reserved space is not the same as existing. An address may satisfy `isB20` while no `createB20` call has ever produced it, and the two cases must be distinguished: +No restriction is placed on `CREATE`/`CREATE2` output addresses or [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) delegations into the reserved space: nothing an external mechanism can install satisfies the existence check ([§3.16](#316-account-sentinel)), so a squatter — at a 2^80 grinding cost — only bricks its own account ([§4.9](#49-0xef-as-the-account-sentinel)). Dispatch keys on the address alone — `isB20` and a variant the active fork recognizes. Existence is *not* part of the routing decision; it is checked inside the handler, so a call to a recognized-variant address that holds no token still reaches native code and is rejected there: @@ -146,9 +144,9 @@ Dispatch keys on the address alone — `isB20` and a variant the active fork rec The unrecognized-variant row is unreachable on a chain a node is following — a variant only becomes creatable at the fork that defines it — and is specified so that dispatch is total. -Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. Were balance treated as occupancy, anyone could permanently block creation at an address published through `getB20Address` by force-feeding it one wei. This standard gives such a balance no meaning and no way out. +Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. This standard gives such a balance no meaning and no way out. -Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's — an exact comparison, not a non-empty test, which is what the argument above rests on. +Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's — an exact comparison, not a non-empty test. An account occupying a recognized-variant reserved address becomes unreachable at activation: calls route to the token handler and its own code never runs. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. @@ -205,7 +203,7 @@ Creation parameters are variant-specific: The sentinel MUST be written **before** the initial storage: storage written to an account that is still EIP-161-empty can be discarded by an intervening clearing pass, and rescuing the account afterwards is too late ([§3.16](#316-account-sentinel)). -**The bootstrap window.** `initCalls` run as *privileged* calls against the new token, in order, atomically with creation — a token is born with no role holders, so the first `MINT_ROLE` grant would otherwise need an authority that does not yet exist. The window skips the role gate and the transfer-side policy gates, and nothing more. Three checks are **never** skipped, on any path: +**The bootstrap window.** `initCalls` run as *privileged* calls against the new token, in order, atomically with creation ([§4.3](#43-a-privileged-bootstrap-window)). The window skips the role gate and the transfer-side policy gates, and nothing more. Three checks are **never** skipped, on any path: - the `MINT_RECEIVER` policy ([§3.8](#38-transfer-policies)); - pause state ([§3.9](#39-pause)) and the supply cap ([§3.10](#310-supply-cap)); @@ -221,10 +219,10 @@ Any failing `initCall` reverts the entire creation, and a call shorter than four Every B20 token is created as one of two variants, fixed permanently in its address at creation time ([§3.3](#33-b20-address-space)): -- **Asset** (`variant = ASSET`, byte `0x00`) — the shared surface of [§3.6](#36-shared-token-interface) through [§3.11](#311-permit-eip-2612), plus the extensions of [§3.12](#312-asset-variant-extensions): a protocol-uniform multiplier, on-chain announcements, batch minting, and free-form extra metadata. `decimals` is chosen at creation in the inclusive range `[6, 18]`. This suits tokenized real-world assets — funds, equities, bonds — whose per-unit value is periodically redenominated independently of any transfer activity. -- **Stablecoin** (`variant = STABLECOIN`, byte `0x01`) — the shared surface plus exactly one extension, an immutable `currency()` code ([§3.13](#313-stablecoin-variant-extension)). `decimals` is fixed at `6` and is not stored. The variant is deliberately narrow: a unit is expected to hold a constant meaning over time, so nothing that could rescale or restate it is exposed. +- **Asset** (`variant = ASSET`, byte `0x00`) — the shared surface of [§3.6](#36-shared-token-interface) through [§3.11](#311-permit-eip-2612), plus the extensions of [§3.12](#312-asset-variant-extensions): a protocol-uniform multiplier, on-chain announcements, batch minting, and free-form extra metadata. `decimals` is chosen at creation in the inclusive range `[6, 18]`. +- **Stablecoin** (`variant = STABLECOIN`, byte `0x01`) — the shared surface plus exactly one extension, an immutable `currency()` code ([§3.13](#313-stablecoin-variant-extension)). `decimals` is fixed at `6` and is not stored. -Both variants share identical roles, transfer-policy scopes, pause features, supply-cap mechanism, memo surface, and permit implementation — the shared traits execute the same code for both. A wallet or indexer that understands only the shared surface can safely ignore the variant-specific methods: `balanceOf`, `transfer`, and every other shared method behave identically either way. +Both variants share identical roles, transfer-policy scopes, pause features, supply-cap mechanism, memo surface, and permit implementation — the shared traits execute the same code for both, and a caller that understands only the shared surface can safely ignore the variant extensions. ### 3.6 Shared Token Interface @@ -277,10 +275,10 @@ interface IB20 { - `mint` increases `to`'s balance and `totalSupply` together, failing with `SupplyCapExceeded` if the result would exceed the configured cap. - `burn` reduces only the caller's own balance, with no path to anyone else's funds. It is role-gated nonetheless, so an issuer controls who may retire supply. -- `seizeWithMemo` reassigns `from`'s balance to `to` without an allowance and without the transfer policies, leaving `totalSupply` unchanged. It is seizure by *transfer*, not by destruction, because a freeze order normally requires the balance to be handed over rather than erased. `from` is seizable only when `SEIZE_HOLDER_POLICY` does **not** authorize it (`AccountNotSeizable` otherwise), so freezing is structurally prior to seizure rather than merely procedurally so; `to` must be authorized by `SEIZE_RECEIVER_POLICY` ([§3.8](#38-transfer-policies)). It emits `Transfer`, `Memo`, and `Seized` in that order, giving indexers a distinguishable enforcement record. +- `seizeWithMemo` reassigns `from`'s balance to `to` without an allowance and without the transfer policies, leaving `totalSupply` unchanged. `from` is seizable only when `SEIZE_HOLDER_POLICY` does **not** authorize it (`AccountNotSeizable` otherwise); `to` must be authorized by `SEIZE_RECEIVER_POLICY` ([§3.8](#38-transfer-policies)). It emits `Transfer`, `Memo`, and `Seized` in that order. - `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. -**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. `seizeWithMemo` is the exception in the other direction: it has no memo-less form, because a seizure should always carry its reference. Zero is permitted. The memo is a reconciliation reference, and both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain. It is a separate event rather than a widened `Transfer` for the reason given in [§4](#4-rationale). +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. `seizeWithMemo` alone has no memo-less form. Zero is permitted. Both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain ([§4.6](#46-memo-as-a-separate-event)). On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. @@ -288,8 +286,8 @@ Because these selectors are the compatibility surface every integrator relies on | Case | Required behavior | |---|---| -| `transfer`/`transferFrom`/`mint`/`seizeWithMemo` to the zero address | Reverts with `InvalidReceiver`. Burning goes through `burn`, which adjusts `totalSupply`; a transfer to the zero address would strand supply silently | -| `transferFrom` where `from` is the zero address | Reverts with `InvalidSender` — unreachable in practice, but rejected explicitly rather than by assumption | +| `transfer`/`transferFrom`/`mint`/`seizeWithMemo` to the zero address | Reverts with `InvalidReceiver` | +| `transferFrom` where `from` is the zero address | Reverts with `InvalidSender` | | Zero-value `transfer`/`transferFrom` | Succeeds and emits `Transfer`, per ERC-20 | | Zero-value `mint`/`burn` | Succeeds and emits the corresponding `Transfer`, leaving `totalSupply` unchanged | | `approve` to the zero address | Reverts with `InvalidSpender` | @@ -298,7 +296,7 @@ Because these selectors are the compatibility surface every integrator relies on | `transfer`/`transferFrom` where `from == to` | Succeeds and emits `Transfer`, leaving the balance unchanged | | Insufficient balance or allowance, or a supply-cap breach | Reverts with `InsufficientBalance`, `InsufficientAllowance`, or `SupplyCapExceeded`, with no partial state change. Each carries the observed and required amounts | -Every failure mode in this interface reverts with a specific error rather than returning `false`, so a rejected transfer can never be mistaken for a successful one. +Every failure mode in this interface reverts with a specific error rather than returning `false`. ### 3.7 Roles and Access Control @@ -351,17 +349,17 @@ Custom roles are supported via `setRoleAdmin`/`grantRole` with any `bytes32` val **The last admin.** Each token maintains a count of `DEFAULT_ADMIN_ROLE` holders — the one piece of state this model adds over the conventional `AccessControl` layout — and three protections follow from it: -- Neither `revokeRole` nor `renounceRole` can strip the last remaining holder; both fail with `LastAdminCannotRenounce`, so admin control cannot be lost by accident. -- Giving it up deliberately goes through a distinct entry point, `renounceLastAdmin()`, callable only by the sole holder (`NotSoleAdmin` otherwise). "We lost access by mistake" and "we chose to make this token immutable" are never the same code path — the dangerous action has to be named explicitly. +- Neither `revokeRole` nor `renounceRole` can strip the last remaining holder; both fail with `LastAdminCannotRenounce`. +- Giving it up deliberately goes through a distinct entry point, `renounceLastAdmin()`, callable only by the sole holder (`NotSoleAdmin` otherwise). - Once the count reaches zero, `grantRole`, `revokeRole`, and `setRoleAdmin` fail unconditionally for *every* role, not just `DEFAULT_ADMIN_ROLE`, so no `setRoleAdmin` detour through an unrelated custom role can reinstate an admin. **This guard MUST also hold on the factory's privileged bootstrap path** ([§3.4](#34-b20factory)); it is the only mechanism that could otherwise route around a permanent state, so it admits no exception. `renounceRole` requires `callerConfirmation == msg.sender` (`AccessControlBadConfirmation` otherwise), guarding against a mis-signed or replayed calldata; renouncing a role the caller does not hold succeeds silently, matching `AccessControl` semantics. -The transition is one-way and freezes role *membership*, not the roles' effects. Existing holders keep their powers — a `MINT_ROLE` holder can still mint, a `PAUSE_ROLE` holder can still pause — and any holder can still drop their own membership through `renounceRole`. What becomes impossible is granting a role to a new holder or revoking it from an existing one. An issuer should therefore finish all role assignments before calling `renounceLastAdmin()`, or configure everything inside the bootstrap window and never create an admin at all ([§3.4](#34-b20factory)). +The transition is one-way and freezes role *membership*, not the roles' effects: existing holders keep their powers, and any holder can still drop their own membership through `renounceRole`; what becomes impossible is granting a role to a new holder or revoking one from an existing holder. ### 3.8 Transfer Policies -Every B20 token holds **six independent policy slots**, each referencing a policy in the singleton **PolicyRegistry**. Separate axes, rather than one flag per account, are what let an issuer express asymmetric constraints without any custom logic: +Every B20 token holds **six independent policy slots**, each referencing a policy in the singleton **PolicyRegistry** ([§4.4](#44-six-policy-scopes-rather-than-one-account-flag)): | Scope | Checked against | Applies to | |---|---|---| @@ -384,7 +382,7 @@ function updatePolicy(bytes32 scope, uint64 policyId) external; // DEFAULT_ADMIN Every scope starts at policy ID `0` (`ALWAYS_ALLOW`), so a new token is fully open and compliance is opt-in. `SEIZE_HOLDER_POLICY` is inverted, and the same default is therefore the safe one from the other direction: on an unconfigured token no account is seizable at all. `SEIZE_RECEIVER_POLICY` defaults open, so an issuer that has configured the holder side may seize to any destination without first allowlisting a treasury. Only balance-moving operations are gated: `approve`, self-`burn`, and all reads are not, since granting permission to move funds is not itself a movement of funds. -The PolicyRegistry is a separate singleton precompiled contract, independent of any specific token, so many tokens can share one list — the central reason for hoisting compliance state out of the token: +The PolicyRegistry is a singleton, independent of any token, so many tokens can share one list: ```solidity interface IPolicyRegistry { @@ -415,9 +413,9 @@ interface IPolicyRegistry { A `BLOCKLIST` authorizes everyone except the accounts added to it; an `ALLOWLIST` authorizes no one except the accounts added. Membership updates are type-checked: calling `updateAllowlist` on a `BLOCKLIST` fails with `IncompatiblePolicyType`. Each batch is limited to **64 addresses** (`BatchSizeTooLarge`), which bounds the work a single call can do — a requirement of [§3.14](#314-gas-accounting) for any entry point whose cost scales with its input. Adding an existing member or removing an absent one is idempotent. -**Admin lifecycle.** Transfer is two-step: the incumbent nominates with `stageUpdateAdmin` (zero address cancels) and the nominee must call `finalizeUpdateAdmin` to take over, which proves the destination is controlled — a single-step transfer to a mistyped address would hand a live compliance list to an unreachable account. `renounceAdmin` permanently freezes a policy: membership can never change again while reads keep working, which makes a genesis allowlist guaranteed never to expand expressible. A frozen policy remains distinguishable from one never created: the first is judged against its final membership, the second as the empty set. +**Admin lifecycle.** Transfer is two-step: the incumbent nominates with `stageUpdateAdmin` (zero address cancels) and the nominee must call `finalizeUpdateAdmin` to take over, which proves the destination is controlled. `renounceAdmin` permanently freezes a policy: membership can never change again while reads keep working. A frozen policy remains distinguishable from one never created: the first is judged against its final membership, the second as the empty set. -A `policyId` is self-describing: its most significant byte encodes the `PolicyType` (`0x00` = `BLOCKLIST`, `0x01` = `ALLOWLIST`; any other value is not a valid type), and its low 56 bits are a counter. Any caller can determine a policy's type from the ID alone, with no storage read — the same principle as encoding the variant in the token address. +A `policyId` is self-describing: its most significant byte encodes the `PolicyType` (`0x00` = `BLOCKLIST`, `0x01` = `ALLOWLIST`; any other value is not a valid type), and its low 56 bits are a counter. `isAuthorized` **never reverts** — it sits on the path of every transfer, and a revert would let one misconfigured policy render a token permanently unusable. An unrecognized ID is treated as an **empty policy of the type its own most significant byte encodes**; an invalid type byte authorizes no one. The refusal is raised by the token as `PolicyForbids(scope, policyId)`; the registry only answers true or false. @@ -428,9 +426,9 @@ Two sentinel IDs therefore exist without any `createPolicy` call, and both follo | `ALWAYS_ALLOW` | `0x0000000000000000` | `0x00` = `BLOCKLIST` | An empty blocklist authorizes everyone | | `ALWAYS_BLOCK` | `0x0100000000000001` | `0x01` = `ALLOWLIST` | An empty allowlist authorizes no one | -`ALWAYS_ALLOW` is deliberately `0`, so a freshly created token — whose policy scopes are all zero — carries no compliance restriction; counters start at `2`. A sentinel value of plain `1` would **not** work: its most significant byte is `0x00`, so the rule above would read it as an empty `BLOCKLIST` and authorize everyone, the opposite of the intended meaning. +`ALWAYS_ALLOW` is deliberately `0`, so a freshly created token — whose policy scopes are all zero — carries no compliance restriction; counters start at `2`. -**Tolerant reads require strict writes.** The empty-set fallback has a sharp corollary: a token referencing a never-created `BLOCKLIST` ID would authorize everyone, which looks like an open hole. It is closed on the write side — `updatePolicy` MUST verify `policyExists(newId)` and revert `PolicyNotFound` otherwise. Because every ID that reaches a token slot is guaranteed to exist, the read path's tolerance can never be exploited. +**Tolerant reads require strict writes.** A token referencing a never-created `BLOCKLIST` ID would authorize everyone, so `updatePolicy` MUST verify `policyExists(newId)` and revert `PolicyNotFound` otherwise. Because every ID that reaches a token slot is guaranteed to exist, the read path's tolerance can never be exploited. ### 3.9 Pause @@ -447,9 +445,9 @@ function isPaused(PausableFeature feature) external view returns (bool); function pausedFeatures() external view returns (PausableFeature[] memory); ``` -Pause state is a bitmask over `PausableFeature`, so the three categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched — an issuer can halt `MINT` first and add `TRANSFER` later without disturbing the first decision, and re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`, since it can only be a caller error. `PausableFeature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. +Pause state is a bitmask over `PausableFeature`, so the categories are frozen and thawed independently. `pause` ORs the given features into the mask and `unpause` clears them, leaving unlisted features untouched; re-pausing an already-paused feature is idempotent. An operation whose feature is paused reverts with `ContractPaused(feature)`. An empty array passed to `pause`/`unpause` is rejected with `EmptyFeatureSet`. `PausableFeature` is append-only across future protocol versions so existing bit positions never shift. A token is unpaused across all features at creation. -`SEIZE` is a category of its own rather than a part of `TRANSFER`, so an issuer freezing ordinary activity during an incident keeps the one action the incident may require: `pause(TRANSFER)` halts user transfers while leaving `seizeWithMemo` callable, and `pause(SEIZE)` withdraws the seizure power on its own. +`SEIZE` is a category of its own: `pause(TRANSFER)` halts user transfers while leaving `seizeWithMemo` callable, and `pause(SEIZE)` withdraws the seizure power on its own. ### 3.10 Supply Cap @@ -461,9 +459,9 @@ function supplyCap() external view returns (uint256); function updateSupplyCap(uint256 newCap) external; // DEFAULT_ADMIN_ROLE-gated ``` -The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `InvalidSupplyCap(currentSupply, proposedCap)` if asked to. The standard has no mechanism for retroactively invalidating tokens already minted, so the cap can only ever bind future issuance, never undo past issuance. +The factory initializes the cap to `type(uint128).max`, which means no cap at all; an issuer wanting one from the outset sets it inside the bootstrap window ([§3.4](#34-b20factory)) rather than through a creation parameter. `updateSupplyCap` can move the cap in either direction, with one restriction: it cannot be set below the amount of supply that already exists, and fails with `InvalidSupplyCap(currentSupply, proposedCap)` if asked to. -A cap above `type(uint128).max` is rejected with the same `InvalidSupplyCap`. This ceiling is not a capacity judgment — it is half of an overflow guard. Together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` by construction, so no scaled read can overflow or need wide arithmetic. The bound is not restrictive in practice: `type(uint128).max` is roughly `3.4 × 10^38`, which at 18 decimals is about `3.4 × 10^20` whole units — several orders of magnitude beyond any real-world quantity a token would represent. +A cap above `type(uint128).max` is rejected with the same `InvalidSupplyCap` — half of an overflow guard: together with the matching bound on the Asset multiplier ([§3.12](#312-asset-variant-extensions)), it keeps every multiplier-derived read inside `uint256` ([§6.5](#65-multiplier-bounds-and-precision)). ### 3.11 Permit (EIP-2612) @@ -488,7 +486,7 @@ function eip712Domain() external view returns ( ); ``` -`permit` reverts with `ExpiredSignature(deadline)` past the deadline, `InvalidSigner(signer, owner)` when recovery does not yield `owner`, and `InvalidApprover` for a zero `owner`. Signature verification is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet needs a different approval path (a direct `approve` call, or an external batching/relay mechanism) rather than `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. +`permit` reverts with `ExpiredSignature(deadline)` past the deadline, `InvalidSigner(signer, owner)` when recovery does not yield `owner`, and `InvalidApprover` for a zero `owner`. Signature verification is plain ECDSA over a 65-byte `(v, r, s)` triple; there is no fallback to ERC-1271's `isValidSignature` for contract-owned accounts, so a smart-contract wallet cannot use `permit`. Because `updateName` changes the domain separator ([§3.6](#36-shared-token-interface)), any integrator holding a cached `DOMAIN_SEPARATOR()` value must refresh it upon observing `EIP712DomainChanged`, or signatures generated afterward will fail to verify. ### 3.12 Asset Variant Extensions @@ -527,13 +525,13 @@ interface IB20Asset { `balanceOf` returns the raw, unscaled balance, and the multiplier never changes what `transfer`, `transferFrom`, `mint`, or `burn` move — only the three conversion views are affected. `scaledBalanceOf(account)` equals `toScaledBalance(balanceOf(account))` in one round trip. Because the conversion divides by `1e18`, a raw→scaled→raw round trip need not reproduce the original: callers requiring exact accounting MUST treat the raw balance as authoritative and the scaled value as display only. -`updateMultiplier` is gated by `OPERATOR_ROLE` ([§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling)). It MUST reject a `newMultiplier` of zero, which would make every account's scaled balance permanently zero with no way to recover a meaningful display value. +`updateMultiplier` is gated by `OPERATOR_ROLE` ([§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling)). It MUST reject a `newMultiplier` of zero, which would zero every scaled balance permanently. -It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMultiplier`. This bound is the other half of the overflow guard described in [§3.10](#310-supply-cap): with both the supply cap and the multiplier bounded by `type(uint128).max`, the product `rawBalance * multiplier` never exceeds `uint256`, and dividing by `WAD` only shrinks it further. Two consequences are deliberate — an implementation needs no wide-arithmetic intermediate, and no scaled read can revert or truncate because of overflow. See [§6.5](#65-multiplier-bounds-and-precision) for the risks the bounds do not remove. +It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMultiplier` — the other half of [§3.10](#310-supply-cap)'s overflow guard ([§6.5](#65-multiplier-bounds-and-precision)). -**Issuance must be converted at the current multiplier.** The protocol does not do this for the issuer: `mint` and `batchMint` take *raw* amounts. At a multiplier of `1.2`, a subscription worth 120 units of value must mint `120 / 1.2 = 100` raw — minting 120 raw would hand the subscriber 144 units of scaled value and dilute every existing holder. This is issuer accounting discipline, not something the protocol can enforce, and it is the main reason `updateMultiplier` and `batchMint` are gated by different roles and SHOULD both be wrapped in an announcement. +**Issuance must be converted at the current multiplier.** The protocol does not do this for the issuer: `mint` and `batchMint` take *raw* amounts. At a multiplier of `1.2`, a subscription worth 120 units of value must mint `120 / 1.2 = 100` raw — minting 120 raw would hand the subscriber 144 units of scaled value and dilute every existing holder. The protocol cannot enforce this; both operations SHOULD be wrapped in an announcement. -**Announcements.** `announce` publishes a disclosure and atomically executes a bundle of operations against the token itself, so that the disclosure and the act it describes are bound together on-chain — the analogue of a corporate-action filing. Each `internalCall` executes against this token preserving the original `msg.sender`, so role checks still apply to the announcer; the bundle may be empty, making a pure disclosure. `id` is single-use (`AnnouncementIdAlreadyUsed`) and is marked used before execution. A call shorter than four bytes fails with `InternalCallMalformed`, a nested `announce` with `AnnouncementInProgress`, and a reverting call with `InternalCallFailed` — the inner reason is not propagated. Any failure rolls back the entire announcement, emitted events included. Indexers pair `Announcement` and `EndAnnouncement` by `id`; every log between them belongs to that disclosure. +**Announcements.** `announce` publishes a disclosure and atomically executes a bundle of operations against the token itself, so that the disclosure and the act it describes are bound together on-chain. Each `internalCall` executes against this token preserving the original `msg.sender`, so role checks still apply to the announcer; the bundle may be empty, making a pure disclosure. `id` is single-use (`AnnouncementIdAlreadyUsed`) and is marked used before execution. A call shorter than four bytes fails with `InternalCallMalformed`, a nested `announce` with `AnnouncementInProgress`, and a reverting call with `InternalCallFailed` — the inner reason is not propagated. Any failure rolls back the entire announcement, emitted events included. Indexers pair `Announcement` and `EndAnnouncement` by `id`; every log between them belongs to that disclosure. **Batch minting.** `batchMint` requires equal-length, non-empty arrays (`LengthMismatch`). The role and pause gates are checked once for the batch, and the per-recipient mints then run privileged to avoid re-checking them — but `MINT_RECEIVER_POLICY` and the supply cap are enforced on **every** recipient individually. The batch is all-or-nothing: one non-compliant recipient reverts the whole call. @@ -541,7 +539,7 @@ It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMu **Relationship to [BEP-677](./BEP-677.md).** BEP-677 brings the same scaling concept to BEP-20 contracts via [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056), and adds *scheduled* changes taking effect at a future timestamp. Two differences are intentional: BEP-677 governs issuer-written code and can only *recommend* multiplier bounds, whereas this standard governs protocol code and enforces them; and this standard defines an immediate multiplier only. Scheduling on the Asset variant is left to a future BEP, which would have to define how a pending change interacts with `pause` and the supply cap. -**Not a yield mechanism.** The multiplier rescales every holder by the same factor, with no opt-in, no per-holder accounting, and nothing separately claimable. It expresses accrual-into-NAV instruments — money-market funds, staking receipts, accumulating notes, stock splits — and cannot express a distribution that is paid out. This standard defines no yield primitive; an issuer needing one builds it at the application layer. +**Not a yield mechanism.** The multiplier rescales every holder by the same factor, with no opt-in, no per-holder accounting, and nothing separately claimable. It can express accrual-into-NAV instruments, not a distribution that is paid out. This standard defines no yield primitive; an issuer needing one builds it at the application layer. This section applies only to `variant = ASSET`. A Stablecoin-variant token does not implement `IB20Asset`, so its dispatch handler never recognizes these selectors and such a call reverts as any unrecognized selector would. @@ -557,9 +555,9 @@ interface IB20Stablecoin { `currency` is an [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) code (`"USD"`, `"EUR"`, `"SGD"`) supplied at creation and immutable thereafter. The factory validates that it is non-empty and uppercase `A–Z` only ([§3.4](#34-b20factory)); it does **not** validate the claim. The code is a self-declaration of denomination, not evidence of reserves, and integrators MUST treat it as such. -The value of putting it on-chain is that denomination becomes machine-readable. A BEP-20 token carries only a free-text `symbol` that anyone may set to `"USDC"`; nothing distinguishes a genuine dollar token from an impostor, and nothing tells a contract whether two tokens are denominated in the same currency at all. A fixed, validated field lets routing, settlement, and FX logic key off denomination without an oracle or a hand-maintained address list. `B20Created` carries the code in `variantEventParams` so indexers can build a token→currency index at creation time. +`B20Created` carries the code in `variantEventParams`, so indexers can build a token→currency index at creation time. -The variant is deliberately narrow: no multiplier, no announcements, no batch mint, no extra metadata, and `decimals` fixed at `6`. A unit of a payment token is expected to mean the same thing tomorrow as today, so nothing that could restate it is exposed. +The variant is deliberately narrow: no multiplier, no announcements, no batch mint, no extra metadata, and `decimals` fixed at `6`. ### 3.14 Gas Accounting @@ -573,24 +571,24 @@ Because B20 operations bypass opcode dispatch, the implementation MUST replicate | Storage write | [EIP-2200](https://eips.ethereum.org/EIPS/eip-2200) net metering over `original`/`current`/`new`, the cold surcharge, and [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529) refunds including reversal on revert | | Reading an account's balance, nonce, or code | EIP-2929 account-access cost: warm always, plus the cold-account surcharge on first touch | | Log emission | Log base cost, per topic, per byte | -| Hashing | Per-word keccak cost. Mapping slots are derived by hashing, so leaving it unmetered would donate computation | +| Hashing | Per-word keccak cost, including mapping-slot derivation | | Calldata | Input words at the keccak per-word rate, once per dispatch, substituting for the ABI-decoding opcodes bytecode would have paid for | -| Writing account code | The existing creation cost, per-byte deposit cost, and keccak of the code. The creation cost is owed whenever the target had no code, **including at a prefunded address** ([§3.3](#33-b20-address-space)). This is what the sentinel ([§3.16](#316-account-sentinel)) pays for | +| Writing account code | The existing creation cost, per-byte deposit cost, and keccak of the code. The creation cost is owed whenever the target had no code, **including at a prefunded address** ([§3.3](#33-b20-address-space)). | -Every row resolves to whatever the active fork's cost function returns, so a later change to the gas schedule propagates automatically. Should BSC adopt a separate state-gas dimension ([EIP-8037](https://eips.ethereum.org/EIPS/eip-8037), [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038); the `StateGas` field already present in BSC's accounting is the shape they assume), B20 inherits it with no amendment here — precisely because it never restated the numbers. +Every row resolves to whatever the active fork's cost function returns, so a later change to the gas schedule propagates automatically. Should BSC adopt a separate state-gas dimension ([EIP-8037](https://eips.ethereum.org/EIPS/eip-8037), [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038); the `StateGas` field already present in BSC's accounting is the shape they assume), B20 inherits it with no amendment here. -**Storage reads are account-agnostic.** A token reads a registry's slot at ordinary storage cost, with no account-access surcharge, exactly as if the slot were its own. This is the one place the model has no bytecode equivalent rather than merely a cheaper one: bytecode must `CALL` the owner and pay the call machinery and a second interpretation frame. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose general foreign-storage reads — the only cross-account reads permitted are a token consulting the two registries for its own gating. +**Storage reads are account-agnostic.** A token reads a registry's slot at ordinary storage cost, with no account-access surcharge, exactly as if the slot were its own. Implementations MUST NOT synthesize an account-access charge for a foreign storage read, and MUST NOT expose general foreign-storage reads — the only cross-account reads permitted are a token consulting the two registries for its own gating. -**Not charged.** The table is exhaustive. Per-opcode execution, memory expansion, call machinery, and stack/jump/arithmetic have no counterpart here, and their absence is the whole of this standard's efficiency claim. An implementation MUST NOT add a synthetic overhead charge to approximate them: the never-cheaper-than-bytecode rule concerns state access, which is charged identically, and a synthetic surcharge would be unfalsifiable. +**Not charged.** The table is exhaustive. Per-opcode execution, memory expansion, call machinery, and stack/jump/arithmetic have no counterpart here. An implementation MUST NOT add a synthetic overhead charge to approximate them: the never-cheaper-than-bytecode rule concerns state access, which is charged identically, and a synthetic surcharge would be unfalsifiable. -**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas stipend, however cheap the write. That check, not the write's own cost, is what makes Solidity's `transfer()`/`send()` safe, since net metering prices a warm dirty rewrite at roughly a hundred gas. A B20 token writes state without executing `SSTORE`, so the implementation MUST apply the same check before any state write. Omitting it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. +**Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas stipend, however cheap the write. That check, not the write's own cost, is what makes Solidity's `transfer()`/`send()` safe. A B20 token writes state without executing `SSTORE`, so the implementation MUST apply the same check before any state write. Omitting it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. **Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound — the 64-address batch limit ([§3.8](#38-transfer-policies)) is one; `batchMint`, `announce`'s call bundle, and every dynamic argument need equivalents. Because no schedule is published, verification is behavioural. Two properties SHOULD be measured before mainnet activation: -- **Parity** — for each entry point, gas charged equals what an equivalent BEP-20 implementation would pay for the same state accesses, and never less. Differential testing against a reference contract establishes it directly, reducing correctness to "was every access accounted for" rather than "is this number right". -- **Wall-clock cost** — worst-case execution time per entry point. The omissions above reduce gas without reducing the work a node does, and a chain targeting sub-second blocks is bounded by time as well as by gas. +- **Parity** — for each entry point, gas charged equals what an equivalent BEP-20 implementation would pay for the same state accesses, and never less. Differential testing against a reference contract establishes it directly. +- **Wall-clock cost** — worst-case execution time per entry point. Gas omissions do not reduce the work a node does, and a chain targeting sub-second blocks is bounded by time as well as by gas. ### 3.15 Feature Activation @@ -622,7 +620,7 @@ interface IActivationRegistry { } ``` -`isActivated` never reverts. `activate` on an already-active feature fails with `AlreadyActivated`, and `deactivate` on an inactive one with `AlreadyDeactivated`, so a no-op governance action is surfaced rather than silently accepted. +`isActivated` never reverts. `activate` on an already-active feature fails with `AlreadyActivated`, and `deactivate` on an inactive one with `AlreadyDeactivated`. A feature is identified by a `bytes32` value, defined as the keccak-256 hash of its canonical name, so that a later BEP can introduce a new feature without modifying this interface: From 89293704001328b34fdb40faf867e0527c5927ec Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 16:09:31 +0800 Subject: [PATCH 39/41] BEP-702: one-sentence Summary per BEP-1 --- BEPs/BEP-702.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index ab2df14f..e8cbb47e 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -53,7 +53,7 @@ ## 1. Summary -This BEP introduces a protocol-native fungible token standard for BSC, in two variants — an **Asset** form carrying a protocol-level rescaling mechanism for tokenized real-world assets, and a deliberately narrow **Stablecoin** form for payment tokens. Tokens created under this standard are not deployed bytecode: they are instantiated through a singleton factory precompile and executed by native node logic, while remaining fully selector- and event-compatible with [BEP-20](./BEP20.md)/[ERC-20](https://eips.ethereum.org/EIPS/eip-20). The standard bundles the access-control, pausability, supply-cap, reconciliation-memo, and transfer-compliance primitives that regulated and institutional issuers currently have to re-implement (and re-audit) individually on top of BEP-20. +This BEP introduces **B20**, a fungible-token standard built into the BSC protocol itself, making a token's behaviour a guarantee of the chain rather than of per-token contract code, while staying fully compatible with existing [BEP-20](./BEP20.md) wallets and applications. ## 2. Motivation From 4ac4d1c8818e743a2e590ea9fb88ce9ed16dc6e8 Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 16:46:35 +0800 Subject: [PATCH 40/41] BEP-702: address review findings on activation scope, conversions, existence, ABI, and bounds --- BEPs/BEP-702.md | 53 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index e8cbb47e..fb466d30 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -53,7 +53,7 @@ ## 1. Summary -This BEP introduces **B20**, a fungible-token standard built into the BSC protocol itself, making a token's behaviour a guarantee of the chain rather than of per-token contract code, while staying fully compatible with existing [BEP-20](./BEP20.md) wallets and applications. +This BEP introduces **B20**, a fungible-token standard built into the BSC protocol itself, making a token's behaviour a guarantee of the chain rather than of per-token contract code, while remaining selector- and event-compatible with existing [BEP-20](./BEP20.md) wallets and applications. ## 2. Motivation @@ -62,7 +62,7 @@ BEP-20 defines an ABI convention, not a guarantee about behaviour: any address m 1. **Guaranteed behaviour.** Hidden mint paths and post-audit proxy upgrades sit comfortably behind a normal-looking BEP-20 interface, so integrators must re-audit every token they touch. Native logic ships through the hard-fork process: what an address claims and what it executes are the same fact. 2. **Compliance primitives, verified once.** Role-gated mint and burn, seizure of frozen balances, granular pause, and supply caps exist in audited libraries — but nothing proves a deployed token runs them unmodified, so the verification cost recurs per token. Built into the protocol, they inherit the guarantee above. 3. **Redenomination for real-world assets.** Rescaling per-unit value across every holder at once, independent of any transfer, has an interface convention ([BEP-677](./BEP-677.md)) but no enforceable semantics — the same claim-versus-guarantee gap again ([§3.12](#312-asset-variant-extensions)). -4. **State-access pricing.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs; native execution charges the state accesses alone ([§3.14](#314-gas-accounting)). +4. **State-access pricing.** A BEP-20 transfer pays full bytecode interpretation and ABI decoding on top of two SLOADs and two SSTOREs; native execution removes that overhead, charging only the metered work ([§3.14](#314-gas-accounting)). All of it sits behind the unchanged BEP-20 selector surface, so existing wallets, indexers, and routers integrate without modification ([§5](#5-backward-compatibility)). @@ -93,7 +93,7 @@ Three independent checks guard B20 operations, each with its own controller: the Every token gets a deterministic 20-byte address in a reserved space ([§3.3](#33-b20-address-space)), holding no executable bytecode — only a one-byte sentinel that is never run ([§3.16](#316-account-sentinel)). Call dispatch is extended to recognize those addresses and route to a shared handler parameterized by the target: the variant byte selects the method set ([§3.6](#36-shared-token-interface) alone for Stablecoin, plus [§3.12](#312-asset-variant-extensions) for Asset), and the address alone selects whose state is read and written. -Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates only creation, never tokens that already exist ([§4.2](#42-a-governance-switch-on-top-of-the-fork)). +Activation happens in two steps. The hard fork in which this standard ships makes the code present on every node; a governance-controlled switch then determines when token creation is permitted on a given network ([§3.15](#315-feature-activation)). The switch gates token creation and PolicyRegistry writes, never operations on existing tokens ([§4.2](#42-a-governance-switch-on-top-of-the-fork)). ### 3.2 Stateful Precompiled Contracts @@ -146,11 +146,11 @@ The unrecognized-variant row is unreachable on a chain a node is following — a Because a value-bearing call is refused across the recognized-variant reserved space whether or not a token exists there, an ordinary transfer cannot fund such an address; `SELFDESTRUCT` still can, crediting the beneficiary with no call to refuse. Balance therefore says nothing about existence, and existence MUST NOT be inferred from it, nor from nonce: `createB20` at a prefunded address succeeds normally. This standard gives such a balance no meaning and no way out. -Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `addr`'s code hash equals the sentinel's — an exact comparison, not a non-empty test. +Existence is instead determined by the account sentinel ([§3.16](#316-account-sentinel)): `isB20Initialized(addr)` is true exactly when `isB20(addr)` holds **and** `addr`'s code hash equals the sentinel's — an exact comparison, not a non-empty test, and false for any address outside the token space, the sentinel-bearing registries included. An account occupying a recognized-variant reserved address becomes unreachable at activation: calls route to the token handler and its own code never runs. An implementation MUST verify before activation that the reserved space is unoccupied, and `createB20` MUST reject a derived address that already carries code ([§3.4](#34-b20factory)). Balance is deliberately not part of either check. -That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so recovering addresses requires the preimage table, which a node retains only if synced with preimages enabled. A node without it recovers no addresses at all, which is indistinguishable from a clean result — an implementation MUST therefore confirm the scanned set was non-empty before treating the outcome as a pass, or reconstruct it from chain history. +That verification is not a range scan: the state trie is keyed by `keccak256(address)`, so a scan is only as good as its address source, and an incomplete source — a partial preimage table, most commonly — produces a false clean. An implementation MUST derive the address set from a source whose completeness is checkable against the state itself: the full chain history from genesis, or a snapshot whose account count matches the trie's. The checked block height and result SHOULD be recorded alongside the upgrade configuration. ### 3.4 B20Factory @@ -192,12 +192,27 @@ interface IB20Factory { } ``` -Creation parameters are variant-specific: +`params` is the `abi.encode` of the variant's creation struct: -| Variant | Fields | Validation | -|---|---|---| -| `ASSET` | `name`, `symbol`, `initialAdmin`, `decimals` | `decimals` in `[6, 18]`, else `InvalidDecimals` | -| `STABLECOIN` | `name`, `symbol`, `initialAdmin`, `currency` | `currency` non-empty (`MissingRequiredField`) and uppercase `A–Z` only (`InvalidCurrency`); `decimals` is fixed at `6` and not stored | +```solidity +struct B20AssetCreateParams { + uint8 version; // this revision: 1 + string name; + string symbol; + address initialAdmin; // zero address for an admin-less token (see below) + uint8 decimals; // in [6, 18], else InvalidDecimals +} + +struct B20StablecoinCreateParams { + uint8 version; // this revision: 1 + string name; + string symbol; + address initialAdmin; + string currency; // non-empty (MissingRequiredField), uppercase A-Z only (InvalidCurrency) +} +``` + +A `version` other than the active revision's fails with `UnsupportedVersion(version, variant)`, checked before any field so that version errors take precedence; calldata that does not decode as the variant's struct fails with `AbiDecodeFailed`. The Stablecoin's `decimals` is fixed at `6` and is not a parameter. In `B20Created`, `variantEventParams` is empty for `ASSET`, and for `STABLECOIN` is `abi.encode(B20StablecoinEventParams{version: 1, currency})`. `createB20` proceeds in order: resolve `creator = msg.sender`; confirm the variant's feature is activated ([§3.15](#315-feature-activation)), else `FeatureNotActivated`; derive the address ([§3.3](#33-b20-address-space)); validate `params`; reject a derived address that already carries code (`TokenAlreadyExists` — retry with a different `salt`); **write the account sentinel** ([§3.16](#316-account-sentinel)); write the initial storage; execute `initCalls`; emit `B20Created`. @@ -278,7 +293,7 @@ interface IB20 { - `seizeWithMemo` reassigns `from`'s balance to `to` without an allowance and without the transfer policies, leaving `totalSupply` unchanged. `from` is seizable only when `SEIZE_HOLDER_POLICY` does **not** authorize it (`AccountNotSeizable` otherwise); `to` must be authorized by `SEIZE_RECEIVER_POLICY` ([§3.8](#38-transfer-policies)). It emits `Transfer`, `Memo`, and `Seized` in that order. - `updateName`/`updateSymbol`/`updateContractURI` rewrite the corresponding metadata and emit `NameUpdated`/`SymbolUpdated`/`ContractURIUpdated`. `updateName` additionally rotates the EIP-712 domain separator ([§3.11](#311-permit-eip-2612)), emitting `EIP712DomainChanged`. `ContractURIUpdated` carries no arguments, following [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572); integrators MUST re-read `contractURI()` on observing it. -**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. `seizeWithMemo` alone has no memo-less form. Zero is permitted. Both of the event's fields are indexed so an indexer can filter by payer or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain ([§4.6](#46-memo-as-a-separate-event)). +**Memo.** Each of `transfer`, `transferFrom`, `mint`, and `burn` has a `…WithMemo` counterpart taking a 32-byte `memo`, behaving identically to the base method plus one `Memo` event emitted immediately after the operation's own event. `seizeWithMemo` alone has no memo-less form. Zero is permitted. Both of the event's fields are indexed so an indexer can filter by caller or by reference without scanning. Payloads over 32 bytes, or carrying personal data, SHOULD be committed by hash and held off-chain ([§4.6](#46-memo-as-a-separate-event)). On an Asset-variant token, every method above reports and moves raw balances only; [§3.12](#312-asset-variant-extensions) layers the display-facing multiplier on top without touching this interface. @@ -411,7 +426,7 @@ interface IPolicyRegistry { } ``` -A `BLOCKLIST` authorizes everyone except the accounts added to it; an `ALLOWLIST` authorizes no one except the accounts added. Membership updates are type-checked: calling `updateAllowlist` on a `BLOCKLIST` fails with `IncompatiblePolicyType`. Each batch is limited to **64 addresses** (`BatchSizeTooLarge`), which bounds the work a single call can do — a requirement of [§3.14](#314-gas-accounting) for any entry point whose cost scales with its input. Adding an existing member or removing an absent one is idempotent. +A `BLOCKLIST` authorizes everyone except the accounts added to it; an `ALLOWLIST` authorizes no one except the accounts added. Membership updates are type-checked: calling `updateAllowlist` on a `BLOCKLIST` fails with `IncompatiblePolicyType`. Each batch is limited to **64 addresses** (`BatchSizeTooLarge`). Adding an existing member or removing an absent one is idempotent. **Admin lifecycle.** Transfer is two-step: the incumbent nominates with `stageUpdateAdmin` (zero address cancels) and the nominee must call `finalizeUpdateAdmin` to take over, which proves the destination is controlled. `renounceAdmin` permanently freezes a policy: membership can never change again while reads keep working. A frozen policy remains distinguishable from one never created: the first is judged against its final membership, the second as the empty set. @@ -523,7 +538,9 @@ interface IB20Asset { } ``` -`balanceOf` returns the raw, unscaled balance, and the multiplier never changes what `transfer`, `transferFrom`, `mint`, or `burn` move — only the three conversion views are affected. `scaledBalanceOf(account)` equals `toScaledBalance(balanceOf(account))` in one round trip. Because the conversion divides by `1e18`, a raw→scaled→raw round trip need not reproduce the original: callers requiring exact accounting MUST treat the raw balance as authoritative and the scaled value as display only. +`balanceOf` returns the raw, unscaled balance, and the multiplier never changes what `transfer`, `transferFrom`, `mint`, or `burn` move — only the three conversion views are affected. `scaledBalanceOf(account)` equals `toScaledBalance(balanceOf(account))` in one round trip. + +The conversions are `toScaledBalance(x) = ⌊x · multiplier / 1e18⌋` and `toRawBalance(y) = ⌊y · 1e18 / multiplier⌋`, with the product computed in `uint256` under checked arithmetic: a product that overflows reverts with an arithmetic panic, exactly as checked Solidity math would. Overflow is unreachable for stored balances ([§6.5](#65-multiplier-bounds-and-precision)) but not for arbitrary arguments, which the two view functions accept. Because the conversion floors, a raw→scaled→raw round trip need not reproduce the original: callers requiring exact accounting MUST treat the raw balance as authoritative and the scaled value as display only. `updateMultiplier` is gated by `OPERATOR_ROLE` ([§4.5](#45-separate-roles-for-mint-burn-seizure-and-rescaling)). It MUST reject a `newMultiplier` of zero, which would zero every scaled balance permanently. @@ -533,7 +550,7 @@ It MUST also reject a `newMultiplier` above `type(uint128).max`, with `InvalidMu **Announcements.** `announce` publishes a disclosure and atomically executes a bundle of operations against the token itself, so that the disclosure and the act it describes are bound together on-chain. Each `internalCall` executes against this token preserving the original `msg.sender`, so role checks still apply to the announcer; the bundle may be empty, making a pure disclosure. `id` is single-use (`AnnouncementIdAlreadyUsed`) and is marked used before execution. A call shorter than four bytes fails with `InternalCallMalformed`, a nested `announce` with `AnnouncementInProgress`, and a reverting call with `InternalCallFailed` — the inner reason is not propagated. Any failure rolls back the entire announcement, emitted events included. Indexers pair `Announcement` and `EndAnnouncement` by `id`; every log between them belongs to that disclosure. -**Batch minting.** `batchMint` requires equal-length, non-empty arrays (`LengthMismatch`). The role and pause gates are checked once for the batch, and the per-recipient mints then run privileged to avoid re-checking them — but `MINT_RECEIVER_POLICY` and the supply cap are enforced on **every** recipient individually. The batch is all-or-nothing: one non-compliant recipient reverts the whole call. +**Batch minting.** `batchMint` requires equal-length arrays (`LengthMismatch`) and a non-empty batch (`EmptyBatch`). The role and pause gates are checked once for the batch, and the per-recipient mints then run privileged to avoid re-checking them — but `MINT_RECEIVER_POLICY` and the supply cap are enforced on **every** recipient individually. The batch is all-or-nothing: one non-compliant recipient reverts the whole call. **Extra metadata.** A free-form `string => string` map for issuer-defined attributes (`"isin"`, `"category"`, `"region"`) that institutional systems read. An unset key returns the empty string; writing the empty string deletes the entry. An empty key is rejected with `InvalidMetadataKey`. @@ -583,7 +600,7 @@ Every row resolves to whatever the active fork's cost function returns, so a lat **Reentrancy sentry.** EIP-2200 halts `SSTORE` with an out-of-gas exception whenever remaining gas is at or below the 2300-gas stipend, however cheap the write. That check, not the write's own cost, is what makes Solidity's `transfer()`/`send()` safe. A B20 token writes state without executing `SSTORE`, so the implementation MUST apply the same check before any state write. Omitting it would retroactively invalidate the reentrancy assumption that already-deployed, unchangeable contracts were audited against. -**Bounded inputs.** Deriving cost from work done is only safe if the work is bounded. Every entry point whose cost scales with its input MUST carry a protocol-level bound — the 64-address batch limit ([§3.8](#38-transfer-policies)) is one; `batchMint`, `announce`'s call bundle, and every dynamic argument need equivalents. +**Variable-size inputs are bounded by gas.** Because every unit of work — each calldata word, each per-recipient mint, each log byte — is charged as it is performed, a variable-size input needs no protocol-level count limit: the transaction gas limit is the bound, exactly as it is for bytecode. The one fixed count limit is the 64-address policy batch ([§3.8](#38-transfer-policies)). Because no schedule is published, verification is behavioural. Two properties SHOULD be measured before mainnet activation: @@ -736,13 +753,13 @@ This logic runs as native client code, not as an isolated contract. A single imp ### 6.2 Activation Authority -The worst outcome of a compromised or misused activation key is bounded by what the switch reaches ([§3.15](#315-feature-activation)): issuance halts, or a feature opens earlier than intended — never a frozen or moved balance. Vesting the key in the timelock keeps the "opened too early" direction under ordinary governance review. +The worst outcome of a compromised or misused activation key is bounded by what the switch reaches ([§3.15](#315-feature-activation)): issuance halts, policy lists freeze where they stand — which can prolong an account's blocked state by preventing its removal — or a feature opens earlier than intended. Balances can never be moved. Vesting the key in the timelock keeps the "opened too early" direction under ordinary governance review. ### 6.3 Identity-Fingerprint Strength A token address carries a 9-byte (72-bit) fingerprint of `keccak256(creator ++ salt)`. Finding *any* colliding pair gains an attacker nothing, because `createB20` rejects an address that already exists. The attack that matters is targeted: `getB20Address` lets an issuer publish an address before creating it, and an attacker who finds a second preimage for that specific fingerprint — work on the order of 2^72 — can create the token first with itself as `initialAdmin`, then mint at will against an address integrators have already committed to. -72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space. The current split favours the prefix, on the grounds that a fingerprint collision lets an attacker seize an address an issuer has already published, whereas grinding into the space only bricks the squatter's own account ([§3.3](#33-b20-address-space)); an implementation that revisits it SHOULD keep the reserved prefix at no fewer than eight bytes. Independently of the split, issuers SHOULD create a token before publishing its address rather than relying on prediction across a long window. +72 bits is below the level considered adequate for new systems. The eight padding bytes ([§3.3](#33-b20-address-space)) are where a remedy would come from, and the trade-off is direct: every byte moved from padding into the fingerprint raises the targeted-collision cost and lowers the cost of grinding an account into the reserved space. The current split favours the prefix, on the grounds that a fingerprint collision lets an attacker seize an address an issuer has already published, whereas grinding into the space only bricks the squatter's own account ([§3.3](#33-b20-address-space)); an implementation that revisits it SHOULD keep the reserved prefix at no fewer than eight bytes. Independently of the split, prediction is not commitment: issuers SHOULD create a token before publishing its address, and integrators MUST NOT bind approvals, balances, or documentation to a predicted address until `isB20Initialized` is true. ### 6.4 Privileged Keys @@ -754,7 +771,7 @@ Three authorities carry risk that no protocol rule can bound: ### 6.5 Multiplier Bounds and Precision -Overflow in `toScaledBalance`/`toRawBalance` is prevented structurally rather than arithmetically: the supply cap ([§3.10](#310-supply-cap)) and the multiplier ([§3.12](#312-asset-variant-extensions)) are each bounded by `type(uint128).max`, so the intermediate product cannot exceed `uint256` and the quotient cannot either. Implementations MUST enforce both bounds at write time; relaxing either would require reintroducing a wide intermediate *and* defining what a scaled read returns when the result itself exceeds `uint256` — a case the bounds make unreachable. +For stored balances, overflow in the conversions is prevented structurally: the supply cap ([§3.10](#310-supply-cap)) and the multiplier ([§3.12](#312-asset-variant-extensions)) are each bounded by `type(uint128).max`, so the product fits `uint256`. Implementations MUST enforce both bounds at write time. For the arbitrary arguments the view functions accept, no structural bound exists; the checked product of [§3.12](#312-asset-variant-extensions) reverts there instead. What the bounds do not remove is precision loss. Every multiplier-derived read floors, so an extreme multiplier can still round a small holder's scaled balance down to zero, and a raw→scaled→raw round trip can lose up to one unit. Issuers SHOULD bound the multiplier range they are willing to set at the application layer, and any accounting that must be exact SHOULD treat the raw balance as authoritative. From 2a020a9cf860e0d04b2f6f05bc00178608f5b56f Mon Sep 17 00:00:00 2001 From: qybdyx Date: Wed, 5 Aug 2026 17:16:45 +0800 Subject: [PATCH 41/41] BEP-702: align ActivationRegistry ABI and calldata-cost basis with Base --- BEPs/BEP-702.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/BEPs/BEP-702.md b/BEPs/BEP-702.md index fb466d30..76c0ec28 100644 --- a/BEPs/BEP-702.md +++ b/BEPs/BEP-702.md @@ -589,7 +589,7 @@ Because B20 operations bypass opcode dispatch, the implementation MUST replicate | Reading an account's balance, nonce, or code | EIP-2929 account-access cost: warm always, plus the cold-account surcharge on first touch | | Log emission | Log base cost, per topic, per byte | | Hashing | Per-word keccak cost, including mapping-slot derivation | -| Calldata | Input words at the keccak per-word rate, once per dispatch, substituting for the ABI-decoding opcodes bytecode would have paid for | +| Calldata | Data-copy plus memory cost per 32-byte input word (`G_copy + G_memory`, 6 gas today), once per dispatch — the cost bytecode would pay to read its own calldata | | Writing account code | The existing creation cost, per-byte deposit cost, and keccak of the code. The creation cost is owed whenever the target had no code, **including at a prefunded address** ([§3.3](#33-b20-address-space)). | Every row resolves to whatever the active fork's cost function returns, so a later change to the gas schedule propagates automatically. Should BSC adopt a separate state-gas dimension ([EIP-8037](https://eips.ethereum.org/EIPS/eip-8037), [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038); the `StateGas` field already present in BSC's accounting is the shape they assume), B20 inherits it with no amendment here. @@ -615,13 +615,12 @@ Shipping the code and permitting its use are separate decisions ([§4](#4-ration interface IActivationRegistry { event FeatureActivated(bytes32 indexed feature, address indexed caller); event FeatureDeactivated(bytes32 indexed feature, address indexed caller); - event ActivationAdminChanged(address indexed previousAdmin, address indexed newAdmin); + event AdminChanged(address indexed previousAdmin, address indexed newAdmin, address indexed caller); error FeatureNotActivated(bytes32 feature); error AlreadyActivated(bytes32 feature); - error AlreadyDeactivated(bytes32 feature); - error NotActivationAdmin(address caller); - error ZeroActivationAdmin(); + error Unauthorized(address caller); + error ZeroAdminAddress(); function isActivated(bytes32 feature) external view returns (bool); @@ -631,13 +630,13 @@ interface IActivationRegistry { function admin() external view returns (address); - function activate(bytes32 feature) external; // activation admin only - function deactivate(bytes32 feature) external; // activation admin only - function setActivationAdmin(address newAdmin) external; // activation admin only + function activate(bytes32 feature) external; // activation admin only + function deactivate(bytes32 feature) external; // activation admin only + function setAdmin(address newAdmin) external; // activation admin only; zero reverts ZeroAdminAddress } ``` -`isActivated` never reverts. `activate` on an already-active feature fails with `AlreadyActivated`, and `deactivate` on an inactive one with `AlreadyDeactivated`. +`isActivated` never reverts. `activate` on an already-active feature fails with `AlreadyActivated`; `deactivate` on an inactive one reuses `FeatureNotActivated` — there is no separate already-deactivated error. A caller that is not the admin fails with `Unauthorized(caller)`; the zero address is never a valid admin, and a zero caller is likewise `Unauthorized`. A feature is identified by a `bytes32` value, defined as the keccak-256 hash of its canonical name, so that a later BEP can introduce a new feature without modifying this interface: @@ -661,7 +660,7 @@ A token's dispatch entry point performs no activation check at all. This is a no Deactivating a feature stops creation, and for the PolicyRegistry it also stops membership and admin updates on policies that already exist — every non-view method sits behind the gate. What it never reaches is a token: tokens already created keep working exactly as before, reads included. An issuer relying on a shared policy for live compliance should note that a deactivation freezes that list where it stands. Freezing activity on a specific token remains the issuer's decision, exercised through that token's own `pause` ([§3.9](#39-pause)), not something a chain operator can do through this switch. Reads are never gated because `isAuthorized` sits on the path of every transfer, and a network-level switch must not be able to make transfers fail. -**Authority.** `activationAdmin` MUST be governance-controlled — BSC's timelock is the intended holder — and MUST be rotatable through `setActivationAdmin` from activation onward, so a compromised key needs no hard fork to replace. Its initial value comes from chain configuration; a zero address means nothing can be activated on that network. The three mutating methods reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)), and resolving a flag is charged as a storage read ([§3.14](#314-gas-accounting)). +**Authority.** `activationAdmin` MUST be governance-controlled — BSC's timelock is the intended holder — and MUST be rotatable through `setAdmin` from activation onward, so a compromised key needs no hard fork to replace. Its initial value comes from chain configuration; a zero address means nothing can be activated on that network. The three mutating methods reject static and delegated calls on the same terms as every other entry point ([§3.2](#32-stateful-precompiled-contracts)), and resolving a flag is charged as a storage read ([§3.14](#314-gas-accounting)). ### 3.16 Account Sentinel